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
+743
-16
No files matched your search
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
class CountryLanguages
|
||||
{
|
||||
private const MAP = [
|
||||
'AE' => 'ar', 'AF' => 'ps', 'AL' => 'sq', 'AM' => 'hy', 'AO' => 'pt',
|
||||
'AR' => 'es', 'AT' => 'de', 'AU' => 'en', 'AZ' => 'az',
|
||||
'BA' => 'bs', 'BD' => 'bn', 'BE' => 'nl', 'BG' => 'bg', 'BH' => 'ar',
|
||||
'BO' => 'es', 'BR' => 'pt', 'BY' => 'be',
|
||||
'CA' => 'en', 'CH' => 'de', 'CL' => 'es', 'CN' => 'zh', 'CO' => 'es',
|
||||
'CR' => 'es', 'CU' => 'es', 'CZ' => 'cs',
|
||||
'DE' => 'de', 'DK' => 'da', 'DO' => 'es', 'DZ' => 'ar',
|
||||
'EC' => 'es', 'EE' => 'et', 'EG' => 'ar', 'ES' => 'es', 'ET' => 'am',
|
||||
'FI' => 'fi', 'FR' => 'fr',
|
||||
'GB' => 'en', 'GE' => 'ka', 'GR' => 'el', 'GT' => 'es',
|
||||
'HK' => 'zh', 'HN' => 'es', 'HR' => 'hr', 'HU' => 'hu',
|
||||
'ID' => 'id', 'IE' => 'en', 'IL' => 'he', 'IN' => 'hi', 'IQ' => 'ar',
|
||||
'IR' => 'fa', 'IS' => 'is', 'IT' => 'it',
|
||||
'JO' => 'ar', 'JP' => 'ja',
|
||||
'KE' => 'sw', 'KG' => 'ky', 'KH' => 'km', 'KR' => 'ko', 'KW' => 'ar',
|
||||
'KZ' => 'kk',
|
||||
'LA' => 'lo', 'LB' => 'ar', 'LK' => 'si', 'LT' => 'lt', 'LU' => 'fr',
|
||||
'LV' => 'lv', 'LY' => 'ar',
|
||||
'MA' => 'ar', 'MD' => 'ro', 'ME' => 'sr', 'MK' => 'mk', 'MM' => 'my',
|
||||
'MN' => 'mn', 'MT' => 'mt', 'MX' => 'es', 'MY' => 'ms',
|
||||
'NG' => 'en', 'NI' => 'es', 'NL' => 'nl', 'NO' => 'no', 'NP' => 'ne',
|
||||
'NZ' => 'en',
|
||||
'OM' => 'ar',
|
||||
'PA' => 'es', 'PE' => 'es', 'PH' => 'tl', 'PK' => 'ur', 'PL' => 'pl',
|
||||
'PR' => 'es', 'PS' => 'ar', 'PT' => 'pt', 'PY' => 'es',
|
||||
'QA' => 'ar',
|
||||
'RO' => 'ro', 'RS' => 'sr', 'RU' => 'ru',
|
||||
'SA' => 'ar', 'SD' => 'ar', 'SE' => 'sv', 'SG' => 'en', 'SI' => 'sl',
|
||||
'SK' => 'sk', 'SO' => 'so', 'SV' => 'es', 'SY' => 'ar',
|
||||
'TH' => 'th', 'TJ' => 'tg', 'TN' => 'ar', 'TR' => 'tr', 'TW' => 'zh',
|
||||
'TZ' => 'sw',
|
||||
'UA' => 'uk', 'UG' => 'en', 'US' => 'en', 'UY' => 'es', 'UZ' => 'uz',
|
||||
'VE' => 'es', 'VN' => 'vi',
|
||||
'YE' => 'ar',
|
||||
'ZA' => 'en', 'ZW' => 'en',
|
||||
];
|
||||
|
||||
public static function primary(?string $iso2): ?string
|
||||
{
|
||||
if (! $iso2) return null;
|
||||
return self::MAP[strtoupper($iso2)] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Models\Video;
|
||||
use App\Notifications\NewCommentLikeNotification;
|
||||
use App\Notifications\NewCommentNotification;
|
||||
use App\Notifications\NewReplyNotification;
|
||||
use App\Services\LlmLyricsService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
|
||||
@@ -15,7 +16,7 @@ class CommentController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index']);
|
||||
$this->middleware('auth')->except(['index', 'translate']);
|
||||
}
|
||||
|
||||
public function index(Video $video)
|
||||
@@ -34,6 +35,29 @@ class CommentController extends Controller
|
||||
|
||||
$commenter = Auth::user();
|
||||
|
||||
// Idempotency guard: a client that retries (duplicate listeners after an
|
||||
// SPA swap, fast double-click, a flaky connection) can POST the identical
|
||||
// body twice. Treat an identical comment from the same user to the same
|
||||
// video within 5 seconds as the same submission and return the first one.
|
||||
$existing = $video->comments()
|
||||
->where('user_id', $commenter->id)
|
||||
->where('body', $request->body)
|
||||
->where(function ($q) use ($request) {
|
||||
$request->parent_id
|
||||
? $q->where('parent_id', $request->parent_id)
|
||||
: $q->whereNull('parent_id');
|
||||
})
|
||||
->where('created_at', '>=', now()->subSeconds(5))
|
||||
->latest('id')
|
||||
->first();
|
||||
|
||||
if ($existing) {
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'comment' => $existing->load('user:id,name,avatar'),
|
||||
]);
|
||||
}
|
||||
|
||||
$comment = $video->comments()->create([
|
||||
'user_id' => $commenter->id,
|
||||
'body' => $request->body,
|
||||
@@ -89,6 +113,37 @@ class CommentController extends Controller
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
public function translate(Request $request, Comment $comment, LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Derive a source-language hint from the comment author's most-frequent
|
||||
// access country (video_views + profile_visits, falling back to their
|
||||
// nationality setting). Never trust the client for this — it's used
|
||||
// to disambiguate scripts the LLM might otherwise misidentify.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$comment->user_id]);
|
||||
$sourceCountry = $topCountries[$comment->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translated = $llm->translate((string) $comment->body, $target, $sourceLang);
|
||||
|
||||
if ($translated === '') {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'translated' => $translated,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country'=> $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function like(Comment $comment)
|
||||
{
|
||||
$userId = Auth::id();
|
||||
|
||||
@@ -26,7 +26,7 @@ class VideoController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress']);
|
||||
$this->middleware('auth')->except(['index', 'show', 'search', 'stream', 'hls', 'trending', 'shorts', 'download', 'downloadMp3', 'recordShare', 'ogImage', 'vsPreview', 'accessShare', 'showByToken', 'recommendations', 'slideshowProgress', 'playerData', 'streamAudioTrack', 'lyricsProgress', 'translateDescription']);
|
||||
}
|
||||
|
||||
public function index()
|
||||
@@ -913,6 +913,53 @@ class VideoController extends Controller
|
||||
* mirror + the NAS copy and removes any in-flight progress / temp files,
|
||||
* so the next Generate produces a fresh result.
|
||||
*/
|
||||
public function translateDescription(Request $request, Video $video, \App\Services\LlmLyricsService $llm)
|
||||
{
|
||||
if (! $llm->isEnabled()) {
|
||||
return response()->json(['error' => 'Translation is not configured'], 503);
|
||||
}
|
||||
|
||||
$html = (string) $video->description;
|
||||
if (trim($html) === '') {
|
||||
return response()->json(['error' => 'Nothing to translate'], 400);
|
||||
}
|
||||
// Flatten the rich description → plain text, keeping paragraph breaks
|
||||
// so the client can render original and translation line-by-line.
|
||||
$plain = trim(html_entity_decode(strip_tags(
|
||||
preg_replace(['/<\/p>/i', '/<br\s*\/?>/i'], ["\n", "\n"], $html)
|
||||
), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
|
||||
$lines = preg_split('/\R/u', $plain);
|
||||
|
||||
$target = (string) $request->input('target', 'en');
|
||||
|
||||
// Source language inferred from the owner's access-history country,
|
||||
// falling back to users.nationality.
|
||||
$topCountries = \App\Models\User::topCountriesFor([$video->user_id]);
|
||||
$sourceCountry = $topCountries[$video->user_id] ?? null;
|
||||
$sourceLang = \App\Data\CountryLanguages::primary($sourceCountry);
|
||||
|
||||
$translatedLines = $llm->translateLines($lines, $target, $sourceLang);
|
||||
if (! $translatedLines || count($translatedLines) !== count($lines)) {
|
||||
return response()->json(['error' => 'Translation failed'], 502);
|
||||
}
|
||||
|
||||
$segments = [];
|
||||
foreach ($lines as $i => $src) {
|
||||
$segments[] = [
|
||||
'original' => (string) $src,
|
||||
'translated' => (string) ($translatedLines[$i] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'segments' => $segments,
|
||||
'target' => $target,
|
||||
'source_hint' => $sourceLang,
|
||||
'author_country' => $sourceCountry,
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteLyrics(Request $request, Video $video)
|
||||
{
|
||||
if (Auth::id() !== $video->user_id) {
|
||||
|
||||
@@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
@@ -56,8 +58,57 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
'password' => 'hashed',
|
||||
'two_factor_enabled' => 'boolean',
|
||||
'notification_preferences' => 'array',
|
||||
// TAKEONE account link. The token is a live credential for that
|
||||
// platform, so it is encrypted at rest and never sent back to a browser.
|
||||
'takeone_token' => 'encrypted',
|
||||
'takeone_connected_at' => 'datetime',
|
||||
];
|
||||
|
||||
/**
|
||||
* Can this user run TAKEONE lookups?
|
||||
*
|
||||
* A super admin always can: the platform holds a TAKEONE super-admin token
|
||||
* on their behalf, so they are connected with full reach without ever
|
||||
* pasting anything. Everyone else links their own account, and sees only
|
||||
* what that account may see.
|
||||
*/
|
||||
public function takeoneLinked(): bool
|
||||
{
|
||||
return $this->takeoneToken() !== null;
|
||||
}
|
||||
|
||||
/** True when the connection is the platform's, not one this user pasted. */
|
||||
public function takeoneManagedLink(): bool
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
return ($own === null || $own === '')
|
||||
&& $this->isSuperAdmin()
|
||||
&& ! empty(config('services.takeone.admin_token'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The token to talk to TAKEONE with. A personally linked account always
|
||||
* wins, so a super admin who deliberately links their own identity is not
|
||||
* silently upgraded back to the platform credential.
|
||||
*/
|
||||
public function takeoneToken(): ?string
|
||||
{
|
||||
$own = $this->takeone_token;
|
||||
|
||||
if ($own !== null && $own !== '') {
|
||||
return $own;
|
||||
}
|
||||
|
||||
if ($this->isSuperAdmin()) {
|
||||
$platform = config('services.takeone.admin_token');
|
||||
|
||||
return ! empty($platform) ? $platform : null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Defaults for each preference key (true = on, false = off)
|
||||
private const NOTIF_DEFAULTS = [
|
||||
'notif_new_comment' => true,
|
||||
@@ -105,6 +156,55 @@ class User extends Authenticatable implements MustVerifyEmail
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the most-frequent ISO2 country for each user id, aggregating
|
||||
* video_views + profile_visits and falling back to users.nationality.
|
||||
* Returns [user_id => 'BH', …]; keys without data are omitted.
|
||||
*/
|
||||
public static function topCountriesFor(array $userIds): array
|
||||
{
|
||||
$userIds = array_values(array_unique(array_filter($userIds, 'is_numeric')));
|
||||
if (! $userIds) return [];
|
||||
|
||||
$tally = [];
|
||||
$rows = DB::table('video_views')
|
||||
->selectRaw('user_id, country, COUNT(*) as c')
|
||||
->whereIn('user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
if (Schema::hasTable('profile_visits')) {
|
||||
$rows = DB::table('profile_visits')
|
||||
->selectRaw('visitor_user_id as user_id, country, COUNT(*) as c')
|
||||
->whereIn('visitor_user_id', $userIds)
|
||||
->whereNotNull('country')->where('country', '!=', '')
|
||||
->groupBy('visitor_user_id', 'country')->get();
|
||||
foreach ($rows as $r) {
|
||||
$cc = strtoupper($r->country);
|
||||
$tally[(int) $r->user_id][$cc] = ($tally[(int) $r->user_id][$cc] ?? 0) + (int) $r->c;
|
||||
}
|
||||
}
|
||||
|
||||
$out = [];
|
||||
foreach ($tally as $uid => $counts) {
|
||||
arsort($counts);
|
||||
$out[$uid] = array_key_first($counts);
|
||||
}
|
||||
|
||||
// Fallback to users.nationality for anyone still unresolved.
|
||||
$missing = array_values(array_diff($userIds, array_keys($out)));
|
||||
if ($missing) {
|
||||
$nats = static::whereIn('id', $missing)
|
||||
->whereNotNull('nationality')->where('nationality', '!=', '')
|
||||
->pluck('nationality', 'id');
|
||||
foreach ($nats as $uid => $cc) $out[(int) $uid] = strtoupper($cc);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
public static function generateUniqueUsername(string $name): string
|
||||
{
|
||||
$base = substr(Str::slug(trim($name)), 0, 18);
|
||||
|
||||
@@ -191,16 +191,176 @@ class LlmLyricsService
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Translate arbitrary text (e.g. a user comment) to a target language.
|
||||
* Returns the translated string, or '' on any failure / when disabled.
|
||||
*/
|
||||
public function translate(string $text, string $targetLang = 'en', ?string $sourceLangHint = null): string
|
||||
{
|
||||
$text = trim($text);
|
||||
if ($text === '' || ! $this->isEnabled()) return '';
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
$cacheKey = 'llm_translate_v4:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1($text);
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_string($cached) && $cached !== '') return $cached;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the text is in that language unless it is OBVIOUSLY in a different language (e.g. uses a different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate the following text into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output ONLY the translated text. No prose, no quotes, no language label, no notes.\n"
|
||||
. " - Translate EVERY word of the source language, including short exclamations,\n"
|
||||
. " repeated words, and words that happen to also look like a name in the target\n"
|
||||
. " language. Example: Portuguese \"Linda, linda, linda\" → \"Beautiful, beautiful,\n"
|
||||
. " beautiful\" (NOT the English name \"Linda\"). Spanish \"hola\" → \"hello\".\n"
|
||||
. " - A word is a proper noun ONLY if clearly a person/place/brand name in context\n"
|
||||
. " (e.g. \"My name is Linda\"). If it stands alone or is repeated as an exclamation,\n"
|
||||
. " it is almost certainly a regular word — translate it.\n"
|
||||
. " - If the text is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions (e.g. @alice), hashtags, URLs, and timestamp tokens\n"
|
||||
. " like @1:23 or @01:23-02:45 EXACTLY as written.\n"
|
||||
. " - Keep the same tone and line breaks.\n\n"
|
||||
. "TEXT:\n" . $text;
|
||||
|
||||
// Translation is deterministic — temp 0. If the model echoes the input
|
||||
// (common when a word happens to also exist as a name in the target
|
||||
// language, e.g. Portuguese "Linda" → English "Linda"), retry once
|
||||
// with a stronger directive. If the retry also echoes, we return it.
|
||||
// Comments cap at 1000 chars → 400 tokens is plenty of headroom and
|
||||
// keeps the model from drifting into long completions on tiny inputs.
|
||||
$budget = max(128, min(1024, (int) ceil(mb_strlen($text) * 1.5) + 64));
|
||||
$raw = $this->postProcess($this->call($prompt, $budget, 0.0));
|
||||
$needsRetry = $sourceLangHint
|
||||
&& $sourceLangHint !== $targetLang
|
||||
&& self::looksLikeEcho($text, $raw);
|
||||
if ($needsRetry) {
|
||||
$retryPrompt = $prompt
|
||||
. "\n\nIMPORTANT: Your previous attempt was identical to the source. The source is\n"
|
||||
. "DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$retry = $this->postProcess($this->call($retryPrompt, $budget, 0.0));
|
||||
if ($retry !== '' && ! self::looksLikeEcho($text, $retry)) $raw = $retry;
|
||||
}
|
||||
if ($raw === '') return '';
|
||||
|
||||
Cache::put($cacheKey, $raw, now()->addDays(30));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function postProcess(string $raw): string
|
||||
{
|
||||
$raw = trim($raw);
|
||||
$raw = trim(preg_replace('/^```[a-zA-Z]*\s*|\s*```$/m', '', $raw));
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/** Normalised equality: ignore case, whitespace, punctuation, emojis. */
|
||||
private static function looksLikeEcho(string $src, string $out): bool
|
||||
{
|
||||
if ($out === '') return false;
|
||||
$norm = fn (string $s) => mb_strtolower(preg_replace('/[^\p{L}\p{N}]+/u', '', $s) ?? '');
|
||||
$a = $norm($src); $b = $norm($out);
|
||||
return $a !== '' && $a === $b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a sequence of lines, preserving the one-to-one mapping so the
|
||||
* caller can render them side-by-side with the originals. Empty entries
|
||||
* are kept as empty strings. Returns an array of the same length as $lines
|
||||
* (or [] on any failure / when disabled).
|
||||
*/
|
||||
public function translateLines(array $lines, string $targetLang = 'en', ?string $sourceLangHint = null): array
|
||||
{
|
||||
if (! $lines || ! $this->isEnabled()) return [];
|
||||
$targetLang = preg_replace('/[^a-zA-Z-]/', '', $targetLang) ?: 'en';
|
||||
$sourceLangHint = $sourceLangHint ? (preg_replace('/[^a-zA-Z-]/', '', $sourceLangHint) ?: null) : null;
|
||||
|
||||
$provider = $this->activeProvider();
|
||||
$cacheKey = 'llm_translate_lines_v2:' . ($provider['id'] ?? '') . ':' . $targetLang . ':' . ($sourceLangHint ?? '-') . ':' . sha1(json_encode($lines));
|
||||
$cached = Cache::get($cacheKey);
|
||||
if (is_array($cached) && count($cached) === count($lines)) return $cached;
|
||||
|
||||
// Index non-empty lines so empty separators are preserved verbatim.
|
||||
$toTranslate = [];
|
||||
foreach ($lines as $i => $l) {
|
||||
if (trim((string) $l) !== '') $toTranslate[$i] = (string) $l;
|
||||
}
|
||||
if (! $toTranslate) {
|
||||
Cache::put($cacheKey, $lines, now()->addDays(30));
|
||||
return $lines;
|
||||
}
|
||||
|
||||
$numbered = [];
|
||||
foreach ($toTranslate as $i => $l) $numbered[] = $i . "\t" . $l;
|
||||
|
||||
$hint = $sourceLangHint
|
||||
? "The author's written language is \"{$sourceLangHint}\" (ISO-639). Assume the lines are in that language unless they are OBVIOUSLY in a different language (different script).\n"
|
||||
: '';
|
||||
|
||||
$prompt = "Translate each of the following numbered lines into the language with ISO-639 code \"{$targetLang}\".\n"
|
||||
. $hint
|
||||
. "Rules:\n"
|
||||
. " - Output a JSON object mapping each original index (as string) to its translated line.\n"
|
||||
. " - Keep ONE output line per input line — do NOT merge, split, re-order, or drop any.\n"
|
||||
. " - Translate every word including short exclamations and repeated words that happen\n"
|
||||
. " to look like a name in the target language (Portuguese \"Linda\" → \"Beautiful\").\n"
|
||||
. " - If a line is already in the target language, repeat it unchanged.\n"
|
||||
. " - Preserve emojis, @mentions, hashtags, URLs, and timestamp tokens like @1:23 EXACTLY.\n"
|
||||
. " - Respond with ONLY the JSON object. No prose, no markdown, no code fence.\n\n"
|
||||
. "LINES (index TAB text):\n" . implode("\n", $numbered);
|
||||
|
||||
$raw = $this->postProcess($this->call($prompt, 8192, 0.0));
|
||||
$obj = json_decode($raw, true);
|
||||
if (! is_array($obj)) return [];
|
||||
|
||||
$out = $lines;
|
||||
$echoCount = 0;
|
||||
$translatedCount = 0;
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj[(string) $i] ?? ($obj[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '') {
|
||||
$out[$i] = $v;
|
||||
$translatedCount++;
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang && self::looksLikeEcho($src, $v)) {
|
||||
$echoCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
// If > 30% of the translated lines just echo the source, nudge the model.
|
||||
if ($sourceLangHint && $sourceLangHint !== $targetLang
|
||||
&& $translatedCount > 0 && $echoCount / $translatedCount > 0.3) {
|
||||
$retryPrompt = $prompt . "\n\nIMPORTANT: Your previous attempt echoed the source for many lines. "
|
||||
. "The source is DEFINITELY in \"{$sourceLangHint}\", not in \"{$targetLang}\". Translate every word.";
|
||||
$raw2 = $this->postProcess($this->call($retryPrompt, 8192, 0.0));
|
||||
$obj2 = json_decode($raw2, true);
|
||||
if (is_array($obj2)) {
|
||||
foreach ($toTranslate as $i => $src) {
|
||||
$v = $obj2[(string) $i] ?? ($obj2[$i] ?? null);
|
||||
if (is_string($v) && trim($v) !== '' && ! self::looksLikeEcho($src, $v)) {
|
||||
$out[$i] = $v;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Cache::put($cacheKey, $out, now()->addDays(30));
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Dispatch to the active provider's adapter. */
|
||||
private function call(string $prompt, int $maxTokens): string
|
||||
private function call(string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$p = $this->activeProvider();
|
||||
if (! $p) return '';
|
||||
try {
|
||||
return match ($p['kind']) {
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens),
|
||||
'anthropic' => $this->callAnthropic($p, $prompt, $maxTokens, $temperature),
|
||||
'openai' => $this->callOpenAI($p, $prompt, $maxTokens, $temperature),
|
||||
default => $this->callOllama($p, $prompt, $maxTokens, $temperature),
|
||||
};
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('LLM call failed: ' . $e->getMessage(), ['provider' => $p['name'] ?? '?']);
|
||||
@@ -208,24 +368,37 @@ class LlmLyricsService
|
||||
}
|
||||
}
|
||||
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOllama(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'http://localhost:11434'), '/');
|
||||
// Qwen3-style "thinking" models respect /no_think to skip the hidden
|
||||
// reasoning phase. Without it they can exhaust num_predict inside
|
||||
// the thinking block and emit empty content.
|
||||
$taggedPrompt = $prompt . "\n\n/no_think";
|
||||
$resp = Http::timeout(180)->acceptJson()->post($endpoint . '/api/chat', [
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'messages' => [['role' => 'user', 'content' => $taggedPrompt]],
|
||||
'stream' => false,
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => 0.2],
|
||||
'think' => false,
|
||||
// Keep the model resident for an hour so follow-up requests skip
|
||||
// the cold-start reload (default is 5 minutes). Dramatically
|
||||
// reduces per-click latency for comment/description translation.
|
||||
'keep_alive' => '1h',
|
||||
'options' => ['num_predict' => $maxTokens, 'temperature' => $temperature],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('Ollama API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
return '';
|
||||
}
|
||||
$j = $resp->json();
|
||||
return (string) ($j['message']['content'] ?? '');
|
||||
$content = (string) ($j['message']['content'] ?? '');
|
||||
// Some thinking models still ignore the flag and leak <think>…</think>
|
||||
// tags into content. Strip them.
|
||||
$content = preg_replace('/<think>.*?<\/think>\s*/is', '', $content) ?? $content;
|
||||
return trim($content);
|
||||
}
|
||||
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens): string
|
||||
private function callAnthropic(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.anthropic.com'), '/');
|
||||
$resp = Http::timeout(120)->withHeaders([
|
||||
@@ -235,6 +408,7 @@ class LlmLyricsService
|
||||
])->post($endpoint . '/v1/messages', [
|
||||
'model' => $p['model'],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => $temperature,
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
@@ -245,7 +419,7 @@ class LlmLyricsService
|
||||
return (string) ($j['content'][0]['text'] ?? '');
|
||||
}
|
||||
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens): string
|
||||
private function callOpenAI(array $p, string $prompt, int $maxTokens, float $temperature = 0.2): string
|
||||
{
|
||||
$endpoint = rtrim((string) ($p['endpoint'] ?? 'https://api.openai.com'), '/');
|
||||
$resp = Http::timeout(120)->withToken((string) $p['api_key'])
|
||||
@@ -254,7 +428,7 @@ class LlmLyricsService
|
||||
'model' => $p['model'],
|
||||
'messages' => [['role' => 'user', 'content' => $prompt]],
|
||||
'max_tokens' => $maxTokens,
|
||||
'temperature' => 0.2,
|
||||
'temperature' => $temperature,
|
||||
]);
|
||||
if (! $resp->successful()) {
|
||||
Log::warning('OpenAI API error', ['status' => $resp->status(), 'body' => substr($resp->body(), 0, 500)]);
|
||||
|
||||
@@ -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="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
|
||||
@@ -58,6 +58,7 @@ Route::get('/videos/{video}/lyrics/progress', [VideoController::class, 'lyricsPr
|
||||
Route::post('/videos/{video}/lyrics/save', [VideoController::class, 'saveLyrics'])->name('videos.lyrics.save')->middleware(['auth', 'throttle:30,1']);
|
||||
Route::delete('/videos/{video}/lyrics', [VideoController::class, 'deleteLyrics'])->name('videos.lyrics.delete')->middleware(['auth', 'throttle:20,1']);
|
||||
Route::post('/videos/{video}/share', [VideoController::class, 'recordShare'])->name('videos.recordShare');
|
||||
Route::post('/videos/{video}/description/translate', [VideoController::class, 'translateDescription'])->name('videos.description.translate')->middleware('throttle:30,1');
|
||||
Route::post('/videos/{video}/share/email', [VideoController::class, 'shareByEmail'])->name('videos.shareEmail')->middleware(['auth', 'throttle:10,1']);
|
||||
Route::post('/videos/{video}/share/members', [VideoController::class, 'shareWithMembers'])->name('videos.shareMembers')->middleware(['auth', 'throttle:20,1']);
|
||||
Route::get('/videos/{video}/og-image', [VideoController::class, 'ogImage'])->name('videos.ogImage');
|
||||
@@ -137,6 +138,7 @@ Route::post('/profile-visits/{user}', [UserController::class, 'recordProfileVisi
|
||||
|
||||
// Comment routes
|
||||
Route::get('/videos/{video}/comments', [CommentController::class, 'index'])->name('comments.index');
|
||||
Route::post('/comments/{comment}/translate', [CommentController::class, 'translate'])->name('comments.translate');
|
||||
Route::middleware(['auth', 'verified'])->group(function () {
|
||||
Route::post('/videos/{video}/comments', [CommentController::class, 'store'])->name('comments.store');
|
||||
Route::put('/comments/{comment}', [CommentController::class, 'update'])->name('comments.update');
|
||||
|
||||
Reference in new issue
Block a user