bep40 commited on
Commit
7f65e3f
·
verified ·
1 Parent(s): 5ac2e35

fix: add BDP to /api/article + replace scroll-snap with manual touch swipe (guaranteed slide change detection)"

Browse files
Files changed (1) hide show
  1. static/index.html +71 -58
static/index.html CHANGED
@@ -26,9 +26,10 @@
26
  .slider-item{flex:0 0 160px;scroll-snap-align:start;cursor:pointer;flex-shrink:0}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img{width:100%;height:100%;object-fit:cover}
27
  .slider-title{color:#ccc;font-size:10px;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
28
  .shorts-item{flex:0 0 110px!important}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}
29
- .tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000}
30
- .tiktok-feed{height:100%;overflow-y:scroll;scroll-snap-type:y mandatory;scrollbar-width:none}.tiktok-feed::-webkit-scrollbar{display:none}
31
- .tiktok-slide{height:80vh;max-height:680px;min-height:400px;scroll-snap-align:start;scroll-snap-stop:always;position:relative;background:#000;display:flex;align-items:center;justify-content:center}
 
32
  .tiktok-slide video{width:100%;height:100%;object-fit:cover}.tiktok-slide.contain-mode video{object-fit:contain}
33
  .tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3;opacity:0;pointer-events:none;transition:opacity .25s}
34
  .tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5;opacity:0;pointer-events:none;transition:opacity .25s}
@@ -67,20 +68,17 @@
67
  <div id="view-article" class="view"></div>
68
  <script>
69
  const SPACE=location.origin;
70
- let _tikData=[], _viewedSession=new Set();
71
  function hid(s){let h=0;for(let i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h|=0;}return Math.abs(h).toString(36).slice(0,10);}
72
-
73
  function srvView(aid){if(_viewedSession.has(aid))return;_viewedSession.add(aid);fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aid})}).catch(()=>{});}
74
  function srvStats(aid){return fetch('/api/stats/'+aid).then(r=>r.json()).catch(()=>({views:0,shares:0,comments:[]}));}
75
  function srvComment(aid,name,text){return fetch('/api/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aid,name,text})}).then(r=>r.json()).catch(()=>({comments:[]}));}
76
-
77
  async function doShare(title,articleUrl,img){
78
- const res=await fetch('/api/register_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:articleUrl,title:title,img:img||''})}).then(r=>r.json()).catch(()=>null);
79
  const shareUrl=res?res.social_url:(SPACE+'/#/share/'+hid(articleUrl));
80
  if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});
81
  else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép link!')).catch(()=>{});
82
  }
83
-
84
  async function init(){
85
  const cats=await fetch('/api/categories').then(r=>r.json());
86
  let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="video">🎬 Video</div>';
@@ -89,7 +87,7 @@ async function init(){
89
  document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat);});
90
  await loadHome();
91
  const pending=localStorage.getItem('pending_article');
92
- if(pending){localStorage.removeItem('pending_article');readArticle(pending,'vne');}
93
  }
94
  function switchCat(id){document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.querySelectorAll('video').forEach(v=>v.pause());if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='video'){document.getElementById('view-video').classList.add('active');loadVideos();}else{document.getElementById('view-cat').classList.add('active');loadCat(id);}}
95
  function showView(id){document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.getElementById(id).classList.add('active');}
@@ -100,22 +98,17 @@ async function loadHome(){
100
  if(sh&&sh.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts</span></div><div class="slider-track">';sh.forEach(a=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',\`${a.link}\`)"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`;});h+='</div></div>';}
101
  if(hl&&hl.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Highlight</span></div><div class="slider-track">';hl.forEach(a=>{h+=`<div class="slider-item" onclick="openTikTok('highlights',\`${a.link}\`)"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`;});h+='</div></div>';}
102
  const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a);});
103
- for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='bdp'?'badge-bdp':'badge-vne';const lb=a.source==='bbc'?'BBC':a.source==='bdp'?'BDP':'VnE';h+=`<div class="card" onclick="readArticle(\`${a.link}\`,\`${a.source}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${lb}</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';}
104
  document.getElementById('view-home').innerHTML=h;
105
  }
106
- async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json());if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return;}let h='<div class="grid">';arts.forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='bdp'?'badge-bdp':'badge-vne';h+=`<div class="card" onclick="readArticle(\`${a.link}\`,\`${a.source}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${a.source}</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';el.innerHTML=h;}
107
 
108
- // FIX: readArticle handles ALL sources in VNEWS reader (fetches article from /api/article)
109
- // Only opens external link if API returns error (unsupported source)
110
- async function readArticle(url,source){
111
  showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';
112
  const aid=hid(url);srvView(aid);
113
  const[data,stats]=await Promise.all([fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()),srvStats(aid)]);
114
- if(!data||data.error){
115
- // API doesn't support this source → show link to open externally
116
- el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p style="text-align:center;padding:40px"><a href="${url}" target="_blank" style="color:#5cb87a;font-size:14px">📰 Mở bài viết gốc</a></p></div>`;
117
- return;
118
- }
119
  const ogImg=data.og_image||'';
120
  let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${data.title}</h1>`;
121
  if(data.summary)h+=`<div class="article-summary">${data.summary}</div>`;
@@ -131,6 +124,7 @@ async function submitCmt(aid){const n=document.getElementById('cn-'+aid),t=docum
131
 
132
  async function loadVideos(){const el=document.getElementById('view-video');if(el.dataset.loaded)return;el.innerHTML='<div class="loading">Đang tải...</div>';const[hl,bdp]=await Promise.all([fetch('/api/highlights').then(r=>r.json()),fetch('/api/bdp_videos').then(r=>r.json())]);let h='<div class="section-title">🎬 Highlight</div><div class="grid">';hl.forEach(a=>{h+=`<div class="card" onclick="openTikTok('highlights',\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-24h">24h</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';if(bdp.length){h+='<div class="section-title">⚽ BDP</div><div class="grid">';bdp.forEach(a=>{h+=`<div class="card" onclick="openTikTok('bdp',\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-bdp">BDP</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';}el.innerHTML=h;el.dataset.loaded='1';}
133
 
 
134
  async function openTikTok(type,targetLink){
135
  showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));
136
  const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải video...</div>';
@@ -144,8 +138,8 @@ async function openTikTok(type,targetLink){
144
  if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
145
  const ti=vids.findIndex(v=>v.link===targetLink);
146
  const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;
147
- _tikData=ordered;
148
- let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="tiktok-container"><div class="tiktok-feed" id="tiktok-feed">`;
149
  ordered.forEach((v,i)=>{
150
  const aid=hid(v.link||v.title);const isHLS=v.src.includes('.m3u8');const poster=v.poster?` poster="${v.poster}"`:'';
151
  const vtag=isHLS?`<video playsinline preload="metadata"${poster} data-hls="${v.src}" muted loop></video>`:`<video playsinline preload="metadata"${poster} muted loop><source src="${v.src}" type="video/mp4"></video>`;
@@ -166,59 +160,78 @@ ${vtag}
166
  });
167
  h+='</div></div>';
168
  h+=`<div class="topbar"><span>📋 ${ordered.length} video</span></div><div class="grid">`;
169
- ordered.forEach((v,i)=>{const bg=v.source==='bdp'?'badge-bdp':'badge-24h';h+=`<div class="card" onclick="scrollToSlide(${i})"><div class="card-img">${(v.img||v.poster)?`<img src="${v.img||v.poster}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge ${bg}">${v.source==='bdp'?'BDP':'24h'}</span><div class="card-title">${v.title}</div></div></div>`;});
170
  h+='</div>';
171
  el.innerHTML=h;
172
  initFeed();
173
  ordered.forEach((v,i)=>{const aid=hid(v.link||v.title);srvStats(aid).then(s=>{const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=s.views;const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=s.comments.length;const sc=document.getElementById('sc-'+i);if(sc)sc.textContent=s.shares;renderVCmts(i,s.comments);});});
174
  }
175
 
176
- function scrollToSlide(i){
177
- const feed=document.getElementById('tiktok-feed');const slide=document.getElementById('ts-'+i);
178
- if(feed&&slide){feed.scrollTop=slide.offsetTop-feed.offsetTop;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
179
  }
180
 
181
  function shareVid(i){const v=_tikData[i];if(!v)return;doShare(v.title,v.link||'',v.poster||v.img||'');}
182
  function toggleCmt(i){const p=document.getElementById('cp-'+i);if(p)p.classList.toggle('open');}
183
- function closeAllCmtPanels(){document.querySelectorAll('.cmt-panel.open').forEach(p=>p.classList.remove('open'));}
184
  function renderVCmts(i,cmts){const el=document.getElementById('vcl-'+i);if(!el)return;let h='';(cmts||[]).slice().reverse().forEach(c=>{h+=`<div class="cmt-item"><b>${c.n}</b> <small>${c.d}</small><p>${c.t}</p></div>`;});if(!cmts||!cmts.length)h='<p style="color:#666;font-size:11px">Chưa có bình luận</p>';el.innerHTML=h;}
185
  async function submitVCmt(i,aid){const n=document.getElementById('vcn-'+i),t=document.getElementById('vct-'+i);if(!n.value.trim()||!t.value.trim())return;const res=await srvComment(aid,n.value.trim(),t.value.trim());t.value='';renderVCmts(i,res.comments||[]);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=(res.comments||[]).length;}
186
 
187
- // ★ FIX: Use IntersectionObserver to detect which slide is visible
188
- // This works reliably with scroll-snap on all browsers including mobile
189
  function initFeed(){
190
- const feed=document.getElementById('tiktok-feed');if(!feed)return;
 
 
191
  const slides=feed.querySelectorAll('.tiktok-slide');
192
  // Init HLS
193
  slides.forEach(sl=>{const v=sl.querySelector('video[data-hls]');if(v&&!v._h){v._h=1;const s=v.dataset.hls;if(v.canPlayType('application/vnd.apple.mpegURL'))v.src=s;else if(Hls.isSupported()){const h=new Hls();h.loadSource(s);h.attachMedia(v);}else v.src=s;}});
194
- let cur=-1;
195
- function activate(idx){
196
- if(idx===cur)return;
197
- // ★ CLOSE ALL COMMENT PANELS
198
- closeAllCmtPanels();
199
- slides.forEach((sl,i)=>{
200
- const v=sl.querySelector('video');
201
- if(i===idx){v.currentTime=0;v.play().catch(()=>setTimeout(()=>v.play().catch(()=>{}),300));srvView(sl.dataset.aid||'');}
202
- else{v.pause();sl.classList.remove('show-ui');}
203
- });
204
- cur=idx;
205
- }
206
-
207
- // ★ FIX: IntersectionObserver — fires when a slide becomes >50% visible in the feed
208
- const observer=new IntersectionObserver((entries)=>{
209
- entries.forEach(entry=>{
210
- if(entry.isIntersecting){
211
- const idx=parseInt(entry.target.dataset.idx);
212
- if(!isNaN(idx))activate(idx);
213
- }
214
- });
215
- },{root:feed, threshold:0.5});
216
- slides.forEach(sl=>observer.observe(sl));
217
-
218
- // Also close comments on any scroll start (belt and suspenders)
219
- feed.addEventListener('scroll',()=>{closeAllCmtPanels();},{passive:true});
220
-
221
- setTimeout(()=>activate(0),400);
 
 
 
222
  // TAP: show/hide UI
223
  slides.forEach(sl=>{
224
  const v=sl.querySelector('video');let hideT;
@@ -234,7 +247,7 @@ function initFeed(){
234
  });
235
  });
236
  }
237
- function toggleMute(){const f=document.getElementById('tiktok-feed');if(!f)return;const vs=f.querySelectorAll('video');const m=!vs[0]?.muted;vs.forEach(v=>v.muted=m);f.querySelectorAll('.tiktok-unmute').forEach(b=>b.textContent=m?'🔇':'🔊');}
238
  function toggleContain(btn){const sl=btn.closest('.tiktok-slide');sl.classList.toggle('contain-mode');btn.textContent=sl.classList.contains('contain-mode')?'⤢':'⬇';}
239
  function seekV(i,s){const sl=document.getElementById('ts-'+i);if(sl){const v=sl.querySelector('video');if(v)v.currentTime=Math.max(0,Math.min(v.duration||9999,v.currentTime+s));}}
240
 
 
26
  .slider-item{flex:0 0 160px;scroll-snap-align:start;cursor:pointer;flex-shrink:0}.slider-thumb{position:relative;width:100%;aspect-ratio:16/9;border-radius:6px;overflow:hidden;background:#333}.slider-thumb img{width:100%;height:100%;object-fit:cover}
27
  .slider-title{color:#ccc;font-size:10px;margin-top:3px;line-height:1.2;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
28
  .shorts-item{flex:0 0 110px!important}.shorts-thumb{aspect-ratio:3/4!important;border-radius:8px!important}
29
+ /* TikTok feed: NO scroll-snap. Manual swipe control. */
30
+ .tiktok-container{width:100%;height:80vh;max-height:680px;min-height:400px;background:#000;overflow:hidden;position:relative}
31
+ .tiktok-feed{position:absolute;top:0;left:0;right:0;transition:transform .3s ease}
32
+ .tiktok-slide{width:100%;height:80vh;max-height:680px;min-height:400px;position:relative;background:#000;display:flex;align-items:center;justify-content:center}
33
  .tiktok-slide video{width:100%;height:100%;object-fit:cover}.tiktok-slide.contain-mode video{object-fit:contain}
34
  .tiktok-bottom{position:absolute;bottom:0;left:0;right:60px;padding:12px 10px 16px;background:linear-gradient(transparent,rgba(0,0,0,.85));z-index:3;opacity:0;pointer-events:none;transition:opacity .25s}
35
  .tiktok-right{position:absolute;right:8px;bottom:100px;display:flex;flex-direction:column;align-items:center;gap:14px;z-index:5;opacity:0;pointer-events:none;transition:opacity .25s}
 
68
  <div id="view-article" class="view"></div>
69
  <script>
70
  const SPACE=location.origin;
71
+ let _tikData=[],_viewedSession=new Set(),_curSlide=0,_totalSlides=0;
72
  function hid(s){let h=0;for(let i=0;i<s.length;i++){h=((h<<5)-h)+s.charCodeAt(i);h|=0;}return Math.abs(h).toString(36).slice(0,10);}
 
73
  function srvView(aid){if(_viewedSession.has(aid))return;_viewedSession.add(aid);fetch('/api/view',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aid})}).catch(()=>{});}
74
  function srvStats(aid){return fetch('/api/stats/'+aid).then(r=>r.json()).catch(()=>({views:0,shares:0,comments:[]}));}
75
  function srvComment(aid,name,text){return fetch('/api/comment',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({aid,name,text})}).then(r=>r.json()).catch(()=>({comments:[]}));}
 
76
  async function doShare(title,articleUrl,img){
77
+ const res=await fetch('/api/register_share',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url:articleUrl,title,img:img||''})}).then(r=>r.json()).catch(()=>null);
78
  const shareUrl=res?res.social_url:(SPACE+'/#/share/'+hid(articleUrl));
79
  if(navigator.share)navigator.share({title,url:shareUrl}).catch(()=>{});
80
  else navigator.clipboard.writeText(shareUrl).then(()=>alert('Đã sao chép link!')).catch(()=>{});
81
  }
 
82
  async function init(){
83
  const cats=await fetch('/api/categories').then(r=>r.json());
84
  let bar='<div class="cat active" data-cat="home">🏠</div><div class="cat" data-cat="video">🎬 Video</div>';
 
87
  document.querySelectorAll('.cat').forEach(t=>{t.onclick=()=>switchCat(t.dataset.cat);});
88
  await loadHome();
89
  const pending=localStorage.getItem('pending_article');
90
+ if(pending){localStorage.removeItem('pending_article');readArticle(pending);}
91
  }
92
  function switchCat(id){document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));document.querySelector(`[data-cat="${id}"]`)?.classList.add('active');document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.querySelectorAll('video').forEach(v=>v.pause());if(id==='home')document.getElementById('view-home').classList.add('active');else if(id==='video'){document.getElementById('view-video').classList.add('active');loadVideos();}else{document.getElementById('view-cat').classList.add('active');loadCat(id);}}
93
  function showView(id){document.querySelectorAll('.view').forEach(x=>x.classList.remove('active'));document.getElementById(id).classList.add('active');}
 
98
  if(sh&&sh.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">📱 Shorts</span></div><div class="slider-track">';sh.forEach(a=>{h+=`<div class="slider-item shorts-item" onclick="openTikTok('shorts',\`${a.link}\`)"><div class="slider-thumb shorts-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`;});h+='</div></div>';}
99
  if(hl&&hl.length){h+='<div class="slider-wrap"><div class="slider-header"><span class="slider-label">🎬 Highlight</span></div><div class="slider-track">';hl.forEach(a=>{h+=`<div class="slider-item" onclick="openTikTok('highlights',\`${a.link}\`)"><div class="slider-thumb">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="slider-title">${a.title}</div></div>`;});h+='</div></div>';}
100
  const groups={};news.forEach(a=>{if(!groups[a.group])groups[a.group]=[];groups[a.group].push(a);});
101
+ for(const[g,arts] of Object.entries(groups)){h+=`<div class="section-title">${g}</div><div class="grid">`;arts.slice(0,6).forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='bdp'?'badge-bdp':'badge-vne';const lb=a.source==='bbc'?'BBC':a.source==='bdp'?'BDP':'VnE';h+=`<div class="card" onclick="readArticle(\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${lb}</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';}
102
  document.getElementById('view-home').innerHTML=h;
103
  }
104
+ async function loadCat(id){const el=document.getElementById('view-cat');el.innerHTML='<div class="loading">Đang tải...</div>';const arts=await fetch('/api/category/'+id).then(r=>r.json());if(!arts.length){el.innerHTML='<div class="loading">Không có tin</div>';return;}let h='<div class="grid">';arts.forEach(a=>{const bg=a.source==='bbc'?'badge-bbc':a.source==='bdp'?'badge-bdp':'badge-vne';h+=`<div class="card" onclick="readArticle(\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}</div><div class="card-body"><span class="badge ${bg}">${a.source}</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';el.innerHTML=h;}
105
 
106
+ // Article reader: always tries to render in VNEWS. Never redirects externally.
107
+ async function readArticle(url){
 
108
  showView('view-article');const el=document.getElementById('view-article');el.innerHTML='<div class="loading">Đang tải...</div>';
109
  const aid=hid(url);srvView(aid);
110
  const[data,stats]=await Promise.all([fetch('/api/article?url='+encodeURIComponent(url)).then(r=>r.json()),srvStats(aid)]);
111
+ if(!data||data.error){el.innerHTML=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><p style="text-align:center;padding:40px"><a href="${url}" target="_blank" style="color:#5cb87a;font-size:14px">📰 Mở bài viết</a></p></div>`;return;}
 
 
 
 
112
  const ogImg=data.og_image||'';
113
  let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="article-view"><h1 class="article-title">${data.title}</h1>`;
114
  if(data.summary)h+=`<div class="article-summary">${data.summary}</div>`;
 
124
 
125
  async function loadVideos(){const el=document.getElementById('view-video');if(el.dataset.loaded)return;el.innerHTML='<div class="loading">Đang tải...</div>';const[hl,bdp]=await Promise.all([fetch('/api/highlights').then(r=>r.json()),fetch('/api/bdp_videos').then(r=>r.json())]);let h='<div class="section-title">🎬 Highlight</div><div class="grid">';hl.forEach(a=>{h+=`<div class="card" onclick="openTikTok('highlights',\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-24h">24h</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';if(bdp.length){h+='<div class="section-title">⚽ BDP</div><div class="grid">';bdp.forEach(a=>{h+=`<div class="card" onclick="openTikTok('bdp',\`${a.link}\`)"><div class="card-img">${a.img?`<img src="${a.img}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge badge-bdp">BDP</span><div class="card-title">${a.title}</div></div></div>`;});h+='</div>';}el.innerHTML=h;el.dataset.loaded='1';}
126
 
127
+ // ══════ TIKTOK FEED: Manual swipe (no scroll-snap) ══════
128
  async function openTikTok(type,targetLink){
129
  showView('view-tiktok');document.querySelectorAll('.cat').forEach(x=>x.classList.remove('active'));
130
  const el=document.getElementById('view-tiktok');el.innerHTML='<div class="loading">Đang tải video...</div>';
 
138
  if(!vids.length){el.innerHTML='<div class="loading">Không tìm thấy video</div>';return;}
139
  const ti=vids.findIndex(v=>v.link===targetLink);
140
  const ordered=ti>0?[...vids.slice(ti),...vids.slice(0,ti)]:vids;
141
+ _tikData=ordered;_curSlide=0;_totalSlides=ordered.length;
142
+ let h=`<button class="back-btn" onclick="switchCat('home')">← Quay lại</button><div class="tiktok-container" id="tiktok-ctn"><div class="tiktok-feed" id="tiktok-feed">`;
143
  ordered.forEach((v,i)=>{
144
  const aid=hid(v.link||v.title);const isHLS=v.src.includes('.m3u8');const poster=v.poster?` poster="${v.poster}"`:'';
145
  const vtag=isHLS?`<video playsinline preload="metadata"${poster} data-hls="${v.src}" muted loop></video>`:`<video playsinline preload="metadata"${poster} muted loop><source src="${v.src}" type="video/mp4"></video>`;
 
160
  });
161
  h+='</div></div>';
162
  h+=`<div class="topbar"><span>📋 ${ordered.length} video</span></div><div class="grid">`;
163
+ ordered.forEach((v,i)=>{const bg=v.source==='bdp'?'badge-bdp':'badge-24h';h+=`<div class="card" onclick="goToSlide(${i})"><div class="card-img">${(v.img||v.poster)?`<img src="${v.img||v.poster}">`:''}<div class="card-play">▶</div></div><div class="card-body"><span class="badge ${bg}">${v.source==='bdp'?'BDP':'24h'}</span><div class="card-title">${v.title}</div></div></div>`;});
164
  h+='</div>';
165
  el.innerHTML=h;
166
  initFeed();
167
  ordered.forEach((v,i)=>{const aid=hid(v.link||v.title);srvStats(aid).then(s=>{const vc=document.getElementById('vc-'+i);if(vc)vc.textContent=s.views;const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=s.comments.length;const sc=document.getElementById('sc-'+i);if(sc)sc.textContent=s.shares;renderVCmts(i,s.comments);});});
168
  }
169
 
170
+ // Go to specific slide (from grid card click)
171
+ function goToSlide(i){
172
+ if(i<0||i>=_totalSlides)return;
173
+ _curSlide=i;
174
+ const feed=document.getElementById('tiktok-feed');
175
+ feed.style.transition='transform .3s ease';
176
+ feed.style.transform=`translateY(-${i*100}%)`;
177
+ activateSlide(i);
178
+ }
179
+
180
+ function activateSlide(idx){
181
+ // ★ CLOSE ALL COMMENT PANELS - guaranteed to run on every slide change
182
+ document.querySelectorAll('.cmt-panel.open').forEach(p=>p.classList.remove('open'));
183
+ const slides=document.querySelectorAll('.tiktok-slide');
184
+ slides.forEach((sl,i)=>{
185
+ const v=sl.querySelector('video');
186
+ if(i===idx){v.currentTime=0;v.play().catch(()=>setTimeout(()=>v.play().catch(()=>{}),200));srvView(sl.dataset.aid||'');}
187
+ else{v.pause();sl.classList.remove('show-ui');}
188
+ });
189
  }
190
 
191
  function shareVid(i){const v=_tikData[i];if(!v)return;doShare(v.title,v.link||'',v.poster||v.img||'');}
192
  function toggleCmt(i){const p=document.getElementById('cp-'+i);if(p)p.classList.toggle('open');}
 
193
  function renderVCmts(i,cmts){const el=document.getElementById('vcl-'+i);if(!el)return;let h='';(cmts||[]).slice().reverse().forEach(c=>{h+=`<div class="cmt-item"><b>${c.n}</b> <small>${c.d}</small><p>${c.t}</p></div>`;});if(!cmts||!cmts.length)h='<p style="color:#666;font-size:11px">Chưa có bình luận</p>';el.innerHTML=h;}
194
  async function submitVCmt(i,aid){const n=document.getElementById('vcn-'+i),t=document.getElementById('vct-'+i);if(!n.value.trim()||!t.value.trim())return;const res=await srvComment(aid,n.value.trim(),t.value.trim());t.value='';renderVCmts(i,res.comments||[]);const cc=document.getElementById('cc-'+i);if(cc)cc.textContent=(res.comments||[]).length;}
195
 
196
+ // ★ MANUAL SWIPE: touch events on container, translateY on feed
 
197
  function initFeed(){
198
+ const ctn=document.getElementById('tiktok-ctn');
199
+ const feed=document.getElementById('tiktok-feed');
200
+ if(!ctn||!feed)return;
201
  const slides=feed.querySelectorAll('.tiktok-slide');
202
  // Init HLS
203
  slides.forEach(sl=>{const v=sl.querySelector('video[data-hls]');if(v&&!v._h){v._h=1;const s=v.dataset.hls;if(v.canPlayType('application/vnd.apple.mpegURL'))v.src=s;else if(Hls.isSupported()){const h=new Hls();h.loadSource(s);h.attachMedia(v);}else v.src=s;}});
204
+ // Play first
205
+ setTimeout(()=>activateSlide(0),400);
206
+ // Touch swipe
207
+ let startY=0,deltaY=0,swiping=false;
208
+ ctn.addEventListener('touchstart',e=>{
209
+ // Don't swipe if touching comment panel
210
+ if(e.target.closest('.cmt-panel'))return;
211
+ startY=e.touches[0].clientY;deltaY=0;swiping=true;
212
+ feed.style.transition='none';
213
+ },{passive:true});
214
+ ctn.addEventListener('touchmove',e=>{
215
+ if(!swiping)return;
216
+ deltaY=e.touches[0].clientY-startY;
217
+ const offset=-_curSlide*100;
218
+ const pct=offset+(deltaY/ctn.offsetHeight)*100;
219
+ feed.style.transform=`translateY(${pct}%)`;
220
+ },{passive:true});
221
+ ctn.addEventListener('touchend',()=>{
222
+ if(!swiping)return;swiping=false;
223
+ feed.style.transition='transform .3s ease';
224
+ const threshold=ctn.offsetHeight*0.2;
225
+ if(deltaY<-threshold&&_curSlide<_totalSlides-1){_curSlide++;activateSlide(_curSlide);}
226
+ else if(deltaY>threshold&&_curSlide>0){_curSlide--;activateSlide(_curSlide);}
227
+ feed.style.transform=`translateY(-${_curSlide*100}%)`;
228
+ });
229
+ // Mouse wheel for desktop
230
+ ctn.addEventListener('wheel',e=>{
231
+ e.preventDefault();
232
+ if(e.deltaY>30&&_curSlide<_totalSlides-1){_curSlide++;goToSlide(_curSlide);}
233
+ else if(e.deltaY<-30&&_curSlide>0){_curSlide--;goToSlide(_curSlide);}
234
+ },{passive:false});
235
  // TAP: show/hide UI
236
  slides.forEach(sl=>{
237
  const v=sl.querySelector('video');let hideT;
 
247
  });
248
  });
249
  }
250
+ function toggleMute(){document.querySelectorAll('.tiktok-slide video').forEach(v=>v.muted=!v.muted);document.querySelectorAll('.tiktok-unmute').forEach(b=>b.textContent=document.querySelector('.tiktok-slide video')?.muted?'🔇':'🔊');}
251
  function toggleContain(btn){const sl=btn.closest('.tiktok-slide');sl.classList.toggle('contain-mode');btn.textContent=sl.classList.contains('contain-mode')?'⤢':'⬇';}
252
  function seekV(i,s){const sl=document.getElementById('ts-'+i);if(sl){const v=sl.querySelector('video');if(v)v.currentTime=Math.max(0,Math.min(v.duration||9999,v.currentTime+s));}}
253