diff --git "a/static/app_v2.js" "b/static/app_v2.js" --- "a/static/app_v2.js" +++ "b/static/app_v2.js" @@ -1,64 +1,6 @@ -// VNEWS v17.1 - FPT Play slider removed from homepage -/** - * VNEWS Frontend v2 - Shorts Dantri/SKDS removed, VTV Digital CDN - * v2.8 - Changed short AI feed video share button icon (📥) to distinguish from article share (📤) - * v2.6 - Fixed share links: doShare now includes post_id for wall posts, - * readSlidePost has share button, /s + /s/{slug} render slides/video inline - * v2.5 - Added 'Tạo lại' button on wall cards with video - * v2.4 - Fixed: prependWallPost detached-element bug, makeShortVideo UI update, slide viewer for rewrite posts - */ -// ---- World Cup 2026 tab switcher (was missing -> loadHome() crashed) ---- -function switchWCTab(tab){ - try { - var el = document.getElementById('wc-content'); - if(!el) return; - var d = (typeof _wc2026Data !== 'undefined') ? _wc2026Data : null; - if(!d){ el.innerHTML = '
Không có dữ liệu World Cup 2026
'; return; } - if(tab === 'fixtures'){ - var m = (d.fixtures && d.fixtures.matches) || []; - var h = '
'; - m.slice(0, 40).forEach(function(x){ - h += '
'+esc(x.group||'')+''+esc(x.home||'?')+' '+esc(x.score||'VS')+' '+esc(x.away||'?')+''+esc(x.date_vn||'')+'
'; - }); - h += '
'; - el.innerHTML = m.length ? h : '
Chưa có lịch thi đấu
'; - } else if(tab === 'standings'){ - el.innerHTML = '
'+esc((d.standings && (typeof d.standings==='string'?d.standings:JSON.stringify(d.standings)))||'Bảng xếp hạng đang cập nhật')+'
'; - } else if(tab === 'highlights'){ - el.innerHTML = '
'+esc((d.highlights && (typeof d.highlights==='string'?d.highlights:JSON.stringify(d.highlights)))||'Highlight đang cập nhật')+'
'; - } else if(tab === 'stats'){ - el.innerHTML = '
'+esc((d.stats && (typeof d.stats==='string'?d.stats:JSON.stringify(d.stats)))||'Thống kê đang cập nhật')+'
'; - } else { - var news = (d.news) || []; - var nh = '
'; - (Array.isArray(news)?news:[]).slice(0,15).forEach(function(n){ - nh += '
'+esc(n.title||'')+'
'; - }); - nh += '
'; - el.innerHTML = Array.isArray(news)&&news.length ? nh : '
Đang tải tin World Cup 2026...
'; - } - // update active tab styling - try { - document.querySelectorAll('.wc-tab').forEach(function(t){ - var want = (tab==='news') ? 'news' : tab; - t.classList.toggle('active', t.getAttribute('onclick') && t.getAttribute('onclick').indexOf("'"+want+"'")>=0); - }); - } catch(e){} - } catch(e){ - var el2 = document.getElementById('wc-content'); - if(el2) el2.innerHTML = '
Lỗi tải World Cup 2026
'; - } -} -function _proxyImg(url){ - if(!url || typeof url !== 'string') return ''; - if(url.startsWith('http') && !url.includes(location.host)){ - return '/api/proxy/img?url='+encodeURIComponent(url); - } - return url; -} - -var _ttsSelections = {}; +// === VNEWS Frontend v2 - Optimized for speed === +// === LOAD HOME - Fast: immediate shell + parallel fetch === function _fetchWithTimeout(url, ms){ return new Promise((resolve,reject)=>{ const ctrl=new AbortController(); @@ -71,125 +13,57 @@ function _fetchWithTimeout(url, ms){ }); } -// ===== rewriteUrl: tạo bài rewrite slide AI từ URL bài viết ===== -// Gọi /api/url_wall (backend thực tế), lấy post rồi đẩy lên Tường AI và mở slide viewer. -async function rewriteUrl(){ - const inp = document.getElementById('url-input'); - const url = (inp && inp.value || '').trim(); - if(!url){ alert('Vui lòng dán URL bài viết'); return; } - if(!/^https?:\/\//i.test(url)){ alert('URL cần bắt đầu bằng http:// hoặc https://'); return; } - let btn = null; - try { btn = inp && inp.parentElement ? inp.parentElement.querySelector('button') : null; } catch(e){} - const orig = btn ? btn.textContent : 'Rewrite'; - if(btn){ btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; } - toast('⏳ Đang tạo bài rewrite slide AI...'); - try { - const r = await fetch('/api/url_wall', { - method:'POST', - headers:{'Content-Type':'application/json'}, - body: JSON.stringify({url: url}) - }); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi tạo bài'); - if(!j.post) throw new Error('Không tạo được bài'); - if(typeof prependWallPost === 'function'){ prependWallPost(j.post); } - else if(Array.isArray(_wallPosts)){ _wallPosts.unshift(j.post); } - if(inp) inp.value = ''; - toast('✅ Đã tạo bài rewrite slide AI!'); - const idx = (_wallPosts || []).indexOf(j.post); - if(j.post.slides && j.post.slides.length){ readSlidePost(idx >= 0 ? idx : 0); } - else if(typeof readWallPost === 'function'){ readWallPost(idx >= 0 ? idx : 0); } - } catch(e){ - toast('❌ ' + e.message); - } finally { - if(btn){ btn.disabled = false; btn.textContent = orig; } - } -} - -// ===== shareVideoToApps: gửi file MP4 qua app (chia sẻ video) ===== -async function shareVideoToApps(videoUrl, title){ - if(!videoUrl){ toast('Không có video để chia sẻ'); return; } - const safeTitle = (title || 'VNEWS Short AI').toString().slice(0, 80); - if(navigator.canShare && navigator.canShare({files:[]}) !== undefined){ - try { - const resp = await fetch(videoUrl, {mode:'cors'}); - if(resp && resp.ok){ - const blob = await resp.blob(); - let fname = (videoUrl.split('?')[0].split('/').pop() || 'vnews-short.mp4'); - if(!fname.toLowerCase().endsWith('.mp4')) fname = 'vnews-short.mp4'; - const file = new File([blob], fname, {type: (blob.type && blob.type.indexOf('video')>=0) ? blob.type : 'video/mp4'}); - if(navigator.canShare && navigator.canShare({files:[file]})){ - await navigator.share({files:[file], title: safeTitle, text: safeTitle}); - return; - } - } - } catch(e){ /* fall through to link share */ } - } - const _base = (typeof SPACE !== 'undefined' && SPACE) ? SPACE : location.origin; - const shareUrl = _base + '/s?url=' + encodeURIComponent(videoUrl) + '&title=' + encodeURIComponent(safeTitle); - if(navigator.share){ - try { await navigator.share({title: safeTitle, text: safeTitle, url: shareUrl}); return; } catch(e){ if(e && e.name === 'AbortError') return; } - } - if(navigator.clipboard && navigator.clipboard.writeText){ - try { await navigator.clipboard.writeText(shareUrl); toast('📋 Đã sao chép link video!'); return; } catch(e){} - } - try { - const ta = document.createElement('textarea'); - ta.value = shareUrl; ta.style.position='fixed'; ta.style.left='-9999px'; ta.style.top='-9999px'; ta.style.opacity='0'; - document.body.appendChild(ta); ta.select(); ta.setSelectionRange(0, 99999); - if(document.execCommand('copy')){ toast('📋 Đã sao chép link video!'); } - else { prompt('📋 Sao chép link video:', shareUrl); } - document.body.removeChild(ta); - } catch(e){ prompt('📋 Sao chép link video:', shareUrl); } -} - async function loadHome(){ const homeEl = document.getElementById('view-home'); if(!homeEl) return; + // Build shell IMMEDIATELY — no skeleton, no delay homeEl.innerHTML = '' - +'
🤖 AI viết bài
' - +'
' + +'
🤖 AI viết bài
' +'
' - +'
' - +'

⚽ Livescore

📋 Cập nhật🔴 Live📅 Hôm nay⏰ Sắp tới✅ Kết quả🏆 NHA🏆 La Liga🏆 Serie A🏆 Bundesliga🏆 League 1
Đang tải...
' + +'

⚽ Livescore

📅 Hôm nay🔴 Live⏰ Sắp tới✅ Kết quả🏆 NHA🏆 La Liga
Đang tải...
' +'

🏆 World Cup 2026

● LIVE
📰 Tin tức📅 Lịch thi đấu🏆 BXH🎬 Highlight📊 Thống kê
Đang tải World Cup 2026...
' - +'
' +'
'; const afterEl = homeEl.querySelector('#home-after-wc'); - loadLivescore('recent'); + // Start critical loads immediately + loadLivescore('today'); loadHotTopics(); - const [featuredData, wallData, hlLeagues, wcData] = await Promise.allSettled([ + // Fetch all data in parallel with shorter timeouts + const [featuredData, shortsData, wallData, hlLeagues, aiData, wcData] = await Promise.allSettled([ _fetchWithTimeout('/api/livescore/featured', 5000), + _fetchWithTimeout('/api/shorts', 8000), _fetchWithTimeout('/api/wall', 5000), _fetchWithTimeout('/api/highlights/leagues', 10000), - _fetchWithTimeout('/api/wc2026', 20000), + _fetchWithTimeout('/api/genk_ai', 8000), + _fetchWithTimeout('/api/wc2026', 8000), ]).then(results => results.map(r => r.status === 'fulfilled' ? r.value : null)); + // Render featured match if(featuredData && featuredData.home){ const sc=featuredData.status==='live'?'':'upcoming'; const st=featuredData.status==='live'?`🔴 ${featuredData.minute||'LIVE'}`:`⏰ ${featuredData.time}`; const area=document.getElementById('home-featured-area'); - if(area) area.innerHTML=``; + if(area) area.innerHTML=``; } + // Store globally + _shortsData = shortsData || []; _wallPosts = (wallData && wallData.posts) || []; _hlLeagueData = hlLeagues || {}; _wc2026Data = wcData; + // Render WC if data arrived if(wcData) switchWCTab('news'); - _renderHLSection(); - _fptStartAuto(); - - // === YOUTUBE RSS FEED (FPT Bóng Đá) — interleave latest videos onto Tường AI === - _ytFeedStartAuto(); - + // Render sections into the after-wc area + _renderShortsIn(afterEl); _renderWallIn(afterEl); + _renderHLIn(afterEl); + if(aiData && aiData.length) _renderSlidesIn('ai-articles','Ứng dụng AI','🤖',aiData,afterEl); } function _renderSlidesIn(key, label, emoji, vids, afterEl){ @@ -200,9 +74,9 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){ const isHL = key==='world-cup'||key==='premier-league'||key==='champions-league'||key==='la-liga'||key==='serie-a'||key==='bundesliga'||key==='friendly'; vids.slice(0,isHL?8:12).forEach((a,i)=>{ if(isHL){ - h+=`
${a.img?``:''}
${esc(a.title)}
`; + h+=`
${a.img?``:''}
${esc(a.title)}
`; } else { - h+=`
${a.img?``:''}
${esc(a.title)}
`; + h+=`
${a.img?``:''}
${esc(a.title)}
`; } }); h+=''; @@ -210,19 +84,39 @@ function _renderSlidesIn(key, label, emoji, vids, afterEl){ afterEl.parentNode.insertBefore(wrap, afterEl); } +function _renderShortsIn(afterEl){ + if(!_shortsData||!_shortsData.length||!afterEl) return; + const mixed=interleaveShorts(_shortsData); + if(!mixed.length) return; + const wrap=document.createElement('div'); + wrap.className='slider-wrap'; + let h=`
📱 Shorts Dân trí & SKĐSMới nhất · xen kẽ
`; + mixed.slice(0,30).forEach((a,i)=>{ + const badge=a.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí'; + h+=`
${a.img?``:''}
${badge} ${esc(a.title)}
`; + }); + h+='
';wrap.innerHTML=h; + afterEl.parentNode.insertBefore(wrap,afterEl); +} + function _renderWallIn(afterEl){ - if(!_wallPosts||!_wallPosts.length) return; + if(!_wallPosts||!_wallPosts.length||!afterEl) return; const posts=_wallPosts; + const aiShorts=posts.filter(p=>p.video); + if(aiShorts.length){ + const wrap=document.createElement('div'); + wrap.className='slider-wrap'; + let h='
🎬 Short AI
'; + aiShorts.slice(0,20).forEach((p,i)=>{h+=`
${esc(p.title)}
`;}); + h+='
';wrap.innerHTML=h; + afterEl.parentNode.insertBefore(wrap,afterEl); + } const wrap=document.createElement('div'); wrap.className='slider-wrap';wrap.id='ai-wall-wrap'; let h='
🧱 Tường AI
'; posts.slice(0,20).forEach((p,i)=>{h+=makeWallItem(p,i);}); h+='
';wrap.innerHTML=h; - const target=document.getElementById('ai-wall-under-compose'); - if(target) target.appendChild(wrap); - else if(afterEl) afterEl.parentNode.insertBefore(wrap,afterEl); - // Interleave YouTube RSS feed videos into the wall with wall CSS - if(wrap && typeof _renderYTFeedInWall === 'function') _renderYTFeedInWall(); + afterEl.parentNode.insertBefore(wrap,afterEl); } function _renderHLIn(afterEl){ @@ -235,2000 +129,245 @@ function _renderHLIn(afterEl){ } } -// === RENDER HIGHLIGHT (Video bóng đá) SECTION — above Livescore, with "Xem thêm" per league === -var _hlPage = {}; // {leagueKey: page} -var _hlHasMore = {}; // {leagueKey: bool} - -function _renderHLSection(){ - const hlEl = document.getElementById('home-hl-section'); - if(!hlEl) return; - if(!_hlLeagueData || Object.keys(_hlLeagueData).length===0){ - return; - } - const HL_CONFIG={"premier-league":{name:"Ngoại Hạng Anh",emoji:"🏴"},"champions-league":{name:"Champions League",emoji:"⭐"},"la-liga":{name:"La Liga",emoji:"🇪🇸"},"serie-a":{name:"Serie A",emoji:"🇮🇹"},"bundesliga":{name:"Bundesliga",emoji:"🇩🇪"},"world-cup":{name:"World Cup 2026",emoji:"🌍"},"fa-cup":{name:"FA Cup",emoji:"🏆"},"friendly":{name:"Giao hữu",emoji:"🤝"}}; - - // Ordered list of leagues that have videos (premier-league first) - const orderedLeagues = []; - const plVids = _hlLeagueData['premier-league']; - if(plVids && plVids.length) orderedLeagues.push('premier-league'); - for(const [key, cfg] of Object.entries(HL_CONFIG)){ - if(key === 'premier-league') continue; - const vids = _hlLeagueData[key]; - if(vids && vids.length) orderedLeagues.push(key); - } - if(orderedLeagues.length === 0){ - return; // nothing to show - } - _hlLeagueKey = orderedLeagues; - - // Build ONE combined video list from ALL leagues (premier league items first). - // The scraped sources return the SAME videos for every league (the league - // sub-pages all resolve to the same content from the server), so combine - // then DEDUPE by link — otherwise the slider shows the same ~10 videos - // repeated N times and "loops back to video #1" after the unique ones. - const seenLinks = new Set(); - let combined = []; - orderedLeagues.forEach(key => { - const vids = _hlLeagueData[key] || []; - vids.forEach(a => { - if(!a || !a.link || seenLinks.has(a.link)) return; - seenLinks.add(a.link); - combined.push(Object.assign({}, a, {_league: key, _pos: combined.length})); - }); - }); - // Newest first: sort by publish date when known (the scrapers now return - // `published` parsed from the image/URL dates). Videos without a date keep - // their relative order and go last, so the slider always starts with the - // freshest clips. - combined.sort(function(x, y){ - const xd = x.published || ''; - const yd = y.published || ''; - if(xd && yd) return xd < yd ? 1 : (xd > yd ? -1 : 0); - if(xd && !yd) return -1; // dated first - if(!xd && yd) return 1; - return (x._pos||0) - (y._pos||0); // stable for undated - }); - _hlLeagueData['all'] = combined; - - // ONE unified "🎬 Video bóng đá" slider (per-league sliders removed). - // All videos render at once — no "Xem thêm" button needed (the paged API - // re-scrapes the same sources, so it adds nothing but duplicates). - let html = '
🎬 Video bóng đá' + combined.length + ' video
'; - html += '
'; - hlEl.innerHTML = html; - _renderHLSlides('all', 'Video bóng đá', '🎬', combined, 'hl-main-track'); -} - -function _renderHLSlides(key, label, emoji, vids, containerId){ - const container = document.getElementById(containerId); - if(!container) return; - if(!vids || !vids.length){ - container.innerHTML = '
Chưa có video
'; - return; - } - // Render ALL football videos immediately (the dataset is small: ~70-80 - // items). No 8-item cap, no chunked lazy-load, no server paging on the - // homepage — every item gets its exact index into the vids array, so - // openHighlightFeed can never mismatch order and the slider can never - // "loop back to video #1". Images use native lazy-loading so only the - // visible thumbnails are fetched. - let h = ''; - vids.forEach((a, i) => { - h += _hlItemHtml(key, a, i); - }); - container.innerHTML = h; -} - -// All football videos are rendered at once from the full vids array, so the -// chunked lazy-load machinery is no longer needed and is removed to keep the -// track's indices simple and duplicate-free. - -function _hlItemHtml(key, a, i){ - return '
' + (a.img ? '' : '') + '
' + esc(a.title) + '
'; -} - -async function _loadMoreHL(key, auto){ - // Legacy: kept as a no-op safety net (the old inline button HTML may still - // be cached in some browsers; it simply appends nothing and hides itself). - const btn = typeof document !== 'undefined' ? document.querySelector('.hl-load-more') : null; - if(btn) btn.style.display = 'none'; - _hlHasMore[key] = false; -} - -// === FPT PLAY SLIDER (auto-updating channel) === -// Shows the latest videos from https://youtube.com/@fptbongdaofficial like the -// Short HOT oEmbed slides. The server can't reach youtube.com from its -// datacenter, so the browser (home IP) fetches the channel via r.jina.ai and -// pushes the result to /api/fptplay/update — the slide refreshes automatically -// when the channel uploads something new. -var _fptVideos = []; -var _fptTimer = null; -var _fptSeen = {}; - -function _fptParseJina(txt){ - // jina markdown of the /videos tab: each entry has a title line and a watch - // URL. Collect (title, videoId) in page order. - const out = []; - const titles = []; - const lines = String(txt||'').split('\n'); - for(let i=0;io.id===id)) title = titles.pop() || ''; - if(!title){ - // fall back to the line itself (sans url) - title = line.replace(/https?:\/\/\S+/g,'').replace(/[|\[\]]/g,'').trim(); - } - out.push({id:id, title:(title||'Video').slice(0,200), link:'https://youtu.be/'+id}); - } else if(/^\s*\d+\./.test(line)){ - const t = line.replace(/^\s*\d+\.\s+/,'').trim(); - if(t && t.length>4 && !/youtube\.com|youtu\.be/i.test(t)) titles.push(t); - } - } - return out; -} - -async function _fptFetchClient(){ - // Browser-side: user's home IP can reach r.jina.ai (same trick as Short HOT - // description). Returns array of {id,title,link}. - try{ - const ctrl = new AbortController(); - const tid = setTimeout(()=>ctrl.abort(), 60000); - const resp = await fetch('https://r.jina.ai/https://www.youtube.com/@fptbongdaofficial/videos', { - headers:{'x-no-cache':'1','x-respond-with':'markdown'}, - signal: ctrl.signal - }); - clearTimeout(tid); - if(!resp.ok) throw new Error('jina '+resp.status); - const txt = await resp.text(); - const parsed = _fptParseJina(txt); - if(!parsed.length) throw new Error('empty parse'); - return parsed; - }catch(e){ - return null; - } -} - -function _fptRender(){ - const el = document.getElementById('home-fpt-section'); - if(!el) return; - if(!_fptVideos || !_fptVideos.length){ - el.innerHTML = ''; - return; - } - const vids = _fptVideos.slice(0, 30); - // Render FPT Play videos using the SAME wall card CSS as Short AI cards - // (via _makeYTFeedWallItem) — 100% match with card video Short AI. - let h = '
📺 FPT Play Bóng Đá' + vids.length + ' video · tự cập nhật
'; - vids.forEach((a,i)=>{ - h += _makeYTFeedWallItem(a, i); - }); - h += '
'; - el.innerHTML = h; - // lazy thumbs - el.querySelectorAll('img[loading]').forEach(function(im){ im.loading='lazy'; }); -} - -async function _fptSync(){ - // 1) read server cache first (fast) +// === WALL POST HELPERS === +function makeWallItem(p,i){ + const hasVideo = p.video && p.video.length > 0; + const thumbContent = p.img + ? `` + : (hasVideo ? `` : ''); + const videoBadge = hasVideo ? `
🎬
` : ''; + const videoBtn = hasVideo + ? `` + : ``; + return `
${thumbContent}${videoBadge}
${esc(p.title)}
${esc((p.text||'').slice(0,180))}
${videoBtn}
`; +} + +async function makeShortVideo(postId, btn, voice, speed){ + if(!postId)return; + const origText = btn ? btn.textContent : '🎬 Tạo Video'; + if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo...';} + toast('⏳ Đang tạo video shorts...'); try{ - const r = await fetch('/api/fptplay/videos'); + let url = '/api/ai/short/'+encodeURIComponent(postId); + const params = []; + if(voice) params.push('voice='+encodeURIComponent(voice)); + if(speed) params.push('speed='+encodeURIComponent(speed)); + if(params.length) url += '?' + params.join('&'); + const r = await fetch(url, {method:'POST'}); const j = await r.json(); - if(j && j.videos && j.videos.length){ - _fptVideos = j.videos; - _fptRender(); - } - }catch(e){} - // 2) refresh from the channel (client-side jina) — replaces stale cache - const fresh = await _fptFetchClient(); - if(fresh && fresh.length){ - // push to server so the slide stays updated and persisted - try{ - const body = JSON.stringify({videos: fresh}); - const r2 = await fetch('/api/fptplay/update', {method:'POST', headers:{'Content-Type':'application/json'}, body}); - const j2 = await r2.json(); - if(j2 && j2.videos && j2.videos.length) _fptVideos = j2.videos; - else _fptVideos = fresh; - }catch(e2){ - _fptVideos = fresh; - } - _fptRender(); - } -} - -function _fptStartAuto(){ - if(_fptTimer) return; - _fptSync(); // immediate first pass - _fptTimer = setInterval(_fptSync, 12 * 60 * 1000); // auto-refresh every 12 min -} - -// === YOUTUBE RSS FEED — auto-updating FPT Bóng Đá videos on Tường AI === -// Channel ID UC4LvrpNXujjbGOS4RDvr41g = FPT Bóng Đá. -// The browser fetches the YouTube RSS feed (feeds/videos.xml) directly since -// the datacenter can't reach youtube.com. Parsed entries are interleaved into -// the AI wall (Tường AI) using the same wall CSS classes as makeWallItem so -// they look like native wall posts. Auto-refresh every 15 minutes. -var _ytFeedVideos = []; -var _ytFeedTimer = null; -var _ytFeedUrl = 'https://www.youtube.com/feeds/videos.xml?channel_id=UC4LvrpNXujjbGOS4RDvr41g'; -// The browser cannot fetch this URL directly (YouTube RSS lacks CORS headers). -// The server-side /api/yt/feed endpoint fetches + parses it and returns JSON. - -function _ytParseRssXml(xmlText){ - // Parse YouTube RSS XML. The browser can fetch the raw XML directly. - // Returns [{id, title, link, img, published}] in newest-first order. - var out = []; - try{ - var parser = new DOMParser(); - var doc = parser.parseFromString(xmlText, 'application/xml'); - // Check for parse errors - var err = doc.querySelector('parsererror'); - if(err){ return null; } - var entries = doc.getElementsByTagName('entry'); - for(var i=0; i String(x.id) === String(postId)); + if(p){ + p.video = j.video; + const itemId = 'wall-item-'+postId; + const el = document.getElementById(itemId); + if(el){ + const idx = _wallPosts.indexOf(p); + el.outerHTML = makeWallItem(p, idx); + const newEl = document.getElementById(itemId); + if(newEl) newEl.className = 'wall-item wall-item-new'; } - // Fallback: YouTube default thumbnail - if(!thumb){ thumb = 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg'; } - out.push({ - id: 'yt-' + vid, - videoId: vid, - title: (title || 'Video').slice(0,200), - link: link, - img: thumb, - published: published - }); - } - return out.length ? out : null; - }catch(e){ - console.error('[yt-feed] parse error', e); - return null; - } -} - -function _normalizeYoutubeTs(p){ - // Sort key: use published date or a numeric id prefix, newest first - if(p.published){ - var d = new Date(p.published); - if(!isNaN(d.getTime())){ return d.getTime(); } - } - // fallback: parse videoId is not chronological; use 0 - return 0; -} - -async function _ytFetchFeed(){ - // Fetch the YouTube RSS feed via r.jina.ai reader proxy (works from the browser - // — YouTube blocks direct RSS access due to CORS). r.jina.ai proxies the - // rss2json.com JSON output as plain text inside a markdown blob. - try{ - var ctrl = new AbortController(); - var tid = setTimeout(function(){ ctrl.abort(); }, 20000); - var rssUrl = 'https://www.youtube.com/feeds/videos.xml?channel_id=UC4LvrpNXujjbGOS4RDvr41g'; - var rjUrl = 'https://r.jina.ai/https://api.rss2json.com/v1/api.json?rss_url=' + encodeURIComponent(rssUrl); - var resp = await fetch(rjUrl, { - headers:{'Accept':'text/plain','x-jina-reader':'1','x-no-cache':'1'}, - signal: ctrl.signal - }); - clearTimeout(tid); - if(!resp.ok){ throw new Error('HTTP ' + resp.status); } - var txt = await resp.text(); - // r.jina.ai wraps the JSON in a markdown block — extract the JSON - var jsonMatch = txt.match(/\{[\s\S]*"status"\s*:\s*"ok"[\s\S]*\}/); - if(!jsonMatch){ - console.error('[yt-feed] no JSON in r.jina.ai response'); - return null; } - var data = JSON.parse(jsonMatch[0]); - if(!data.items || !data.items.length){ return null; } - var out = []; - for(var i=0;i' - : '
'; - var videoBadge = '
🎬
'; - var vid = v.videoId || v.id; - var callOpen = "openYTEmbedFeed('"+esc(vid)+"','"+esc(v.title)+"')"; - return '
' + - '
' + - thumbContent + videoBadge + - '
' + - '
' + esc(v.title) + '
' + - '
' + esc((v.published||'').replace(/T.*/,'') ) + '
' + - '
' + - '' + - '
' + - '
'; -} - -function _renderYTFeedInWall(){ - // Interleave YouTube feed videos into the wall track, using wall CSS. - // Called after _renderWallIn and after each feed refresh. - var track = document.getElementById('ai-wall-track'); - if(!track){ return; } - // Remove any previous feed items (they get re-appended to stay "latest") - var existing = track.querySelectorAll('.wall-yt-feed-item'); - existing.forEach(function(el){ el.remove(); }); - if(!_ytFeedVideos || !_ytFeedVideos.length){ return; } - // Interleave: insert feed items at regular intervals among wall posts - var wallItems = Array.prototype.slice.call(track.children); - var feedItems = _ytFeedVideos.slice(0, 12); // cap to keep wall tidy - var step = Math.max(1, Math.ceil(wallItems.length / feedItems.length)); - var insertIdx = step; - feedItems.forEach(function(v, i){ - var wrapper = document.createElement('div'); - wrapper.className = 'wall-yt-feed-item'; - wrapper.innerHTML = _makeYTFeedWallItem(v, i); - if(insertIdx < wallItems.length){ - track.insertBefore(wrapper, wallItems[insertIdx]); - wallItems.splice(insertIdx, 0, wrapper); - insertIdx += step + 1; - }else{ - track.appendChild(wrapper); - wallItems.push(wrapper); - } - }); -} - -function openYTEmbedFeed(videoId, title){ - // Open a YouTube embed in the SAME TikTok-style viewer used by Short AI - // (buildTikTokSlide + initTikTokFeed) — 100% match with Short AI player. - // Builds a SCROLLABLE feed that starts at this FPT video and lets the user - // swipe UP/DOWN to browse older FPT videos interleaved with Short AI content, - // in newest-first order. - showView('view-tiktok'); - var el = document.getElementById('view-tiktok'); - if(!el){ return; } - - function _fptTs(v){ - if(v && v.published){ - var d = new Date(v.published); - if(!isNaN(d.getTime())) return d.getTime(); - } - return 0; + toast('❌ '+e.message); + if(btn){btn.disabled=false;btn.textContent=origText;} } - - // 1) Gather all playable items: FPT wall videos + Short AI posts (newest first) - var items = []; - var seenFpt = {}; - function pushFpt(v){ - if(!v) return; - var vid = v.videoId || v.id; - if(!vid || seenFpt[vid]) return; - seenFpt[vid] = 1; - items.push({ - kind:'fpt', - videoId: vid, - title: v.title || 'Video', - img: v.img || 'https://i.ytimg.com/vi/' + vid + '/hqdefault.jpg', - ts: _fptTs(v) - }); - } - (_ytFeedVideos||[]).forEach(pushFpt); - (_fptVideos||[]).forEach(pushFpt); - (_wallPosts||[]).forEach(function(p){ - if(!p || !p.video) return; - var ts = parseInt(p.created||'0',10); - if(isNaN(ts) || !ts){ var d = new Date(p.created_str||''); ts = isNaN(d.getTime()) ? 0 : d.getTime(); } - items.push({ - kind:'ai', - videoId: p.id || ('ai-' + items.length), - postId: p.id || '', - title: p.title || 'Video', - img: p.short_thumb || p.img || '', - text: (p.text && p.text !== p.title ? p.text : '').slice(0,400), - video: p.video, - ts: ts - }); - }); - - // 2) Sort newest-first, then rotate so the clicked FPT video is first - items.sort(function(a,b){ return (b.ts||0) - (a.ts||0); }); - var start = 0; - for(var i=0;i' - + ''; - } - function aiVtag(it){ - var isYT = /youtube\.com\/embed|youtu\.be\/|youtube-nocookie|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(it.video||''); - if(!it.video) return ''; - return isYT - ? '
' - : ''; - } - - var h = '' - + '
'; - ordered.forEach(function(it, idx){ - var vtag, badge, badgeClass, shareUrl, postId, desc, extraBtn; - if(it.kind === 'fpt'){ - vtag = fptVtag(it); - badge = 'FPT Play'; badgeClass = 'badge-fpt'; - shareUrl = 'https://www.youtube.com/watch?v=' + it.videoId; - postId = ''; desc = ''; extraBtn = ''; - } else { - vtag = aiVtag(it); - badge = 'Short AI'; badgeClass = 'badge-ai'; - shareUrl = it.video || ''; - postId = it.postId || ''; - desc = it.text || ''; - extraBtn = ''; - } - h += buildTikTokSlide({ - vtag: vtag, - title: it.title, - badge: badge, - badgeClass: badgeClass, - videoId: it.videoId + '-feed-' + String(idx), - idx: idx, - total: ordered.length, - shareUrl: shareUrl, - postId: postId, - desc: desc, - extraBtn: extraBtn || '' - }); - }); - h += '
'; - el.innerHTML = h; - setTimeout(function(){ initTikTokFeed(); }, 200); -} - -async function _ytFeedSync(){ - // 1) render from cache (immediate) - if(_ytFeedVideos.length){ - _renderYTFeedInWall(); - } - // 2) refresh from the live channel feed - var fresh = await _ytFetchFeed(); - if(fresh && fresh.length){ - _ytFeedVideos = fresh.slice(0, 15); - _renderYTFeedInWall(); - } -} - -function _ytFeedStartAuto(){ - if(_ytFeedTimer) return; - _ytFeedSync(); - _ytFeedTimer = setInterval(_ytFeedSync, 15 * 60 * 1000); // every 15 min -} - -// === WALL POST HELPERS === -function makeWallItem(p,i){ - var hasVideo = p.video && p.video.length > 0;var isEmbed = !!(p.embed_oembed || /tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|youtube\.com\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(p.video||'')); - var isSlide = (p.slides && p.slides.length) || p.kind==='slide_summary'; - // Slide/design posts: keep the image's natural aspect ratio (no 16:9 crop) - // so portrait-designed slides aren't cut — this fixes the "saved image ratio - // and background position look wrong vs the preview" bug on the wall thumb. - var thumbStyle = isSlide - ? 'aspect-ratio:auto;max-height:340px;display:flex;align-items:flex-start;justify-content:center;background:#000' - : ''; - var thumbContent = isEmbed - ? (p.img - ? '' - : '
') - : (isSlide - ? (p.img ? '' : (hasVideo ? '' : '')) - : (p.img ? '' : (hasVideo ? '' : ''))); - var videoBadge = hasVideo ? '
🎬
' : ''; - var vid = p.id||i; - var lang = p.language || detectLanguage(p.title + ' ' + (p.text||'')); - var curVoice = p.voice || getAutoVoice(lang); - var curEmotion = p.emotion || detectEmotion(p.title + ' ' + (p.text||'')); - var selKey = 'inline-' + vid; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: curVoice, emotion: curEmotion}; - var voiceOpts = ''; - VOICE_LIST.forEach(function(v){ - voiceOpts += ''; - }); - var emotOpts = ''; - EMOTION_LIST.forEach(function(e){ - emotOpts += ''; - }); - var spd = p.short_speed || '1.2'; - var voiceBar = '
' - +'' - +'' - +'' - +'
'; -var makeBtn = hasVideo - ? '' - : ''; - var designBtn = (p.slides && p.slides.length) ? '' : ''; - return '
'+thumbContent+videoBadge+'
'+esc(p.title)+'
'+esc((p.text||'').slice(0,180))+'
'+voiceBar+'
'+designBtn+makeBtn+'
'; -} - -/* ===================================================================== - * Short AI Creator v2 - * - TikTok background-music list (server-provided) or uploaded audio - * - uploaded image OR uploaded video as the short background - * - "Tạo lại" (recreate): use the DESIGNED slide images + the previous - * short's audio (NO text ever enters the video again) - * ===================================================================== */ -var _shortMusicList = []; -async function loadShortMusicList(){ - if(_shortMusicList.length) return _shortMusicList; - try{ - const r = await fetch('/api/ai-short/music'); - const j = await r.json(); - _shortMusicList = (j.music || []); - }catch(e){ _shortMusicList = []; } - return _shortMusicList; -} - -function openShortCreator(postId){ - if(!postId) return; - const p = _wallPosts.find(x => String(x.id) === String(postId)); - if(!p) return; - // if the post has designed slides, recreate defaults to slides mode - const hasSlides = p.slides && p.slides.length > 0; - _buildShortCreatorModal(p, hasSlides, false); -} - -/* 🔥 Thêm Short HOT — homepage button. Opens the SAME short-creator modal with - * full scrap capability (image / uploaded video / video-from-link) but creates - * a BRAND-NEW wall post (no source article) when the short is generated. */ -function openShortHotCreator(){ - const p = { - id: 'hot-' + Date.now(), - title: '', - text: '', - img: null, - images: [], - slides: null, - video: null, - language: 'vi', - }; - _buildShortCreatorModal(p, false, true); } -function _buildShortCreatorModal(p, defaultUseSlides, hotMode){ - loadShortMusicList().then(music => { - const hasPrev = !!(p.short_audio_url || p.video); - const hasSlides = p.slides && p.slides.length > 0; - const useSlideDef = defaultUseSlides !== undefined ? defaultUseSlides : (hasSlides && hasPrev); - const overlay = document.createElement('div'); - overlay.id = 'short-creator-overlay'; - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.94);z-index:99999;display:flex;align-items:center;justify-content:center;padding:12px;overflow-y:auto'; - - // voice/emotion from stored selection - const selKey = 'inline-' + p.id; - if(!_ttsSelections[selKey]) _ttsSelections[selKey] = {voice: p.voice || getAutoVoice(p.language || detectLanguage(p.title+' '+(p.text||''))), emotion: p.emotion || detectEmotion(p.title+' '+(p.text||''))}; - const curVoice = _ttsSelections[selKey].voice || 'vi-VN-HoaiMyNeural'; - const curEmotion = _ttsSelections[selKey].emotion || 'neutral'; - - let h = '
'; - h += '

'+(hotMode?'🔥 Thêm Short HOT':'🎬 Tạo Short AI')+(hasPrev?' (Tạo lại)':'')+'

'; - if(hotMode){ - h += '
'; - h += '
'; - } - - /* 0. Recreate mode (designed slides only, no text) */ - if(hasSlides){ - h += '
'; - h += ''; - h += '
Dùng ảnh slide đã thiết kế làm nền + giữ nguyên audio của Short trước đó. Chữ KHÔNG xuất hiện trong video.
'; - h += '
'; - h += '
Chọn slide muốn đưa vào Short (tích = dùng):
'; - p.slides.forEach((s,i)=>{ - const simg = s.image || p.img || ''; - h += ''; - }); - h += '
'; - } - - /* 1. Background: image (default) / uploaded video / scraped video link */ - h += '
'; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - h += ''; - h += ''; - h += ''; - h += '
'; - - /* 2. Audio + 3. Voice — SKIPPED in HOT mode (video keeps its own audio) */ - if(!hotMode){ - /* 2. Audio: previous short audio / TikTok music / uploaded audio */ - h += '
'; - h += ''; - /* Music chooser — luôn hiển thị để người dùng NGHE THỬ trước khi chọn */ - h += '
'; - h += '
'; - h += '
'; - h += ''; - h += '
▶ Nghe thử nhạc nền TikTok trước khi chọn. Nhạc sẽ được trộn vào video (nền) khi tạo.
'; - h += '
'; - h += ''; - h += '
'; - - /* 3. Voice + emotion + speed (TTS) */ - h += '
'; - h += '
'; - h += '
'; - h += '
'; - h += '
'; - } - - h += '
'; - h += ''; - h += ''; - h += '
'; - h += '
'; - overlay.innerHTML=h; document.body.appendChild(overlay); - // Cleanup HLS preview instance whenever the modal is removed (✕ / Hủy / success) - (function(){ - const obs = new MutationObserver(function(muts){ - if(document.getElementById('sc-scrape-video')){ - const p = document.getElementById('sc-scrape-video'); - if(p._hls){ try{ p._hls.destroy(); }catch(e){} p._hls = null; } - } - }); - obs.observe(document.body, {childList:true, subtree:false}); - // stop after this overlay is gone - setTimeout(()=>obs.disconnect(), 60*1000); - })(); - - // ---- mode toggles ---- - let bgMode = hotMode ? 'link' : 'img'; - function setBgMode(m){ - bgMode = m; - document.getElementById('sc-bg-mode-img').style.background = m==='img' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-img').style.color = m==='img' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-video').style.background = m==='video' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-video').style.color = m==='video' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-mode-link').style.background = m==='link' ? '#2d8659' : '#333'; - document.getElementById('sc-bg-mode-link').style.color = m==='link' ? '#fff' : '#ccc'; - document.getElementById('sc-bg-img-wrap').style.display = (m==='img' || m==='link') ? 'block' : 'none'; - document.getElementById('sc-bg-video-wrap').style.display = m==='video' ? 'block' : 'none'; - document.getElementById('sc-bg-link-wrap').style.display = m==='link' ? 'block' : 'none'; - } - if(hotMode){ - // HOT mode: only video-from-link is offered; hide ảnh/upload-video buttons - document.getElementById('sc-bg-mode-img').style.display = 'none'; - document.getElementById('sc-bg-mode-video').style.display = 'none'; - document.getElementById('sc-bg-mode-link').style.background = '#2d8659'; - document.getElementById('sc-bg-mode-link').style.color = '#fff'; - } else { - document.getElementById('sc-bg-mode-img').addEventListener('click', ()=>setBgMode('img')); - document.getElementById('sc-bg-mode-video').addEventListener('click', ()=>setBgMode('video')); - document.getElementById('sc-bg-mode-link').addEventListener('click', ()=>setBgMode('link')); - } - - // ---- scraped video link: fetch preview ---- - const scLinkInput = document.getElementById('sc-video-link'); - const scScrapeBtn = document.getElementById('sc-video-scrape'); - if(scScrapeBtn && scLinkInput){ - scScrapeBtn.addEventListener('click', async ()=>{ - const url = scLinkInput.value.trim(); - const st = document.getElementById('sc-video-scrape-status'); - const preview = document.getElementById('sc-video-preview'); - const meta = document.getElementById('sc-video-meta'); - if(!url){ - st.textContent = '⚠️ Dán link video trước.'; - st.style.color = '#e0a030'; - return; - } - st.textContent = '⏳ Đang lấy thông tin video...'; - st.style.color = '#888'; - try{ - const r = await fetch('/api/ai-short/scrape?url=' + encodeURIComponent(url)); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Không lấy được video'); - // metadata - let metaHtml = ''; - if(j.title) metaHtml += '
' + esc(j.title) + '
'; - if(j.duration) metaHtml += '
⏱️ ' + Math.round(j.duration) + 's
'; - if(j.extractor) metaHtml += '
Nguồn: ' + esc(j.extractor) + '
'; - if(j.note) metaHtml += '
⚠️ ' + esc(j.note) + '
'; - meta.innerHTML = metaHtml; - meta.style.display = 'block'; - // direct URL for the short; keep oEmbed fallback metadata too - const scDirect = j.direct_url || ''; - let scEmbed = j.embed_url || ''; - // YouTube/other scrape often returns the oEmbed-style iframe URL via - // direct_url (embed pages can't be downloaded as media). Normalize: - // treat direct embed URLs as oEmbed sources so the submit path saves - // an embeddable slide with auto title/thumb instead of erroring. - if(!scEmbed && /youtube\.com\/embed|youtu\.be\/|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|player\.vimeo\.com|platform\.twitter\.com/.test(scDirect)){ - scEmbed = scDirect; - } - const scThumb = j.thumbnail || j.thumb || ''; - const scDesc = j.description || ''; - meta.setAttribute('data-title', j.title || ''); - meta.setAttribute('data-direct', scDirect || ''); - meta.setAttribute('data-embed', scEmbed || ''); - meta.setAttribute('data-thumb', scThumb || ''); - meta.setAttribute('data-desc', scDesc || ''); - // Show description (YouTube/other) under the title in the meta box - if(scDesc){ - metaHtml += '
' + esc(scDesc) + '
'; - meta.innerHTML = metaHtml; - } - // Auto-fill the Short HOT title field with the real scraped title - const hotTitle = document.getElementById('sc-hot-title'); - if(hotTitle){ hotTitle.setAttribute('data-autofilled', j.title ? '1' : '0'); } - if(hotTitle && j.title && !hotTitle.value.trim()){ - hotTitle.value = j.title; - } - document.getElementById('sc-scrape-direct').value = scDirect; - document.getElementById('sc-scrape-duration').value = j.duration ? String(j.duration) : ''; - // YouTube description client-side fallback: the user's browser (home - // IP) can reach r.jina.ai even when the server cannot; fill the - // description if the server didn't return one. - if(!scDesc && /youtube\.com|youtu\.be/.test(url)){ - try{ - fetch('https://r.jina.ai/' + url, {headers:{'x-no-cache':'1'}}) - .then(resp => resp.text()) - .then(txt => { - const m = txt.match(/(?:^|\n)Description:\s*([\s\S]*?)(?=\n[A-Z][a-z]+:|$)/); - const t2 = txt.match(/(?:^|\n)Title:\s*(.+)/); - if(m && m[1] && m[1].trim()){ - const d = m[1].trim().slice(0,2000); - meta.setAttribute('data-desc', d); - metaHtml += '
' + esc(d) + '
'; - meta.innerHTML = metaHtml; - } - if(t2 && t2[1] && hotTitle && !hotTitle.value.trim()){ - hotTitle.value = t2[1].trim().slice(0,250); - } - }).catch(()=>{}); - }catch(e2){} - } - // fallback note when only oEmbed metadata is available (no direct media) - if(!scDirect && scEmbed){ - st.textContent = 'ℹ️ Video không tải trực tiếp được, sẽ chèn dạng oEmbed (tự lấy tiêu đề/mô tả).'; - st.style.color = '#e0a030'; - meta.style.display = 'block'; - const ebtn = document.getElementById('sc-video-scrape'); - if(ebtn) ebtn.style.display = 'none'; - } - if(j.previewable && scDirect){ - const isYouTube = /youtube\.com\/embed|youtu\.be\/|youtube-nocookie/.test(scDirect); - const isHls = !!j.is_hls || /\.m3u8/i.test(scDirect); - const streamUrl = isHls - ? '/api/ai-short/scrape/hls?url=' + encodeURIComponent(scDirect) - : '/api/ai-short/scrape/preview?url=' + encodeURIComponent(scDirect); - preview.style.display = 'block'; - preview.setAttribute('data-ishls', isHls ? '1' : '0'); - if(isYouTube){ - // YouTube embed — render as iframe (direct embed URL already has autoplay) - preview.setAttribute('data-ishls', '0'); - preview.innerHTML = ''; - st.textContent = '✅ Đã lấy video YouTube. Bấm play để xem trước.'; - st.style.color = '#8f8'; - } else { - preview.innerHTML = ''; - const v = preview.querySelector('#sc-scrape-video'); - if(isHls && v && typeof Hls !== 'undefined' && Hls.isSupported()){ - const hls = new Hls(); - hls.loadSource(streamUrl); - hls.attachMedia(v); - hls.on(Hls.Events.MANIFEST_PARSED, ()=>{ v.play().catch(()=>{}); }); - v._hls = hls; - } else if(isHls && v && v.canPlayType('application/vnd.apple.mpegurl')){ - v.src = streamUrl; // native Safari HLS - } - st.textContent = '✅ Đã lấy video. Bấm play để xem trước.'; - st.style.color = '#8f8'; - } - } else { - preview.style.display = 'none'; - if(j.note){ - st.textContent = '⚠️ ' + j.note; - st.style.color = '#e0a030'; - } else { - st.textContent = '❌ Không phát trước được (có thể do nguồn chặn). Bạn vẫn có thể thử tạo short.'; - st.style.color = '#e05555'; - } - } - }catch(e){ - st.textContent = '❌ ' + e.message; - st.style.color = '#e05555'; - preview.style.display = 'none'; - document.getElementById('sc-scrape-direct').value = ''; - document.getElementById('sc-scrape-duration').value = ''; - } - }); - } - - // ---- rewrite image picker (chọn 1 ảnh cho nhiều slide) ---- - let _scChosenImg = ''; - document.getElementById('sc-rewrite-list').addEventListener('click', e=>{ - const btn = e.target.closest('.sc-rw-img'); - if(!btn) return; - document.querySelectorAll('.sc-rw-img').forEach(b=>{ b.style.borderColor='#333'; b.dataset.sel='0'; }); - btn.style.borderColor='#ffd700'; btn.dataset.sel='1'; - _scChosenImg = btn.dataset.img || ''; - const hidden = document.getElementById('sc-chosen-img'); - if(hidden) hidden.value = _scChosenImg; - }); - - // ---- audio source toggles (skipped in HOT mode) ---- - if(!hotMode){ - function setAudioSrc(v){ - // nhạc nền TikTok luôn hiển thị để nghe thử; ẩn khi chọn "Không âm thanh" - document.getElementById('sc-music-wrap').style.display = v==='none' ? 'none' : 'block'; - document.getElementById('sc-upload-wrap').style.display = v==='upload' ? 'block' : 'none'; - } - document.getElementById('sc-audio-src').addEventListener('change', e=>setAudioSrc(e.target.value)); - if(hasPrev && p.video) setAudioSrc('prev'); else setAudioSrc('tts'); - } - - // ---- recreate slides: show/hide slide picker ---- - const scReuse = document.getElementById('sc-reuse-slides'); - if(scReuse){ - scReuse.addEventListener('change', function(){ - const pk = document.getElementById('sc-slide-picker'); - if(pk) pk.style.display = this.checked ? 'block' : 'none'; - }); - } - - // ---- music preview player (skipped in HOT mode) ---- - let _scAudio = null; - const listenBtn = hotMode ? null : document.getElementById('sc-music-listen'); - const musicSel = hotMode ? null : document.getElementById('sc-music'); - const playerBox = hotMode ? null : document.getElementById('sc-music-playerbox'); - if(listenBtn && musicSel){ - listenBtn.addEventListener('click', function(){ - const opt = musicSel.options[musicSel.selectedIndex]; - const aurl = opt ? (opt.dataset.aurl || '') : ''; - if(!aurl){ - alert('Không có link nhạc cho lựa chọn này.'); - return; - } - // proxy để tránh CORS khi phát thử trong trình duyệt - const streamUrl = '/api/ai-short/music/stream?url=' + encodeURIComponent(aurl); - if(_scAudio && _scAudio.dataset.src === aurl && !_scAudio.paused){ - _scAudio.pause(); listenBtn.textContent = '▶ Nghe thử'; - return; - } - if(_scAudio) _scAudio.pause(); - if(playerBox) playerBox.style.display = 'block'; - if(playerBox) playerBox.innerHTML = '
🎧 '+esc(opt.text)+'
'; - _scAudio = playerBox ? playerBox.querySelector('audio') : null; - if(_scAudio){ - _scAudio.dataset.src = aurl; - _scAudio.addEventListener('ended', function(){ listenBtn.textContent = '▶ Nghe thử'; }); - _scAudio.addEventListener('error', function(){ - listenBtn.textContent = '▶ Nghe thử'; - toast('❌ Không phát được nhạc thử (mạng/vùng chặn). Bạn vẫn có thể chọn để trộn vào video.'); - }); - listenBtn.textContent = '⏹ Dừng'; - } - }); - } - - // ---- create ---- - document.getElementById('sc-create-btn').addEventListener('click', async ()=>{ - const btn = document.getElementById('sc-create-btn'); - const st = document.getElementById('sc-status'); - btn.disabled = true; btn.textContent = '⏳ Đang tạo...'; - st.textContent = 'Đang chuẩn bị...'; - - // upload files first - try{ - // image file (not in HOT mode) - let imgUrl = ''; - const imgUrlInput = document.getElementById('sc-img-url'); - if(imgUrlInput) imgUrl = imgUrlInput.value.trim() || ''; - const imgFile = hotMode ? null : document.getElementById('sc-img-file').files[0]; - if(imgFile){ - st.textContent = '⏳ Đang tải ảnh lên...'; - const fd = new FormData(); fd.append('file', imgFile, imgFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload ảnh'); - imgUrl = j.url; - } - // video file (not in HOT mode) - let videoUrl = ''; - const videoFile = hotMode ? null : document.getElementById('sc-video-file').files[0]; - if(videoFile){ - st.textContent = '⏳ Đang tải video lên...'; - const fd = new FormData(); fd.append('file', videoFile, videoFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload video'); - videoUrl = j.url; - } - // audio file (not in HOT mode) - let uploadAudioUrl = ''; - const audioFile = hotMode ? null : document.getElementById('sc-audio-file').files[0]; - if(audioFile){ - st.textContent = '⏳ Đang tải audio lên...'; - const fd = new FormData(); fd.append('file', audioFile, audioFile.name); - const r = await fetch('/api/ai-short/upload', {method:'POST', body:fd}); - const j = await r.json(); - if(!r.ok || j.error) throw new Error(j.error || 'Lỗi upload audio'); - uploadAudioUrl = j.url; - } - - const audioSrc = hotMode ? 'video' : (document.getElementById('sc-audio-src').value); - const useSlides = hotMode ? false : (document.getElementById('sc-reuse-slides') ? document.getElementById('sc-reuse-slides').checked : false); - // nhạc nền TikTok: giá trị != none được trộn vào video (kể cả tạo lại) - let musicId = ''; - const musicSel = hotMode ? null : document.getElementById('sc-music'); - if(musicSel && musicSel.value && musicSel.value !== 'none' && audioSrc !== 'none'){ - musicId = musicSel.value; - } - if(audioSrc === 'music') musicId = musicSel ? musicSel.value : ''; - let audioUrl = ''; - if(hotMode){ audioUrl = ''; } - else if(audioSrc==='upload') audioUrl = uploadAudioUrl; - else if(audioSrc==='prev' && p.video){ audioUrl = p.short_audio_url || ''; } - // reuse_audio explicitly: only when user chose "giữ nguyên audio short trước" - const reuseAudio = hotMode ? false : (audioSrc === 'prev'); - // collect selected slide indices (recreate mode) - let slideIndices = []; - if(useSlides){ - document.querySelectorAll('#sc-slide-picker .sc-slide-chk:checked').forEach(chk=>{ - slideIndices.push(parseInt(chk.dataset.idx)); - }); - if(!slideIndices.length){ - st.textContent = '⚠️ Chọn ít nhất 1 slide để tạo lại.'; - throw new Error('Chọn ít nhất 1 slide'); - } - } - // reuse_audio is sent explicitly above; music (audio_music) is overlaid by the backend - const body = { - post_id: p.id, - use_slides: useSlides, - slide_indices: slideIndices, - reuse_audio: reuseAudio, - voice: hotMode ? 'vi-VN-HoaiMyNeural' : document.getElementById('sc-voice').value, - emotion: hotMode ? 'neutral' : document.getElementById('sc-emotion').value, - speed: hotMode ? 1.0 : (parseFloat(document.getElementById('sc-speed').value) || 1.2), - audio_music: musicId, - audio_url: audioUrl, - }; - if(hotMode){ - // create a brand-new wall post on the AI wall - body.create_new = true; - const hotTitleEl = document.getElementById('sc-hot-title'); - body.title = hotTitleEl ? hotTitleEl.value.trim() : ''; - // scraped video title as fallback wall title - if(!body.title){ - const meta = document.getElementById('sc-video-meta'); - body.title = (meta && meta.dataset.title) ? meta.dataset.title : '🔥 Short HOT'; - } - body.text = ''; - body.language = 'vi'; - const srcEl = document.getElementById('sc-video-link'); - if(srcEl && srcEl.value.trim()) body.url = srcEl.value.trim(); - } - // if uploaded video selected -> use it as background via images? No: - if(bgMode==='video' && videoUrl){ - body.video_url = videoUrl; // backend handles - const vmute = document.getElementById('sc-video-mute'); - if(vmute && vmute.checked) body.video_muted = true; - } else if(bgMode==='link'){ - // scraped video from link: video-first + image tail completion - const scDirect = document.getElementById('sc-scrape-direct') ? document.getElementById('sc-scrape-direct').value : ''; - const meta = document.getElementById('sc-video-meta'); - const scEmbed = meta ? (meta.getAttribute('data-embed') || '') : ''; - const scThumb = meta ? (meta.getAttribute('data-thumb') || '') : ''; - const scTitle = meta ? (meta.getAttribute('data-title') || '') : ''; - const scDesc = meta ? (meta.getAttribute('data-desc') || '') : ''; - // if the modal title was auto-filled from the scrape, prefer it for - // the wall post (user can still edit it before submitting) - const hotTitleVal = document.getElementById('sc-hot-title') ? document.getElementById('sc-hot-title').value.trim() : ''; - if(scDirect && !/youtube\.com\/embed|youtu\.be\/|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|player\.vimeo\.com|platform\.twitter\.com/.test(scDirect)){ - body.video_url = scDirect; - body.scrape_mode = true; // video-first assembly - const vdur = document.getElementById('sc-scrape-duration') ? document.getElementById('sc-scrape-duration').value : ''; - if(vdur) body.video_dur = parseFloat(vdur) || undefined; - const lmute = document.getElementById('sc-link-mute'); - if(lmute && lmute.checked) body.video_muted = true; - // still send the chosen images (they complete the short if the - // video is shorter than the voice) - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; - if(chosen) body.fixed_image = chosen; - } else if(scEmbed){ - // oEmbed fallback: media can't be downloaded (TikTok/FB/IG/YT block - // datacenter IPs) -> save the short as an embeddable slide with - // auto title/desc/thumb from the scrape metadata. - body.embed_url = scEmbed; - body.embed_title = hotTitleVal || scTitle || ''; - body.embed_thumb = scThumb || ''; - body.embed_desc = scDesc || ''; - const chosenImg = imgUrl || (document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''); - if(chosenImg) body.images = [chosenImg]; - } else { - // No direct media and no embed metadata from the scrape. If the - // user pasted a social/link URL, still post it — the backend - // auto-probes oEmbed and saves the short as an embed slide with - // auto title/desc/thumb (no more "chưa lấy được video" error). - const srcUrlEl = document.getElementById('sc-video-link'); - const srcUrl = srcUrlEl ? srcUrlEl.value.trim() : ''; - if(srcUrl && /^https?:\/\//i.test(srcUrl)){ - body.url = srcUrl; - body.allow_oembed_fallback = true; - st.textContent = 'ℹ️ Không lấy được video trực tiếp — sẽ đăng dạng oEmbed (backend tự lấy tiêu đề/mô tả/ảnh).'; - st.style.color = '#e0a030'; - } else { - st.textContent = '⚠️ Bấm "📥 Lấy video" để lấy video từ link trước khi tạo.'; - throw new Error('Chưa lấy được video từ link'); - } - } - } else { - // 1. ảnh chọn từ danh sách rewrite (áp dụng cho nhiều slide, fix nền cho cả short) - // 2. ảnh URL nhập tay / tải lên - const chosen = document.getElementById('sc-chosen-img') ? document.getElementById('sc-chosen-img').value : ''; - const imgs = []; - if(chosen) imgs.push(chosen); - if(imgUrl) imgs.push(imgUrl); - if(imgs.length) body.images = imgs; // backend: first = fixed bg (normal mode) - if(chosen) body.fixed_image = chosen; // applies to slides mode too (1 ảnh cho nhiều slide) - } - st.textContent = '⏳ Đang tạo video short (có thể mất 1-2 phút)...'; - const r2 = await fetch('/api/ai-short', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(body)}); - const j2 = await r2.json(); - if(!r2.ok || j2.error) throw new Error(j2.error || 'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!'); - const pp = _wallPosts.find(x => String(x.id) === String(p.id)); - if(pp){ - pp.video = j2.video; - pp.voice = j2.voice; pp.emotion = j2.emotion; pp.short_speed = j2.speed; - pp.short_music = j2.music; pp.short_audio_url = audioUrl; pp.short_use_slides = j2.use_slides; - pp.short_images = j2.images; - } - if(hotMode && j2.post){ - // 🆕 brand-new wall post created by the backend -> prepend on the AI wall - prependWallPost(j2.post); - } else { - const itemId = 'wall-item-'+p.id; - const el = document.getElementById(itemId); - if(el){ - const idx = _wallPosts.indexOf(pp); - el.insertAdjacentHTML('afterend', makeWallItem(pp, idx)); - el.remove(); - } - } - document.getElementById('short-creator-overlay').remove(); - }catch(e){ - st.textContent = '❌ ' + e.message; - btn.disabled = false; btn.textContent = hotMode ? '🔥 Thêm Short HOT' : '🎬 Tạo Short AI'; - toast('❌ ' + e.message); - } - }); - }); -} - -/* legacy fast path — used by any remaining tts-create-btn handlers */ -async function makeShortVideo(postId, btn, voice, speed, emotion){ - openShortCreator(postId); - return; -} - -var VOICE_LIST = [ - {id:'vi-VN-HoaiMyNeural', label:'🎙️ Hoài My (VI)', lang:'vi'}, - {id:'vi-VN-NamMinhNeural', label:'🎙️ Nam Minh (VI)', lang:'vi'}, - {id:'en-US-AndrewMultilingualNeural', label:'🎙️ Andrew (EN)', lang:'en'}, - {id:'en-AU-WilliamMultilingualNeural', label:'🎙️ William (EN)', lang:'en'}, - {id:'pt-BR-ThalitaMultilingualNeural', label:'🎙️ Thalita (PT)', lang:'pt'}, - {id:'fr-FR-VivienneMultilingualNeural', label:'🎙️ Vivienne (FR)', lang:'fr'}, - {id:'fr-FR-RemyMultilingualNeural', label:'🎙️ Rémy (FR)', lang:'fr'}, - {id:'de-DE-SeraphinaMultilingualNeural', label:'🎙️ Seraphina (DE)', lang:'de'}, - {id:'de-DE-FlorianMultilingualNeural', label:'🎙️ Florian (DE)', lang:'de'}, - {id:'ko-KR-HyunsuMultilingualNeural', label:'🎙️ Hyunsu (KO)', lang:'ko'}, - {id:'it-IT-GiuseppeMultilingualNeural', label:'🎙️ Giuseppe (IT)', lang:'it'}, -]; -var EMOTION_LIST = [ - {id:'neutral', label:'😐 Trung tính'}, - {id:'happy', label:'😊 Vui vẻ'}, - {id:'excited', label:'🔥 Hào hứng'}, - {id:'sad', label:'😢 Buồn'}, - {id:'humorous', label:'😂 Hài hước'}, - {id:'serious', label:'⚠️ Nghiêm túc'}, - {id:'urgent', label:'🚨 Khẩn cấp'}, - {id:'warm', label:'💖 Ấm áp'}, -]; - -document.addEventListener('click',function(e){ - var btn = e.target.closest('.tts-voice-btn'); - if(btn){ - var container = btn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:btn.dataset.voice,emotion:'neutral'}; - var allBtns = container.querySelectorAll('.tts-voice-btn'); - for(var i=0;ip.video); + let shortAISection = document.getElementById('short-ai-section'); + if(aiShorts.length === 0){ + if(shortAISection) shortAISection.remove(); return; } - var ebtn = e.target.closest('.tts-emotion-btn'); - if(ebtn){ - var container = ebtn.closest('.tts-selector'); - if(container){ - var selKey = 'inline-'+container.dataset.postId; - if(!_ttsSelections[selKey]) _ttsSelections[selKey]={voice:'vi-VN-HoaiMyNeural',emotion:ebtn.dataset.emotion}; - var allBtns = container.querySelectorAll('.tts-emotion-btn'); - for(var i=0;i{ + h+=`
${esc(p.title)}
`; + }); + track.innerHTML = h; } - return; } -}); -function detectLanguage(text){ - if(!text) return 'vi'; - var t=text.toLowerCase(), chars=new Set(t); - var vnChars='đăâêôơưàảãạáằẳẵặắầẩẫậấèẻẽẹéềễểệếìỉĩịíòỏõọóồổỗộốờởỡợớùủũụúừửữựứỳỷỹỵý'; - var vnCount=0; for(var c of vnChars){if(chars.has(c)) vnCount++;} - if(vnCount>=2) return 'vi'; - if(chars.has('ñ')||chars.has('¿')||chars.has('¡')) return 'es'; - if(chars.has('ã')||chars.has('õ')) return 'pt'; - var words=t.split(/\s+/); - var enWords=['the','is','at','which','on','and','or','but','this','that','with','from','have','been']; - var enCount=words.filter(function(w){return enWords.indexOf(w)>=0;}).length; - if(enCount>=2) return 'en'; - return 'vi'; } -function detectEmotion(text){ - if(!text) return 'neutral'; - var t=text.toLowerCase(); - var kws={ - happy:['vui','hạnh phúc','tuyệt','thành công','chiến thắng','feliz','maravilloso','happy','joy','wonderful','great','amazing','love','excellent'], - excited:['hào hứng','phấn khích','đột phá','kỷ lục','đỉnh cao','emocionante','increíble','excited','thrilling','unbelievable','awesome','breakthrough'], - sad:['buồn','đau','mất','thảm họa','khủng hoảng','triste','terrible','sad','unhappy','tragic','painful','death'], - humorous:['hài hước','buồn cười','haha','đùa','engraçado','gracioso','funny','hilarious','joke','lol'], - serious:['nghiêm trọng','khẩn cấp','quan trọng','lo ngại','sério','crítico','serious','critical','urgent','severe','crisis'], - urgent:['khẩn cấp','báo động','ngay lập tức','urgent','breaking','alert','emergency'], - warm:['ấm áp','tình cảm','yêu thương','warm','love','heart','touching'] - }; - var bestScore=0, bestEmotion='neutral'; - for(var em in kws){var score=0; for(var kw of kws[em]){if(t.indexOf(kw)>=0) score++;} if(score>bestScore){bestScore=score;bestEmotion=em;}} - return bestEmotion; -} -function getAutoVoice(lang){var map={vi:'vi-VN-HoaiMyNeural',pt:'pt-BR-ThalitaMultilingualNeural',en:'en-US-AndrewMultilingualNeural',fr:'fr-FR-VivienneMultilingualNeural',de:'de-DE-SeraphinaMultilingualNeural',ko:'ko-KR-HyunsuMultilingualNeural',it:'it-IT-GiuseppeMultilingualNeural'};return map[lang]||'vi-VN-HoaiMyNeural';} -function buildVoiceEmotionSelector(post){ - var lang=post.language||detectLanguage(post.title+' '+(post.text||'')); - var _oldVoiceMap = {'hoaimy':'vi-VN-HoaiMyNeural','namminh':'vi-VN-NamMinhNeural','andrew':'en-US-AndrewMultilingualNeural','jenny':'en-US-AndrewMultilingualNeural','thalita':'pt-BR-ThalitaMultilingualNeural','pt_thalita':'pt-BR-ThalitaMultilingualNeural','vivienne':'fr-FR-VivienneMultilingualNeural','remy':'fr-FR-RemyMultilingualNeural','seraphina':'de-DE-SeraphinaMultilingualNeural','florian':'de-DE-FlorianMultilingualNeural','sunhee':'ko-KR-HyunsuMultilingualNeural','hyunsu':'ko-KR-HyunsuMultilingualNeural','giuseppe':'it-IT-GiuseppeMultilingualNeural','ela':'en-US-AndrewMultilingualNeural','denise':'fr-FR-VivienneMultilingualNeural','katja':'de-DE-SeraphinaMultilingualNeural','nanami':'en-US-AndrewMultilingualNeural','xiaochen':'en-US-AndrewMultilingualNeural','es_carlos':'en-US-AndrewMultilingualNeural','pt_francisco':'pt-BR-ThalitaMultilingualNeural'}; - var _postVoice = post.voice ? (_oldVoiceMap[post.voice] || post.voice) : ''; - var autoVoice= _postVoice || getAutoVoice(lang); - var autoEmotion=post.emotion||detectEmotion(post.title+' '+(post.text||'')); - var selKey = 'inline-'+post.id; - if(!_ttsSelections[selKey]){_ttsSelections[selKey] = {voice: autoVoice, emotion: autoEmotion};} - var h='
'; - h+='
🎙️ Giọng đọc (ngôn ngữ: '+lang.toUpperCase()+'):
'; - VOICE_LIST.forEach(function(v){var sel=v.id===_ttsSelections[selKey].voice?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===_ttsSelections[selKey].emotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:'; - h+='
'; - h+='
'; - return h; -} -window.showVoiceEmotionSelector=function(postId,title,text){ - var overlay=document.createElement('div'); - overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.85);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px'; - var box=document.createElement('div');box.style.cssText='background:#1a1a1a;border:2px solid #2d8659;border-radius:16px;padding:20px;max-width:400px;width:100%;max-height:80vh;overflow-y:auto'; - var lang=detectLanguage(title+' '+text);var autoEmotion=detectEmotion(title+' '+text); - var h='

🎬 Tạo Short AI (ngôn ngữ: '+lang.toUpperCase()+')

'; - h+='
🎙️ Chọn giọng đọc:
'; - VOICE_LIST.forEach(function(v){var sel=v.id===getAutoVoice(lang)?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
😊 Chọn cảm xúc:
'; - EMOTION_LIST.forEach(function(e){var sel=e.id===autoEmotion?'border-color:#5cb87a;background:#1a2a1f':'border-color:#333;background:#222';h+='';}); - h+='
⚡ Tốc độ:
'; - h+='
'; - h+='
'; - h+='
'; - h+=''; - box.innerHTML=h;overlay.appendChild(box);document.body.appendChild(overlay); - var selectedVoice=getAutoVoice(lang),selectedEmotion=autoEmotion; - box.querySelectorAll('.ve-voice-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-voice-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedVoice=this.dataset.voice;});}); - box.querySelectorAll('.ve-emotion-btn').forEach(function(btn){btn.addEventListener('click',function(){box.querySelectorAll('.ve-emotion-btn').forEach(function(b){b.style.borderColor='#333';b.style.background='#222';});this.style.borderColor='#5cb87a';this.style.background='#1a2a1f';selectedEmotion=this.dataset.emotion;});}); - box.querySelector('#ve-cancel-btn').addEventListener('click',function(){overlay.remove();}); - box.querySelector('#ve-create-btn').addEventListener('click',async function(){ - this.disabled=true;this.textContent='⏳ Đang tạo...'; - box.querySelector('#ve-status').style.display='block';box.querySelector('#ve-status').textContent='Đang tạo video shorts...'; - try{ - var speed=parseFloat(box.querySelector('#ve-speed').value)||1.2; - if(!_ttsSelections["inline-"+postId]) _ttsSelections["inline-"+postId]={voice:"vi-VN-HoaiMyNeural",emotion:"neutral"}; - _ttsSelections["inline-"+postId].voice=selectedVoice;_ttsSelections["inline-"+postId].emotion=selectedEmotion; - var r=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:selectedVoice,emotion:selectedEmotion,speed:speed})}); - var j=await r.json(); - if(!r.ok||j.error) throw new Error(j.error||'Lỗi tạo video'); - toast('✅ Đã tạo Short AI!');overlay.remove(); - var p=_wallPosts.find(function(x){return String(x.id)===String(postId);}); - if(p){p.video=j.video;p.voice=j.voice;p.emotion=j.emotion;} - }catch(e){this.disabled=false;this.textContent='🎬 Tạo Short';box.querySelector('#ve-status').textContent='❌ '+e.message;} - }); -}; function prependWallPost(post){ _wallPosts.unshift(post); const track=document.getElementById('ai-wall-track'); const wrap=document.getElementById('ai-wall-wrap'); - const target=document.getElementById('ai-wall-under-compose'); - if(target && (!track||!wrap)){ - const newWrap=document.createElement('div'); - newWrap.className='slider-wrap';newWrap.id='ai-wall-wrap'; - newWrap.innerHTML=`
🧱 Tường AI
${makeWallItem(post,0)}
`; - target.appendChild(newWrap); - const firstItem=newWrap.querySelector('.wall-item'); - if(firstItem)firstItem.className='wall-item wall-item-new'; - // re-interleave YouTube feed items after new wall content - if(typeof _ytFeedVideos !== 'undefined') _renderYTFeedInWall(); + const homeEl=document.getElementById('view-home'); + if(!track||!wrap){ + if(homeEl){ + let insertBefore=homeEl.querySelector('.slider-wrap'); + const newWrap=document.createElement('div'); + newWrap.className='slider-wrap'; + newWrap.id='ai-wall-wrap'; + newWrap.innerHTML=`
🧱 Tường AI
${makeWallItem(post,0)}
`; + if(insertBefore) homeEl.insertBefore(newWrap,insertBefore); + else homeEl.appendChild(newWrap); + const firstItem=newWrap.querySelector('.wall-item'); + if(firstItem)firstItem.className='wall-item wall-item-new'; + } return; } - if(track){ - track.insertAdjacentHTML('afterbegin', makeWallItem(post, 0)); - track.scrollTo({left:0,behavior:'smooth'}); - // re-interleave YouTube feed items after new wall content - if(typeof _renderYTFeedInWall === 'function') _renderYTFeedInWall(); - } -} - + const div=document.createElement('div'); + div.className='wall-item wall-item-new'; + div.id='wall-item-'+(post.id||'new-'+Date.now()); + const hasVideo = post.video && post.video.length > 0; + const thumbContent = post.img + ? `` + : (hasVideo ? `` : ''); + const videoBadge = hasVideo ? `
🎬
` : ''; + const videoBtn = hasVideo + ? `` + : ``; + div.innerHTML=`
${thumbContent}${videoBadge}
${esc(post.title)}
${esc((post.text||'').slice(0,180))}
${videoBtn}
`; + track.prepend(div); + track.scrollTo({left:0,behavior:'smooth'}); + if(hasVideo) refreshShortAISlider(); +} + +// === REST OF FUNCTIONS === +let _shortsData=[]; let _wallPosts=[]; let _currentView='home'; let _currentEventId=null; let _currentMatchUrl=null; +function interleaveShorts(shorts){const dt=shorts.filter(s=>s.channel==='baodantri7941');const sk=shorts.filter(s=>s.channel==='baosuckhoedoisongboyte');const result=[];let i=0,j=0;while(i{const topicText=t.topic||t.label.replace(/^#/,'');return``;}).join('');} +async function loadHotTopics(){const j=await fetch('/api/hot_topics').then(r=>r.json()).catch(()=>({topics:[]}));const el=document.getElementById('hot-topics');if(!el)return;el.innerHTML=(j.topics||[]).slice(0,18).map(t=>{const topicText=t.topic||t.label.replace(/^#/,'');return``;}).join('');if(j.topics&&j.topics[0]){const firstTopic=j.topics[0].topic||j.topics[0].label.replace(/^#/,'');setTimeout(()=>searchTopic(firstTopic),800);}} function searchTopic(topic){if(!topic){topic=document.getElementById('topic-input')?.value.trim();if(!topic){alert('Nhập chủ đề');return;}}document.getElementById('topic-input').value='';_htTopic=topic;_htPage=0;showHashtagSources(topic,0);} -async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`

🔍 ${esc(topic)}

Đang tìm...
`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`

🔍 ${esc(topic)}

Không tìm được bài viết liên quan
`;return;}let h='';if(page===0)h=`

🔍 ${esc(topic)} (${j.total} bài từ 8 nguồn)

`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`
${esc(s.title)}
${esc(s.via||'')}
`;});if(page===0){h+=`
`;if(j.has_more)h+=``;h+=`
`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;const ctrl=new AbortController();setTimeout(()=>ctrl.abort(),4000);fetch('/api/article?url='+encodeURIComponent(s.url),{signal:ctrl.signal}).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=``;}}).catch(()=>{});});}catch(e){box.innerHTML=`

🔍 ${esc(topic)}

Lỗi: ${esc(e.message)}
`;}} +async function showHashtagSources(topic,page){const box=document.getElementById('hashtag-box');if(!box)return;if(page===0)box.innerHTML=`

🔍 ${esc(topic)}

Đang tìm...
`;try{const r=await fetch(`/api/hashtag/sources?topic=${encodeURIComponent(topic)}&page=${page}`);const j=await r.json();const sources=j.sources||[];if(!sources.length&&page===0){box.innerHTML=`

🔍 ${esc(topic)}

Không tìm được bài viết liên quan
`;return;}let h='';if(page===0)h=`

🔍 ${esc(topic)} (${j.total} bài từ 8 nguồn)

`;sources.forEach((s,i)=>{const idx=page*8+i;h+=`
${esc(s.title)}
${esc(s.via||'')}
`;});if(page===0){h+=`
`;if(j.has_more)h+=``;h+=`
`;box.innerHTML=h;}else{document.getElementById('ht-list')?.insertAdjacentHTML('beforeend',h);const btn=document.getElementById('ht-more');if(btn){if(!j.has_more)btn.remove();else{btn.disabled=false;btn.textContent='Tải thêm ▼';}}}sources.forEach((s,i)=>{const idx=page*8+i;if(!s.url)return;fetch('/api/article?url='+encodeURIComponent(s.url)).then(r=>r.json()).then(d=>{if(d&&(d.og_image||d.img)){const el=document.getElementById('ht-img-'+idx);if(el)el.innerHTML=``;}}).catch(()=>{});});}catch(e){box.innerHTML=`

🔍 ${esc(topic)}

Lỗi: ${esc(e.message)}
`;}} function loadMoreHashtag(){_htPage++;const btn=document.getElementById('ht-more');if(btn){btn.disabled=true;btn.textContent='Đang tải...';}showHashtagSources(_htTopic,_htPage);} async function rewriteHashtag(topic){const btn=event?.target;if(btn){btn.disabled=true;btn.textContent='Đang tổng hợp...';}try{const r=await fetch('/api/topic_post',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({topic})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã đăng Tường AI!');if(btn)btn.textContent='✅ Đăng thành công!';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Rewrite AI';}}} -async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='
Đang tải...
';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const d=await _fetchWithTimeout(ep,12000);el.innerHTML=d.html&&d.html.length>50?d.html:'
Không có dữ liệu
';bindMatchClicks(el);if(tab.startsWith('bxh_'))_decorateStandings(el);}catch(e){el.innerHTML='
Lỗi tải hoặc hết thời gian
';}} -function _decorateStandings(el){ - try{ - const items=el.querySelectorAll('.leaderboard-item'); - if(!items.length)return; - // Build header row: # | Đội bóng | Tr T H B BT BB +/- Đ - const head=document.createElement('div'); - head.className='ls-bxh-head'; - head.innerHTML='

Tr

T

H

B

BT

BB

+/-

Đ

'; - el.insertBefore(head, items[0]); - // Make each row use flex layout with the copy cells as a fixed-width grid - items.forEach(function(it){ - it.classList.add('ls-bxh-row'); +async function loadLivescore(tab){document.querySelectorAll('.ls-tab').forEach(t=>t.classList.remove('active'));document.querySelector(`.ls-tab[data-tab="${tab}"]`)?.classList.add('active');const el=document.getElementById('ls-content');if(!el)return;el.innerHTML='
Đang tải...
';let ep='/api/livescore/'+tab;if(tab.startsWith('bxh_'))ep='/api/livescore/standings/'+tab.replace('bxh_','');try{const r=await fetch(ep);const d=await r.json();el.innerHTML=d.html&&d.html.length>50?d.html:'
Không có dữ liệu
';bindMatchClicks(el);}catch(e){el.innerHTML='
Lỗi
';}} +function bindMatchClicks(el){ + el.querySelectorAll('.match-detail').forEach(md=>{ + md.style.cursor='pointer'; + md.addEventListener('click',function(e){ + const statusA=this.querySelector('.status a'); + const teamA=this.querySelector('.teams a[href*="/tran-dau/"]'); + const a = statusA || teamA; + if(a){ + e.preventDefault(); + e.stopPropagation(); + const href=a.getAttribute('href')||''; + const m=href.match(/\/tran-dau\/(\d+)\//); + if(m){ + const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href; + openMatch(m[1],fullUrl); + } + } }); - }catch(e){} + }); + el.querySelectorAll('a').forEach(a=>{ + a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()}); + }); } -function bindMatchClicks(el){el.querySelectorAll('.match-detail').forEach(md=>{md.style.cursor='pointer';md.addEventListener('click',function(e){const statusA=this.querySelector('.status a');const teamA=this.querySelector('.teams a[href*="/tran-dau/"]');const a = statusA || teamA;if(a){e.preventDefault();e.stopPropagation();const href=a.getAttribute('href')||'';const m=href.match(/\/tran-dau\/(\d+)\//);if(m){const fullUrl=href.startsWith('http')?href:'https://bongda.com.vn'+href;openMatch(m[1],fullUrl);}}});});el.querySelectorAll('a').forEach(a=>{a.addEventListener('click',e=>{e.preventDefault();e.stopPropagation()});});} function openMatch(id,url){if(!id)return;_currentEventId=id;if(url)_currentMatchUrl=url;document.getElementById('match-overlay').classList.add('active');document.body.style.overflow='hidden';loadMatchTab('detail')} function closeMatch(){document.getElementById('match-overlay').classList.remove('active');document.body.style.overflow=''} async function loadMatchTab(tab){document.querySelectorAll('.mo-tab').forEach(t=>t.classList.remove('active'));document.querySelectorAll('.mo-tab').forEach(t=>{if((tab==='comm'&&t.textContent==='Diễn biến')||(tab==='stats'&&t.textContent==='Thống kê')||(tab==='detail'&&t.textContent.includes('Chi tiết')))t.classList.add('active')});const el=document.getElementById('mo-body');if(!el)return;el.innerHTML='
Đang tải...
';try{let apiUrl;if(tab==='stats')apiUrl=`/api/match/${_currentEventId}/stats`;else if(tab==='comm')apiUrl=`/api/match/${_currentEventId}/commentaries`;else{apiUrl=`/api/match/${_currentEventId}/detail`;if(_currentMatchUrl)apiUrl+='?url='+encodeURIComponent(_currentMatchUrl)}const r=await fetch(apiUrl);if(!r.ok){el.innerHTML='
Lỗi máy chủ ('+r.status+')
';return}const d=await r.json();if(d.error){el.innerHTML='
'+esc(d.error)+'
';return}if(tab==='detail'&&typeof renderMatchDetail==='function'){renderMatchDetail(el,d);return}el.innerHTML=d.html||'
Không có dữ liệu
'}catch(e){el.innerHTML='
Lỗi
'}} -function esc(s){return String(s||'').replace(/[&<>"']/g,m=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]))} -function showView(id){document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.getElementById(id)?.classList.add('active')} -function switchCat(id){document.querySelectorAll('.cat').forEach(c=>c.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(v=>v.classList.remove('active'));document.querySelectorAll('video').forEach(v=>{v.pause();if(v._hls){v._hls.destroy();v._hls=null}});document.querySelectorAll('iframe[data-yt-src]').forEach(f=>{f.src='';const tw=f.closest('.yt-thumb-wrap');if(tw)tw.style.backgroundImage='none'});if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='news-all'){document.getElementById('view-cat').classList.add('active');loadNewsTab()}else{document.getElementById('view-cat').classList.add('active');loadCat(id)}} -function toast(msg){let t=document.getElementById('progress-toast');if(t){t.textContent=msg;t.style.display='block';setTimeout(()=>{t.style.display='none'},3500)}} - -// ===== doShare: COPY link to clipboard, then try native share as bonus ===== - function doShare(title,url,img,postId){ - var shareUrl; - var _base = (typeof SPACE!=='undefined' && SPACE) ? SPACE : location.origin; - if(postId){ - shareUrl = _base+'/s?post_id='+encodeURIComponent(postId)+'&title='+encodeURIComponent(title); - } else { - shareUrl = _base+'/s?url='+encodeURIComponent(url)+'&title='+encodeURIComponent(title)+'&img='+encodeURIComponent(img||''); - } - // Try clipboard API first (modern, works on HTTPS) - if(navigator.clipboard && navigator.clipboard.writeText){ - navigator.clipboard.writeText(shareUrl).then(function(){ - toast('📋 Đã sao chép link!'); - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }).catch(function(){ - // Fallback: execCommand (deprecated but works on some browsers) - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - }); - } else { - // execCommand approach - try{ - var ta=document.createElement('textarea'); - ta.value=shareUrl;ta.style.position='fixed';ta.style.left='-9999px';ta.style.top='-9999px';ta.style.opacity='0'; - document.body.appendChild(ta);ta.select();ta.setSelectionRange(0,99999); - if(document.execCommand('copy')){toast('📋 Đã sao chép link!');} - else{prompt('📋 Sao chép link:', shareUrl);} - document.body.removeChild(ta); - }catch(e){prompt('📋 Sao chép link:', shareUrl);} - try{if(navigator.share)navigator.share({title:title||'',url:shareUrl}).catch(function(){});}catch(e){} - } -} - async function doInteract(videoId,type){try{const r=await fetch('/api/v2/interact',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,type})});return await r.json();}catch(e){return null;}} async function getInteractions(videoId){try{return await fetch('/api/v2/interactions?id='+encodeURIComponent(videoId)).then(r=>r.json());}catch(e){return{views:0,likes:0,comments:0};}} async function getComments(videoId){try{const j=await fetch('/api/v2/comments?id='+encodeURIComponent(videoId)).then(r=>r.json());return j.comments||[];}catch(e){return[];}} async function postComment(videoId,text){try{const j=await fetch('/api/v2/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id:videoId,text})}).then(r=>r.json());return j.comments||[];}catch(e){return[];}} -function buildTikTokSlide(opts){return`
${opts.vtag}
${opts.badge||''}

${esc(opts.title)}

${opts.desc?`

${esc(opts.desc)}

`:''}
${opts.extraBtn||''}
${opts.idx+1}/${opts.total}
`;} -function toggleFeedFullscreen(videoId){ - const slide = videoId ? document.querySelector('.tiktok-slide[data-vid="'+videoId+'"]') : null; - const feedEl = slide ? slide.closest('.tiktok-container') : document.querySelector('.tiktok-container'); - if(!feedEl) return; - const on = feedEl.classList.toggle('feed-fullscreen'); - document.body.style.overflow = on ? 'hidden' : ''; - const icons = feedEl.querySelectorAll('[id^="fs-toggle-"]'); - icons.forEach(ic => { ic.textContent = on ? '✕' : '⛶'; }); - if(on){ - // keep the active slide centered & playable in full height - setTimeout(()=>{ slide && slide.scrollIntoView({block:'nearest'}); }, 60); - } else if(slide){ - slide.scrollIntoView({block:'nearest'}); - } -} +function buildTikTokSlide(opts){return`
${opts.vtag}
${opts.badge||''}

${esc(opts.title)}

${opts.extraBtn||''}
${opts.idx+1}/${opts.total}
`;} async function doView(videoId,btn){const j=await doInteract(videoId,'view');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.views);}} async function doLike(videoId,btn){const j=await doInteract(videoId,'like');if(j){const c=btn.querySelector('.count');if(c)c.textContent=fmtNum(j.likes);}} function fmtNum(n){if(!n)return'0';if(n>=1000000)return(n/1000000).toFixed(1)+'M';if(n>=1000)return(n/1000).toFixed(1)+'K';return String(n);} async function loadCounters(videoIds){for(let i=0;iĐang tải...
';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);/* Remove snap so feed can scroll to show comment input */var sl=panel.closest('.tiktok-slide');if(sl)sl.style.scrollSnapAlign='';setTimeout(function(){var feed=document.getElementById('tiktok-feed');if(feed){var slidePos=sl?sl.offsetTop:0;feed.scrollTop=slidePos+200;}/* Focus input */var inp=document.getElementById('cmt-input-'+idx);if(inp)inp.focus();},300);} -function renderInlineComments(panel,videoId,idx,cmts){let h='
💬 Bình luận
';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`
${c.time||''}${esc(c.text)}
`;});}else{h+='
Chưa có bình luận
';}h+=`
`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;} -async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);/* Keep focus on input after submit */var inp2=document.getElementById('cmt-input-'+idx);if(inp2)inp2.focus();} -function toggle169View(videoId,iconId){const slide=videoId?document.querySelector(`.tiktok-slide[data-vid="${videoId}"]`):null;if(slide){slide.classList.toggle('ratio-wide');const iconEl=iconId?document.getElementById(iconId):null;if(iconEl)iconEl.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';else{const btn=slide.querySelector('.tiktok-right-btn .icon');if(btn)btn.textContent=slide.classList.contains('ratio-wide')?'📺':'🖥️';}return}document.querySelectorAll('.tiktok-slide.ratio-wide').forEach(s=>s.classList.remove('ratio-wide'));document.querySelectorAll('.tiktok-slide').forEach(s=>s.classList.add('ratio-wide'));document.querySelectorAll('.tiktok-right-btn .icon').forEach(b=>{if(b.textContent==='🖥️')b.textContent='📺';})} -function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){sl._active=true;if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc){fr.src=fr.dataset.ytSrc;const tw=fr.closest('.yt-thumb-wrap');if(tw)tw.style.backgroundImage='none'}const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{sl._active=false;if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const g=sl.querySelector('.slide-gesture');const v=sl.querySelector('video');const fr=sl.querySelector('iframe');const tapFn=e=>{if(g&&e.target!==g)return;if(v){e.preventDefault();if(e.detail>2)return;v.paused?v.play().catch(()=>{}):v.pause()}else if(fr&&fr.dataset.ytSrc&&sl._active){const src=fr.dataset.ytSrc;if(/tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com|youtube\.com\/embed/.test(src)){window.open(src.replace(/&/g,'&'),'_blank')}}};if(g)g.addEventListener('click',tapFn);if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)} -async function openHighlightFeed(league,idx,link,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='
Không có video
';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link)+'&img='+encodeURIComponent(a.img||''));const v=await r.json();if(v&&v.src){return{_idx:i,title:a.title||v.title||'',link:a.link||'',img:a.img||v.poster||'',src:v.src,type:v.type||'',poster:v.poster||a.img||''}}return null}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='
Không tìm thấy video
';return}let ti=-1;if(link){ti=vids.findIndex(v=>(v.link||'')===(link||''));}if(ti<0)ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`
`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${esc(v.poster)}"`:'';const vtag=isYT?`
`:isHLS?``:``;const videoId='hl-'+league+'-'+v._idx;const nv=(i+1)%ordered.length;h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',postId:'',extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -async function openYTShortsFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đã xóa Shorts Dân trí/SKĐS
';} -async function openShortAIFeed(idx){showView('view-tiktok');const el=document.getElementById('view-tiktok');if(!_wallPosts||!_wallPosts.length){el.innerHTML='
Không có Short AI
';return}const aiPosts=_wallPosts.filter(p=>p.video);if(!aiPosts.length||idx>=aiPosts.length){el.innerHTML='
Không có Short AI
';return}const ordered=aiPosts.slice(idx).concat(aiPosts.slice(0,idx));let h=`
`;ordered.forEach((p,i)=>{const baseIdx=_wallPosts.indexOf(p);const isYT=/youtube\.com\/embed|youtu\.be\/|youtube-nocookie|tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|platform\.twitter\.com|player\.vimeo\.com/.test(p.video||'');const vtag=p.video?isYT?`
`:``:'';h+=buildTikTokSlide({vtag,title:p.title,badge:'Short AI',badgeClass:'badge-ai',videoId:p.id||'ai-'+i,idx:i,total:ordered.length,shareUrl:p.video||'',postId:p.id||'',desc:(p.text&&p.text!==p.title?p.text:'').slice(0,400),extraBtn:``})});h+='
';el.innerHTML=h;setTimeout(()=>initTikTokFeed(),200);} -function readWallPost(idx){const p=_wallPosts&&_wallPosts[idx];if(!p)return;if(p.slides&&p.slides.length){readSlidePost(idx);return}const isEmb=p.embed_oembed||/(tiktok\.com\/embed|facebook\.com\/plugins|instagram\.com\/p\/.*\/embed|youtube\.com\/embed|platform\.twitter\.com|player\.vimeo\.com)/.test(p.video||'');if(isEmb||(p.video&&p.video.indexOf('/api/ai/short-file/')===0&&!p.url)){openShortAIFeed(_wallPosts.filter(x=>x.video).indexOf(p));return}readArticle(p.url||'','','',p.title,p.text);} -/** Show rewrite slide viewer - vertical slides with text+image */ -function readSlidePost(idx){const p=_wallPosts[idx];if(!p||!p.slides)return;showView('view-article');const el=document.getElementById('view-article');let h=`
`;p.slides.forEach((s,i)=>{h+=`
Slide ${s.index||i+1}/${p.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
`;el.innerHTML=h;} - -/** Slide Designer Modal - design a slide image with high-contrast text on background */ -function designerGetRatio(){const v=document.getElementById('designer-ratio')?.value||'3:4';const m=v.split(':');return m.length===2?{w:parseInt(m[0]),h:parseInt(m[1])}:1;} -function designerGetLayout(){return document.getElementById('designer-layout')?.value||'solid';} -function designerDebounce(fn,d){clearTimeout(fn._t);fn._t=setTimeout(fn,d);} -function openSlideDesigner(idx){ - designerSlideIdx=idx; - const p=_wallPosts[idx];if(!p||!p.slides)return; - const overlay=document.createElement('div');overlay.id='slide-designer-overlay';overlay.style.cssText='position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - let h='
'; - h+='

🎨 Thiết kế ảnh slide

'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - h+='
'; - ['#ffffff','#000000','#ff4444','#44ff44','#4444ff','#ffff00','#ff00ff','#00ffff','#ff8800','#88ff00'].forEach(c=>{h+=``;}); - h+='
'; - h+='
'; - h+='
'; - h+=''; - h+='
'; - overlay.innerHTML=h;document.body.appendChild(overlay); - document.getElementById('designer-slide-select').addEventListener('change',function(){updateDesignerText(this.value);previewDesignerSlide();}); - document.getElementById('designer-preview-btn').addEventListener('click',function(){previewDesignerSlide();}); - document.getElementById('designer-save-btn').addEventListener('click',function(){saveDesignerSlide(p.title,(p.id||''),idx);}); - ['change','input'].forEach(function(evt){ - const txt=document.getElementById('designer-text');if(txt)txt.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const bg=document.getElementById('designer-bg-url');if(bg)bg.addEventListener(evt,function(){designerDebounce(previewDesignerSlide,150);}); - const ratio=document.getElementById('designer-ratio');if(ratio)ratio.addEventListener(evt,function(){previewDesignerSlide();}); - const layout=document.getElementById('designer-layout');if(layout)layout.addEventListener(evt,function(){previewDesignerSlide();}); - }); - previewDesignerSlide(); -} -function updateDesignerText(slideIdx){const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return;const s=p.slides[parseInt(slideIdx)];if(s)document.getElementById('designer-text').value=s.text||'';document.getElementById('designer-bg-url').value=s.image||p.img||'';designerBgDataUrl=null;} -function handleDesignerBgFile(input){ - const file=input.files&&input.files[0];if(!file)return; - const reader=new FileReader(); - reader.onload=function(e){designerBgDataUrl=e.target.result;document.getElementById('designer-bg-url').value='[uploaded]';}; - reader.readAsDataURL(file); -} -function previewDesignerSlide(){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[designerSlideIdx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const previewArea=document.getElementById('designer-preview-area');if(!previewArea)return; - previewArea.style.display='block';previewArea.innerHTML='
⏳ Đang tạo xem trưởng...
'; - const canvas=document.createElement('canvas'); - const vw=Math.max(document.documentElement.clientWidth||540, document.body.clientWidth||540); - const maxW=Math.min(540, Math.floor(vw*0.9)); - canvas.width=maxW;canvas.height=Math.round(maxW*ratio.h/ratio.w); - canvas.style.maxWidth='100%';canvas.style.width='100%';canvas.style.height='auto'; - canvas.style.borderRadius='12px';canvas.style.boxShadow='0 8px 24px rgba(0,0,0,.5)'; - canvas.style.display='block';canvas.style.margin='0 auto'; - const ctx=canvas.getContext('2d');const img=new Image();img.crossOrigin='anonymous'; - function drawLayout(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(canvas.width/2,canvas.height/2,0,canvas.width/2,canvas.height/2,Math.max(canvas.width,canvas.height)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,canvas.width,canvas.height);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,canvas.width,canvas.height/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,canvas.height/2,canvas.width,canvas.height/2);} - else if(layout==='spotlight'){const cx=canvas.width/2,cy=canvas.height/2,r=Math.max(canvas.width,canvas.height)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(canvas.width,0);ctx.lineTo(0,canvas.height);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,canvas.width,canvas.height);} - } - function drawText(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(canvas.width*28/540)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(canvas.width*0.82); - const words=text.split(' ');let line='';let y=Math.round(canvas.height/2); - const fontSize=Math.round(canvas.width*28/540);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),canvas.width/2,y);y+=lineHeight;line=words[i];}else{line=test;}} - ctx.fillText(line.trim(),canvas.width/2,y); - } - function renderAll(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,canvas.width,canvas.height); - drawLayout(); - drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(canvas.width/iw,canvas.height/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(canvas.width-dw)/2,dy=(canvas.height-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayout();drawText(); - previewArea.innerHTML='';previewArea.appendChild(canvas); - }; - img.onerror=function(){renderAll();}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{renderAll();} -} -async function saveDesignerSlide(title,postId,idx){ - const slideIdx=parseInt(document.getElementById('designer-slide-select')?.value||'0'); - const p=_wallPosts[idx];if(!p||!p.slides)return; - const s=p.slides[slideIdx];if(!s)return; - const text=document.getElementById('designer-text')?.value||s.text||''; - const bgUrlInput=document.getElementById('designer-bg-url')?.value||''; - const bgUrl=designerBgDataUrl||bgUrlInput||s.image||p.img||''; - const textColor=document.getElementById('designer-text')?.style.color||'#ffffff'; - const ratio=designerGetRatio(); - const layout=designerGetLayout(); - const statusEl=document.getElementById('designer-status');if(statusEl)statusEl.textContent='⏳ Đang tạo ảnh...'; - const FINAL_W=ratio.w>=ratio.h?1800:1350; - const FINAL_H=Math.round(FINAL_W*ratio.h/ratio.w); - const canvas=document.createElement('canvas');canvas.width=FINAL_W;canvas.height=FINAL_H; - const ctx=canvas.getContext('2d'); - const img=new Image();img.crossOrigin='anonymous'; - function drawLayoutF(){ - if(layout==='vignette'){const g=ctx.createRadialGradient(FINAL_W/2,FINAL_H/2,0,FINAL_W/2,FINAL_H/2,Math.max(FINAL_W,FINAL_H)*0.6);g.addColorStop(0,'rgba(0,0,0,0)');g.addColorStop(1,'rgba(0,0,0,0.7)');ctx.fillStyle=g;ctx.fillRect(0,0,FINAL_W,FINAL_H);} - else if(layout==='split'){ctx.fillStyle='rgba(0,0,0,0)';ctx.fillRect(0,0,FINAL_W,FINAL_H/2);ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,FINAL_H/2,FINAL_W,FINAL_H/2);} - else if(layout==='spotlight'){const cx=FINAL_W/2,cy=FINAL_H/2,r=Math.max(FINAL_W,FINAL_H)*0.35;ctx.save();ctx.fillStyle='rgba(0,0,0,0.7)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.globalCompositeOperation='destination-out';ctx.fillStyle='white';ctx.beginPath();ctx.arc(cx,cy,r,0,Math.PI*2);ctx.fill();ctx.globalCompositeOperation='source-over';} - else if(layout==='diagonal'){ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);ctx.fillStyle='rgba(0,0,0,0.75)';ctx.beginPath();ctx.moveTo(0,0);ctx.lineTo(FINAL_W,0);ctx.lineTo(0,FINAL_H);ctx.closePath();ctx.fill();} - else {ctx.fillStyle='rgba(0,0,0,0.55)';ctx.fillRect(0,0,FINAL_W,FINAL_H);} - } - function drawTextF(){ - ctx.fillStyle=textColor;ctx.font='bold '+Math.round(FINAL_W*48/1080)+'px sans-serif'; - ctx.textAlign='center';ctx.textBaseline='middle'; - const maxW=Math.round(FINAL_W*0.82);const words=text.split(' ');let line='';let y=Math.round(FINAL_H/2); - const fontSize=Math.round(FINAL_W*48/1080);const lineHeight=Math.round(fontSize*1.3); - for(let i=0;imaxW&&i>0){ctx.fillText(line.trim(),FINAL_W/2,y);y+=lineHeight;line=words[i];}else{line=test;}}ctx.fillText(line.trim(),FINAL_W/2,y); - } - function render(){ - ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - } - img.onload=function(){ - const iw=img.naturalWidth||1,ih=img.naturalHeight||1; - const scale=Math.max(FINAL_W/iw,FINAL_H/ih); - const dw=iw*scale,dh=ih*scale; - const dx=(FINAL_W-dw)/2,dy=(FINAL_H-dh)/2; - ctx.drawImage(img,0,0,iw,ih,dx,dy,dw,dh); - drawLayoutF();drawTextF(); - canvas.toBlob(async function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - try{const r=await fetch('/api/wall/img',{method:'POST',body:formData});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi upload'); - s.image=j.url; - const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - const wr=await fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}); - const wj=await wr.json();if(wr.ok&&wj.post){prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');}else{toast('✅ Đã lưu ảnh slide!');} - document.getElementById('slide-designer-overlay').remove(); - }catch(e){if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);} - },'image/png'); - }; - img.onerror=function(){if(statusEl)statusEl.textContent='⚠️ Không tải được ảnh nền, dùng nền mặc định';ctx.fillStyle='#1a1a1a';ctx.fillRect(0,0,FINAL_W,FINAL_H);drawLayoutF();drawTextF();canvas.toBlob(function(blob){ - const formData=new FormData();formData.append('file',blob,'slide_'+slideIdx+'.png');formData.append('post_id',postId||p.id||''); - fetch('/api/wall/img',{method:'POST',body:formData}).then(r=>r.json()).then(j=>{ - if(j.ok){s.image=j.url;const post={id:p.id,title:title||p.title,text:p.text,img:j.url,url:p.url,slides:p.slides,images:p.images,voice:p.voice,emotion:p.emotion,language:p.language,ts:p.ts,kind:p.kind||'slide_summary'}; - fetch('/api/wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(post)}).then(r=>r.json()).then(wj=>{if(wj.post)prependWallPost(wj.post);toast('✅ Đã lưu ảnh đăng lên Tường AI!');document.getElementById('slide-designer-overlay').remove();});} - }).catch(e=>{if(statusEl)statusEl.textContent='❌ '+e.message;toast('❌ '+e.message);}); - },'image/png');}; - if(bgUrl){img.src=bgUrl.startsWith('/api/')?bgUrl:bgUrl.startsWith('data:')?bgUrl:'/api/proxy/img?url='+encodeURIComponent(bgUrl);}else{render();} -} - -function readNewsTab(tab){loadNewsTab();} -function loadNewsTab(){const el=document.getElementById('view-cat');if(!el)return;el.innerHTML='
Đang tải tin tức...
';fetch('/api/homepage').then(r=>r.json()).then(articles=>{if(!articles||!articles.length){el.innerHTML='
Không có tin
';return}let h='
';articles.forEach(a=>{const src=a.source||'vne';const badge=a.group||a.source||'';h+=`
${a.img?``:''}
${esc(badge)}
${esc(a.title)}
`;});h+='
';el.innerHTML=h;}).catch(()=>{el.innerHTML='
Lỗi tải
';});} - -function readArticle(url,title,img,presetTitle,presetText){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';if(presetTitle){el.innerHTML=`

${esc(presetTitle)}

${presetText?`
${esc(presetText)}
`:''}
`;return;}if(!url)return;fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()).then(d=>{let h=`
`;if(d.title)h+=`

${esc(d.title)}

`;if(d.summary)h+=`
${esc(d.summary)}
`;if(d.body)d.body.forEach(b=>{if(b.type==='p')h+=`

${esc(b.text)}

`;else if(b.type==='heading')h+=`

${esc(b.text)}

`;else if(b.type==='img'&&b.src)h+=``;});h+=`
`;el.innerHTML=h;}).catch(()=>{el.innerHTML=`

Không thể tải bài viết

`;});} -async function rewriteSlide(url){if(!url)return;const btn=document.querySelector('.article-actions .primary')||event?.target;if(btn){btn.disabled=true;btn.textContent='⏳ Đang tạo slides...';}toast('⏳ Đang tạo slide rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error||'Lỗi');toast('✅ Đã tạo slide! Đang tạo Short AI...');if(btn)btn.textContent='✅ Đang tạo Short AI...';if(j.post){j.post.slides=j.slides||[];prependWallPost(j.post);}// Show slides immediately if available -if(j.slides && j.slides.length){showView('view-article');const el=document.getElementById('view-article');let h=`
`;j.slides.forEach(s=>{h+=`
Slide ${s.index}/${j.slides.length}
${s.image?``:''}

${esc(s.text)}

`;});h+=`
⏳ Đang tạo video Short AI...
`;el.innerHTML=h;} -const postId=j.post&&j.post.id;if(postId){setTimeout(async()=>{const sr=await fetch('/api/ai/short/'+encodeURIComponent(postId),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({voice:'vi-VN-HoaiMyNeural',emotion:'neutral',speed:1.2})});const sj=await sr.json();if(sr.ok&&sj.video&&typeof _wallPosts!=='undefined'){const p=_wallPosts.find(x=>String(x.id)===String(postId));if(p){p.video=sj.video;const itemId='wall-item-'+postId;const el2=document.getElementById(itemId);if(el2){const idx=_wallPosts.indexOf(p);el2.insertAdjacentHTML('afterend',makeWallItem(p,idx));el2.remove();}}toast('✅ Short AI đã sẵn sàng!');const statusEl=document.getElementById('short-ai-status');if(statusEl)statusEl.innerHTML='✅ Short AI đẵn sàng! ';}},500);}if(btn)btn.textContent='✅ Hoàn tất';}catch(e){toast('❌ '+e.message);if(btn){btn.disabled=false;btn.textContent='🤖 Slide Rewrite AI';}}} - -function downloadVideo(url, title){ - var a = document.createElement('a'); - a.href = url; - a.download = (title||'video').toString().replace(/[^a-zA-Z0-9_\-\p{L}]/gu,'_').substring(0,60)+'.mp4'; - a.target = '_blank'; - a.rel = 'noopener'; - a.style.display = 'none'; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - toast('📥 Đang tải video xuống...'); -} - -// ===== PERSONAL OPINION POST: Viết bài dựa trên quan điểm cá nhân + tin HOT ===== -async function openPersonalPostPreview() { - const opinion = document.getElementById('opinion-input')?.value.trim(); - if (!opinion || opinion.length < 10) { - alert('Vui lòng nhập quan điểm cá nhân (ít nhất 10 ký tự)'); - return; - } - - // Get selected hot topics from UI (if any) - const selectedTopics = []; - if (window._htTopic) { - selectedTopics.push(window._htTopic); - } - - // Get selected sources from hashtag view - const selectedSources = []; - document.querySelectorAll('.hashtag-src-item.selected').forEach(el => { - const idx = parseInt(el.dataset.idx || '0'); - // Would need source tracking - }); - - const btn = event?.target; - const origText = btn ? btn.textContent : ''; - if (btn) { btn.disabled = true; btn.textContent = '⏳ Đang tạo preview...'; } - - try { - const resp = await fetch('/api/personal_post/preview', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: opinion, - selected_topics: selectedTopics, - selected_sources: selectedSources - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi tạo preview'); - - showPersonalPostModal(data.preview, opinion, selectedTopics, selectedSources); - } catch (e) { - toast('❌ ' + e.message); - } finally { - if (btn) { btn.disabled = false; btn.textContent = origText; } - } -} - -function showPersonalPostModal(preview, originalOpinion, selectedTopics, selectedSources) { - // Remove existing modal - const existing = document.getElementById('personal-post-modal'); - if (existing) existing.remove(); - - const modal = document.createElement('div'); - modal.id = 'personal-post-modal'; - modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:99999;display:flex;align-items:center;justify-content:center;padding:16px;overflow-y:auto'; - - // Each slide renders EXACTLY like the designer's final PNG (same ratio, - // same bg draw, same text draw) — preview = pixel-identical to wall post image. - const slidesHtml = (preview.slides || []).map((s, i) => { - const ratio = {w:3, h:4}; // preview always 3:4 (same as designer final) - const slideStyle = 'width:100%;max-width:280px;margin:0 auto;display:block;border-radius:12px;box-shadow:0 8px 24px rgba(0,0,0,.5)'; - return '
' - + '
Slide ' + (i+1) + '
' - + '' - + '' - + '
'; - }).join(''); - - // Background image source: uploaded data-URL (set later) or slide.image / first source image. - // We keep a small state object so text edits re-render live. - window._pvState = { slides: (preview.slides||[]).map(function(s,i){ - var bg = s.image || (preview.images && preview.images[0]) || ''; - return { text: s.text||'', bg: bg }; - }), ratio: 3/4, layout: 'solid', filter: 'none', glow: 'white', pos: 'bottom', color: '#ffffff' }; - - const sourcesHtml = (preview.sources || []).map((s, i) => { - const imgSrc = (preview.images && preview.images[i+1]) ? preview.images[i+1] : ''; - return '
' - + '
' - + (imgSrc ? '' : '') - + '
' - + '
' + (s.title || '') + '
' - + '' - + '
'; - }).join(''); - - modal.innerHTML = '
' - + '
' - + '

📝 Xem trước bài quan điểm cá nhân

' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '' - + '
' - + '
' - + '' - + '
' - + '' - + '' - + '
' - + '
' + slidesHtml + '
' - + '
' - + '
' - + '' - + '
' - + (sourcesHtml || '
Không có nguồn tin
') - + '
' - + '
' - + '' - + '' - + '
'; - - document.body.appendChild(modal); - - // draw every slide preview canvas - previewRedrawAllSlides(); - - // text edits re-render live - modal.querySelectorAll('.preview-slide-text').forEach(ta => { - ta.addEventListener('input', function(){ - const idx = parseInt(this.dataset.idx || '0', 10); - if(window._pvState && window._pvState.slides[idx]) window._pvState.slides[idx].text = this.value; - previewDrawSlide(idx); - }); - }); - - // Store preview data for publishing - window._personalPostPreview = preview; - window._personalPostOpinion = originalOpinion; -} - -/* Live-edit slide preview canvas (exact same rendering style as designer output) */ -function previewDrawSlide(idx){ - const canvas = document.querySelector('.preview-slide-canvas[data-idx="'+idx+'"]'); - if(!canvas || !window._pvState) return; - const st = window._pvState.slides[idx]; - if(!st) return; - const W = 600, H = Math.round(W * window._pvState.ratio); // 3:4 -> 800 - canvas.width = W; canvas.height = H; - const ctx = canvas.getContext('2d'); - const opts = { text: st.text, pos: window._pvState.pos, glow: window._pvState.glow, - color: window._pvState.color, layout: window._pvState.layout, - filter: window._pvState.filter, bgUrl: st.bg }; - function draw(img){ - // base dark - ctx.fillStyle='#141414'; ctx.fillRect(0,0,W,H); - if(img){ - ctx.save(); - let f=''; - if(opts.filter==='grayscale') f='grayscale(1)'; - else if(opts.filter==='sepia') f='sepia(0.85)'; - else if(opts.filter==='saturate') f='saturate(2.4)'; - else if(opts.filter==='warm') f='sepia(0.45) saturate(1.5) hue-rotate(-15deg)'; - else if(opts.filter==='cool') f='saturate(1.2) hue-rotate(15deg) brightness(1.05)'; - else if(opts.filter==='invert') f='invert(1)'; - else if(opts.filter==='noir') f='grayscale(1) contrast(1.6) brightness(0.9)'; - const iw=img.naturalWidth||1, ih=img.naturalHeight||1; - const scale=Math.max(W/iw, H/ih); - const dw=iw*scale, dh=ih*scale; - ctx.drawImage(img,0,0,iw,ih,(W-dw)/2,(H-dh)/2,dw,dh); - ctx.filter='none'; - ctx.restore(); - } - // solid overlay like designer - ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(0,0,W,H); - drawPreviewText(ctx,W,H,opts); - } - const bgSrc = (typeof _proxyImg==='function') ? _proxyImg(st.bg||'') : (st.bg || ''); - if(bgSrc){ - const im = new Image(); im.crossOrigin='anonymous'; - im.onload = function(){ draw(im); }; - im.onerror = function(){ draw(null); }; - im.src = bgSrc; - } else draw(null); -} -function drawPreviewText(ctx,W,H,opts){ - const text = opts.text || ''; - if(!text.trim()) return; - const fs = Math.min(52, Math.round(W*0.09)); - ctx.font = 'bold '+fs+'px "Segoe UI","Arial","Noto Sans",sans-serif'; - ctx.textAlign='center'; - const lines=[]; let line=''; - const maxW=Math.round(W*0.82); - text.split(' ').forEach(w=>{ - const test = line? line+' '+w : w; - if(ctx.measureText(test).width>maxW && line){ lines.push(line.trim()); line=w; } else { line=test; } +async function toggleComments(videoId,idx){const panel=document.getElementById('cmt-inline-'+idx);if(!panel)return;if(panel.style.display!=='none'){panel.style.display='none';return;}panel.style.display='block';panel.innerHTML='
Đang tải...
';const cmts=await getComments(videoId);renderInlineComments(panel,videoId,idx,cmts);} +function renderInlineComments(panel,videoId,idx,cmts){let h='
💬 Bình luận
';if(cmts.length){cmts.slice(-30).forEach(c=>{h+=`
${c.time||''}${esc(c.text)}
`;});}else{h+='
Chưa có bình luận
';}h+=`
`;panel.innerHTML=h;const list=panel.querySelector('.inline-cmt-list');if(list)list.scrollTop=list.scrollHeight;} +async function submitInlineCmt(videoId,idx){const inp=document.getElementById('cmt-input-'+idx);if(!inp)return;const text=inp.value.trim();if(!text)return;inp.value='';inp.disabled=true;const cmts=await postComment(videoId,text);inp.disabled=false;const panel=document.getElementById('cmt-inline-'+idx);if(panel)renderInlineComments(panel,videoId,idx,cmts);const cc=document.getElementById('cc-'+idx);if(cc)cc.textContent=fmtNum(cmts.length);} +function initTikTokFeed(){const feed=document.getElementById('tiktok-feed');if(!feed)return;const slides=feed.querySelectorAll('.tiktok-slide');let cur=-1;function act(i){if(i===cur)return;slides.forEach((sl,idx)=>{const v=sl.querySelector('video');const fr=sl.querySelector('iframe');if(idx===i){if(v&&v.dataset.hls&&!v._hls&&typeof Hls!=='undefined'&&Hls.isSupported()){const hls=new Hls();hls.loadSource(v.dataset.hls);hls.attachMedia(v);hls.on(Hls.Events.MANIFEST_PARSED,()=>v.play().catch(()=>{}));v._hls=hls}else if(v)v.play().catch(()=>{});if(fr&&!fr.src&&fr.dataset.ytSrc)fr.src=fr.dataset.ytSrc;const vid=sl.dataset.vid;if(vid&&!sl._viewed){sl._viewed=true;doInteract(vid,'view').then(j=>{if(j){const vc=document.getElementById('vc-'+idx);if(vc)vc.textContent=fmtNum(j.views);}});}}else{if(v){v.pause();if(v._hls){v._hls.destroy();v._hls=null}}if(fr&&fr.src)fr.src=''}});cur=i}let sT;feed.addEventListener('scroll',()=>{clearTimeout(sT);sT=setTimeout(()=>{const rect=feed.getBoundingClientRect(),ctr=rect.top+rect.height/2;let best=-1,bestD=1e9;slides.forEach((sl,i)=>{const d=Math.abs(sl.getBoundingClientRect().top+sl.getBoundingClientRect().height/2-ctr);if(d=0)act(best)},150)});setTimeout(()=>act(0),400);slides.forEach(sl=>{const v=sl.querySelector('video');if(v)v.addEventListener('click',e=>{e.preventDefault();v.paused?v.play().catch(()=>{}):v.pause()})});const ids=[...slides].map(sl=>sl.dataset.vid||'');loadCounters(ids)} +async function openHighlightFeed(league,idx,forceUrl){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';let articles=(_hlLeagueData||{})[league]||[];if(!articles.length){try{articles=await fetch('/api/highlights/'+league).then(r=>r.json())}catch(e){articles=[]}}if(!articles.length){el.innerHTML='
Không có video
';return}const vids=[];const results=await Promise.all(articles.map(async(a,i)=>{try{const r=await fetch('/api/video_url?url='+encodeURIComponent(a.link));const v=await r.json();if(v&&v.src)return{...a,...v,_idx:i}}catch(e){}return null}));results.forEach(r=>{if(r)vids.push(r)});vids.sort((a,b)=>a._idx-b._idx);if(!vids.length){el.innerHTML='
Không tìm thấy video
';return}let ti=vids.findIndex(v=>v._idx===idx);if(ti<0)ti=0;const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;let h=`
`;ordered.forEach((v,i)=>{const isYT=v.type==='youtube',isHLS=!isYT&&v.src?.includes('.m3u8'),poster=v.poster?` poster="${v.poster}"`:'';const vtag=isYT?``:isHLS?``:``;const videoId='hl-'+league+'-'+(v.id||v._idx);h+=buildTikTokSlide({vtag,title:v.title,badge:'HL',badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:v.link||'',extraBtn:``});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openYTShortsFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const arts=_shortsData.length?_shortsData:await fetch('/api/shorts').then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có shorts
';return}const ordered=startIdx>0?[...arts.slice(startIdx),...arts.slice(0,startIdx)]:arts;let h=`
`;ordered.forEach((v,i)=>{const id=v.id||'';const src=`https://www.youtube.com/embed/${id}?autoplay=1&rel=0&playsinline=1`;const vtag=``;const badge=v.channel==='baosuckhoedoisongboyte'?'SKĐS':'Dân trí';const videoId='yt-'+id;h+=buildTikTokSlide({vtag,title:v.title,badge,badgeClass:'badge-fpt',videoId,idx:i,total:ordered.length,shareUrl:'https://youtube.com/watch?v='+id});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function openShortAIFeed(startIdx){showView('view-tiktok');const el=document.getElementById('view-tiktok');el.innerHTML='
Đang tải...
';const wall=(await fetch('/api/wall').then(r=>r.json()).catch(()=>({posts:[]}))).posts||[];const vids=wall.filter(p=>p.video);if(!vids.length){el.innerHTML='
Chưa có Short AI
';return}const ordered=startIdx>0?[...vids.slice(startIdx),...vids.slice(0,startIdx)]:vids;let h=`
`;ordered.forEach((p,i)=>{const vtag=``;const videoId='ai-'+(p.id||i);h+=buildTikTokSlide({vtag,title:p.title,badge:'AI',badgeClass:'badge-ai',videoId,idx:i,total:ordered.length,shareUrl:SPACE});});h+='
';el.innerHTML=h;initTikTokFeed();} +async function readArticle(url){showView('view-article');const el=document.getElementById('view-article');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/article?url='+encodeURIComponent(url));const data=await r.json();if(data&&!data.error&&data.body&&data.body.length){_currentArticle={url,data};let h=`

${esc(data.title)}

`;if(data.summary)h+=`
${esc(data.summary)}
`;const seen={};data.body.forEach(b=>{if(b.type==='p')h+=`

${b.text}

`;else if(b.type==='img'&&b.src&&!seen[b.src]){seen[b.src]=1;h+=``}else if(b.type==='heading')h+=`

${esc(b.text)}

`});h+=`

🤖 Hỏi AI

`;el.innerHTML=h;window.scrollTo(0,0);return;}}catch(e){}el.innerHTML=`

Không đọc được.

Mở gốc →
`;} +async function rewriteArticle(){const url=_currentArticle?.url;if(!url)return;toast('⏳ Đang rewrite...');try{const r=await fetch('/api/rewrite_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,context:document.querySelector('.article-view')?.innerText?.slice(0,14000)||''})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng Tường AI!');if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function rewriteUrl(){const url=document.getElementById('url-input')?.value.trim();if(!url)return alert('Dán URL');toast('⏳ Đang rewrite...');try{const r=await fetch('/api/url_wall',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});const j=await r.json();if(!r.ok||j.error)throw new Error(j.error);toast('✅ Đã đăng!');document.getElementById('url-input').value='';if(j.post)prependWallPost(j.post);}catch(e){toast('❌ '+e.message)}} +async function askAI(){const q=document.getElementById('ask-q')?.value.trim();if(!q)return alert('Nhập câu hỏi');const a=document.getElementById('ask-a');a.textContent='Đang hỏi...';try{const r=await fetch('/api/article/ask',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:_currentArticle?.url||'',question:q,context:document.querySelector('.article-view')?.innerText?.slice(0,12000)||''})});const j=await r.json();a.textContent=j.answer||'Không trả lời được';}catch(e){a.textContent='Lỗi: '+e.message}} +async function readWallPost(i){const p=_wallPosts[i];if(!p)return;showView('view-article'); + const images = p.images || []; + let imgGallery = ''; + if(images.length > 0){ + imgGallery = '
'; + images.forEach((imgUrl, imgIdx) => { + if(imgIdx === 0){ + imgGallery += ``; + } else { + if(imgIdx === 1) imgGallery += ''; + imgGallery += '
'; + } + const hasVideo = p.video && p.video.length > 0; + const voiceOptions = [ + {id:'hoaimy', label:'🎙️ Nữ — Hoài My'}, + {id:'namminh', label:'🎙️ Nam — Nam Minh'}, + ]; + let voiceSelector = ''; + if(!hasVideo){ + voiceSelector = `
🎙️ Chọn giọng đọc:
`; + voiceOptions.forEach(v=>{ + voiceSelector += ``; }); - const fakePost = { - id: 'preview-fake', title: document.getElementById('preview-title')?.value || 'Bài quan điểm', - slides: slides, kind: 'personal_opinion', img: slides[0]?.image||'', url: '', video: '' - }; - if(typeof openSlideDesigner === 'function'){ - // designer expects index into _wallPosts; temporarily append so designer works - const existed = _wallPosts && _wallPosts.some(p => p.id === 'preview-fake'); - if(!existed && Array.isArray(_wallPosts)) _wallPosts.unshift(fakePost); - const idx = _wallPosts.findIndex(p => p.id === 'preview-fake'); - openSlideDesigner(idx >= 0 ? idx : 0); - // the fake post is only a designer context; the designer's own save posts - // the designed slides directly to the wall. Remove the fake entry on close. + voiceSelector += `
Tốc độ:
`; + voiceSelector += `
`; + } + document.getElementById('view-article').innerHTML=`
AI

${esc(p.title)}

${imgGallery}

${esc(p.text)}

${hasVideo?``:''}
${hasVideo?`${voiceSelector}`:`${voiceSelector}`}
`; + const firstVoiceBtn = document.querySelector('.tts-voice-btn'); + if(firstVoiceBtn) firstVoiceBtn.classList.add('active'); + window.scrollTo(0,0)} +async function loadNewsTab(){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';try{const r=await fetch('/api/homepage');const news=await r.json();if(!news.length){el.innerHTML='
Không có tin
';return}const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a)});let h='';for(const[g,arts]of Object.entries(groups)){h+=`
${g}
`;arts.slice(0,6).forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'VnE')}
${esc(a.title)}
`});h+='
'}el.innerHTML=h}catch(e){el.innerHTML='
Lỗi
'}} +async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='
Đang tải...
';const arts=await fetch('/api/category/'+id).then(r=>r.json()).catch(()=>[]);if(!arts.length){el.innerHTML='
Không có tin
';return}let h='
';arts.forEach(a=>{h+=`
${a.img?``:''}
${esc(a.source||'')}
${esc(a.title)}
`});h+='
';el.innerHTML=h} +fetch('/api/storage_status').then(r=>r.json()).then(j=>{if(!j.persistent){const home=document.getElementById('view-home');if(home){const w=document.createElement('div');w.className='storage-warn';w.innerHTML='⚠️ Persistent Storage chưa bật.';home.prepend(w)}}}).catch(()=>{}); + +(function(){ + try{ + var hash = window.location.hash; + if(hash && hash.length > 1){ + var articleUrl = decodeURIComponent(hash.substring(1)); + if(articleUrl.startsWith('http')){ + history.replaceState(null, '', window.location.pathname); setTimeout(function(){ - const ov = document.getElementById('slide-designer-overlay'); - if(!ov && Array.isArray(_wallPosts)){ - _wallPosts = _wallPosts.filter(p => p.id !== 'preview-fake'); - } - }, 30000); - } else { - toast('Không có công cụ thiết kế!'); + if(typeof readArticle==='function') readArticle(articleUrl); + }, 1500); + } } -} - -function toggleSourceSelection(el, idx) { - const checkbox = el.querySelector('.source-checkbox'); - checkbox.checked = !checkbox.checked; - el.style.border = checkbox.checked ? '1px solid #2d8659' : '1px solid transparent'; - el.style.background = checkbox.checked ? '#1a2a1f' : '#202020'; -} + }catch(e){} +})(); -async function publishPersonalPostFromModal() { - const title = document.getElementById('preview-title')?.value.trim(); - if (!title) { - alert('Vui lòng nhập tiêu đề'); - return; - } - - // Collect edited slides (text + designed/bg image from _pvState) - const slides = []; - if (window._pvState && window._pvState.slides) { - window._pvState.slides.forEach((s, i) => { - const text = (s.text || '').trim(); - if (text) slides.push({ text, image: s.bg || '', index: slides.length + 1 }); - }); - } else { - document.querySelectorAll('.preview-slide-text').forEach(ta => { - const text = ta.value.trim(); - if (text) slides.push({ text, image: '', index: slides.length + 1 }); - }); - } - - // Collect selected sources - const sources = []; - document.querySelectorAll('[data-source-idx]').forEach((el, i) => { - const checkbox = el.querySelector('.source-checkbox'); - if (checkbox.checked && window._personalPostPreview?.sources?.[i]) { - sources.push(window._personalPostPreview.sources[i]); - } - }); - - const btn = event.target; - const origText = btn.textContent; - btn.disabled = true; - btn.textContent = '⏳ Đang đăng...'; - - try { - const resp = await fetch('/api/personal_post', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - opinion: window._personalPostOpinion, - selected_topics: [], - selected_sources: sources, - custom_title: title, - custom_slides: slides - }) - }); - const data = await resp.json(); - if (!resp.ok || data.error) throw new Error(data.error || 'Lỗi đăng bài'); - - toast('✅ Đã đăng bài quan điểm lên Tường AI!'); - - // Prepend to wall - if (data.post && typeof prependWallPost === 'function') { - prependWallPost(data.post); - } - - // Close modal - document.getElementById('personal-post-modal')?.remove(); - - // Clear opinion input - document.getElementById('opinion-input').value = ''; - } catch (e) { - toast('❌ ' + e.message); - } finally { - btn.disabled = false; - btn.textContent = origText; +(function(){ + try{ + const pa=localStorage.getItem('pending_article'); + const pv=localStorage.getItem('pending_video'); + if(pa){ + localStorage.removeItem('pending_article'); + setTimeout(()=>{ + if(typeof readArticle==='function') readArticle(pa); + },1500); + } + if(pv){ + localStorage.removeItem('pending_video'); + try{ + const v=JSON.parse(pv); + if(v&&v.url) setTimeout(()=>{window.open(v.url,'_blank')},1500); + }catch(e){} } -} + }catch(e){} +})(); +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', function() { + if (typeof loadHome === 'function') loadHome(); + }); +} else { + if (typeof loadHome === 'function') loadHome(); +}