car mode
This commit is contained in:
23
news-app/.claude/settings.local.json
Normal file
23
news-app/.claude/settings.local.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install)",
|
||||||
|
"Bash(npm rebuild:*)",
|
||||||
|
"Bash(npm run build:*)",
|
||||||
|
"Bash(nginx -t:*)",
|
||||||
|
"Bash(systemctl daemon-reload:*)",
|
||||||
|
"Bash(systemctl enable:*)",
|
||||||
|
"Bash(systemctl status:*)",
|
||||||
|
"Bash(systemctl reload:*)",
|
||||||
|
"Bash(curl:*)",
|
||||||
|
"Bash(journalctl:*)",
|
||||||
|
"Bash(grep:*)",
|
||||||
|
"Bash(systemctl restart:*)",
|
||||||
|
"Bash(pm2 restart:*)",
|
||||||
|
"Bash(systemctl list-units:*)",
|
||||||
|
"Bash(sudo systemctl restart:*)",
|
||||||
|
"Bash(sudo nginx:*)",
|
||||||
|
"Bash(sudo systemctl reload:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
190
news-app/build/assets/index-BurtuQum.js
Normal file
190
news-app/build/assets/index-BurtuQum.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))n(r);new MutationObserver(r=>{for(const s of r)if(s.type==="childList")for(const l of s.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&n(l)}).observe(document,{childList:!0,subtree:!0});function i(r){const s={};return r.integrity&&(s.integrity=r.integrity),r.referrerPolicy&&(s.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?s.credentials="include":r.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function n(r){if(r.ep)return;r.ep=!0;const s=i(r);fetch(r.href,s)}})();const d="/carnews/api",w=document.querySelector("#app"),e={articles:[],groups:[],loading:!0,error:null,filter:"all",viewMode:"regular",carMode:!1,carModeIndex:0,carModeInterval:null,aiArticleCount:0};function C(t){const o=new Date(t),n=new Date-o,r=Math.floor(n/(1e3*60*60)),s=Math.floor(n/(1e3*60));return s<60?s+"m ago":r<24?r+"h ago":o.toLocaleDateString("en-US",{month:"short",day:"numeric"})}function y(t,o=150){return!t||t.length<=o?t||"":t.slice(0,o).trim()+"..."}const h={abc:{name:"ABC News",color:"bg-blue-100 text-blue-800"},npr:{name:"NPR",color:"bg-indigo-100 text-indigo-800"},cnn:{name:"CNN",color:"bg-orange-100 text-orange-800"},nbc:{name:"NBC News",color:"bg-purple-100 text-purple-800"},cbs:{name:"CBS News",color:"bg-emerald-100 text-emerald-800"},nytimes:{name:"NY Times",color:"bg-slate-100 text-slate-800"}};function g(t){return h[t]?.color||"bg-gray-100 text-gray-800"}function u(t){return h[t]?.name||t}const v={politics:"bg-red-100 text-red-800",business:"bg-green-100 text-green-800",technology:"bg-blue-100 text-blue-800",sports:"bg-orange-100 text-orange-800",entertainment:"bg-pink-100 text-pink-800",health:"bg-teal-100 text-teal-800",science:"bg-purple-100 text-purple-800",world:"bg-indigo-100 text-indigo-800",other:"bg-gray-100 text-gray-800"};function M(t){return v[t]||v.other}function k(t){const o=t.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${t.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`:"",i=t.sources.slice(0,3).map(r=>`<span class="px-2 py-0.5 text-xs font-medium rounded-full ${g(r)}">${u(r)}</span>`).join(""),n=t.sources.length>3?`<span class="text-xs text-gray-500">+${t.sources.length-3}</span>`:"";return`<article class="group bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden border border-gray-100 border-l-4 border-l-blue-500">
|
||||||
|
${o}
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<span class="px-2.5 py-1 text-xs font-medium rounded-full ${M(t.category)}">
|
||||||
|
${t.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${t.articleCount} articles</span>
|
||||||
|
<span class="ml-auto px-2 py-0.5 text-xs font-medium rounded-full bg-blue-600 text-white">AI Summary</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10] mb-4">
|
||||||
|
${t.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
${i}${n}
|
||||||
|
</div>
|
||||||
|
<details class="text-sm">
|
||||||
|
<summary class="cursor-pointer text-blue-600 hover:text-blue-800 font-medium">View source articles</summary>
|
||||||
|
<ul class="mt-2 space-y-1 pl-4">
|
||||||
|
${t.articles.map(r=>`<li><a href="${r.link}" target="_blank" rel="noopener noreferrer" class="text-gray-600 hover:text-blue-600 hover:underline">${y(r.title,60)} <span class="text-gray-400">(${u(r.source)})</span></a></li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>`}function $(t){const o=t.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${t.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`:"";return`<article class="group bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden border border-gray-100">
|
||||||
|
<a href="${t.link}" target="_blank" rel="noopener noreferrer" class="block">
|
||||||
|
${o}
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="px-2.5 py-1 text-xs font-medium rounded-full ${g(t.source)}">
|
||||||
|
${u(t.source)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${C(t.pubDate)}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2 mb-2">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10]">
|
||||||
|
${y(t.content,500)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>`}function A(){let t="Loading latest news...";return(e.viewMode==="ai"||e.carMode)&&(e.aiArticleCount>0?t=`Sending ${e.aiArticleCount} articles to AI for analysis. Please wait patiently...`:t="Fetching news from sources..."),`<div class="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
|
<div class="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<p class="text-gray-600">${t}</p>
|
||||||
|
</div>`}function j(t){return`<div class="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg></div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Failed to load news</h3>
|
||||||
|
<p class="text-gray-600 mb-4">${t}</p>
|
||||||
|
<button onclick="fetchNews()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Try Again</button>
|
||||||
|
</div>`}function b(){return`<div class="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
|
||||||
|
</svg></div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">No articles found</h3>
|
||||||
|
<p class="text-gray-600">Try selecting a different source filter.</p>
|
||||||
|
</div>`}function N(t,o,i){const n=t.sources.slice(0,4).map(s=>`<span class="px-3 py-1 text-sm font-medium rounded-full ${g(s)} bg-opacity-90">${u(s)}</span>`).join(""),r=t.sources.length>4?`<span class="text-sm text-gray-300">+${t.sources.length-4} more</span>`:"";return`
|
||||||
|
<div class="car-mode-card max-w-4xl w-full bg-white/95 backdrop-blur-sm rounded-3xl shadow-2xl overflow-hidden">
|
||||||
|
${t.image?`
|
||||||
|
<div class="h-64 sm:h-80 overflow-hidden">
|
||||||
|
<img src="${t.image}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.style.display='none'" />
|
||||||
|
</div>
|
||||||
|
`:""}
|
||||||
|
<div class="p-8 sm:p-10">
|
||||||
|
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
<span class="px-4 py-1.5 text-sm font-semibold rounded-full ${M(t.category)}">
|
||||||
|
${t.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">${t.articleCount} articles</span>
|
||||||
|
<span class="ml-auto px-3 py-1 text-sm font-medium rounded-full bg-blue-600 text-white">AI Summary</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4 leading-tight">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 leading-relaxed mb-6 line-clamp-6">
|
||||||
|
${t.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
${n}${r}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="h-1 bg-gray-200">
|
||||||
|
<div class="car-mode-progress h-full bg-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Card counter -->
|
||||||
|
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||||
|
${Array.from({length:i},(s,l)=>`
|
||||||
|
<div class="w-2 h-2 rounded-full transition-all duration-300 ${l===o?"bg-white w-6":"bg-white/40"}"></div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`}function I(){if(e.groups.length===0)return`
|
||||||
|
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center">
|
||||||
|
<div class="car-mode-blur-1"></div>
|
||||||
|
<div class="car-mode-blur-2"></div>
|
||||||
|
<div class="car-mode-blur-3"></div>
|
||||||
|
<div class="relative z-10 text-center text-white">
|
||||||
|
<div class="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-6"></div>
|
||||||
|
<p class="text-xl">${e.aiArticleCount>0?`Sending ${e.aiArticleCount} articles to AI for analysis.<br>Please wait patiently...`:"Fetching news from sources..."}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;const t=e.groups[e.carModeIndex];return`
|
||||||
|
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center p-6">
|
||||||
|
<div class="car-mode-blur-1"></div>
|
||||||
|
<div class="car-mode-blur-2"></div>
|
||||||
|
<div class="car-mode-blur-3"></div>
|
||||||
|
|
||||||
|
<!-- Exit button -->
|
||||||
|
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Navigation buttons -->
|
||||||
|
<button onclick="carModePrev()" class="absolute left-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Previous">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="carModeNext()" class="absolute right-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Next">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="relative z-10 flex items-center justify-center w-full">
|
||||||
|
${N(t,e.carModeIndex,e.groups.length)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Car mode indicator -->
|
||||||
|
<div class="absolute top-6 left-6 z-20 flex items-center gap-2 text-white/70">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Car Mode</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}function a(){if(e.carMode){w.innerHTML=I();return}const t=e.filter==="all"?e.articles:e.articles.filter(c=>c.source===e.filter),o=e.loading?"animate-spin":"",i=[...new Set(e.articles.map(c=>c.source))];let n="";e.loading?n=A():e.error?n=j(e.error):e.viewMode==="ai"?e.groups.length===0?n=b():n=e.groups.map(k).join(""):t.length===0?n=b():n=t.map($).join("");const r=e.viewMode==="regular"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200",s=e.viewMode==="ai"?"bg-blue-600 text-white":"bg-blue-100 text-blue-700 hover:bg-blue-200",l=e.viewMode==="regular"?`
|
||||||
|
<button onclick="setFilter('all')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${e.filter==="all"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200"}">All</button>
|
||||||
|
${i.map(c=>`<button onclick="setFilter('${c}')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${e.filter===c?"bg-gray-900 text-white":g(c)}">${h[c]?.name||c}</button>`).join("")}
|
||||||
|
`:"";w.innerHTML=`<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-10">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">News Feed</h1>
|
||||||
|
<p class="text-sm text-gray-500">${e.viewMode==="ai"?"AI-grouped summaries from multiple sources":"Latest headlines from multiple sources"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-1 mr-2 p-1 bg-gray-100 rounded-lg">
|
||||||
|
<button onclick="setViewMode('regular')" class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${r}">Regular</button>
|
||||||
|
<button onclick="setViewMode('ai')" class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${s}">AI Grouped</button>
|
||||||
|
</div>
|
||||||
|
${l}
|
||||||
|
${e.viewMode==="ai"?'<button onclick="clearCacheAndRefresh()" class="ml-2 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors" title="Clear cache and fetch fresh AI groupings">Clear Cache</button>':""}
|
||||||
|
<button onclick="enterCarMode()" class="ml-2 px-3 py-1.5 text-sm font-medium bg-indigo-100 text-indigo-700 hover:bg-indigo-200 rounded-lg transition-colors flex items-center gap-1.5" title="Enter Car Mode - Auto-cycling news display">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
||||||
|
</svg>
|
||||||
|
Car Mode
|
||||||
|
</button>
|
||||||
|
<button onclick="refresh()" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||||||
|
<svg class="w-5 h-5 ${o}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg></button>
|
||||||
|
</div></div></div></header>
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
${n}
|
||||||
|
</div></main>
|
||||||
|
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 text-center text-sm text-gray-500">
|
||||||
|
News aggregated from ABC, NPR, CNN, NBC, CBS & NY Times
|
||||||
|
</div></footer></div>`}async function f(){e.loading=!0,e.error=null,a();try{const t=await fetch(`${d}/news`);if(!t.ok)throw new Error("Failed to fetch news");const o=await t.json(),i=o.feeds.flatMap(n=>n.items).sort((n,r)=>new Date(r.pubDate)-new Date(n.pubDate));e.articles=i,e.loading=!1,o.errors&&o.errors.length>0&&console.warn("Some feeds failed:",o.errors)}catch(t){e.error=t.message,e.loading=!1}a()}async function p(){e.loading=!0,e.error=null,e.aiArticleCount=0,a();try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((s,l)=>s+l.items.length,0);e.aiArticleCount=Math.min(r,50),a()}const o=await fetch(`${d}/grouped-news`);if(!o.ok)throw new Error("Failed to fetch grouped news");const i=await o.json();e.groups=i.groups,e.loading=!1,e.aiArticleCount=0}catch(t){e.error=t.message,e.loading=!1,e.aiArticleCount=0}a()}window.setFilter=function(t){e.filter=t,a()};window.setViewMode=function(t){e.viewMode=t,t==="ai"&&e.groups.length===0?p():a()};window.refresh=function(){e.viewMode==="ai"?p():f()};window.clearCacheAndRefresh=async function(){e.loading=!0,e.error=null,e.aiArticleCount=0,a();try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((s,l)=>s+l.items.length,0);e.aiArticleCount=Math.min(r,50),a()}const o=await fetch(`${d}/grouped-news?refresh=true`);if(!o.ok)throw new Error("Failed to fetch grouped news");const i=await o.json();e.groups=i.groups,e.loading=!1,e.aiArticleCount=0}catch(t){e.error=t.message,e.loading=!1,e.aiArticleCount=0}a()};window.fetchNews=f;window.fetchGroupedNews=p;window.enterCarMode=async function(){if(e.carMode=!0,e.carModeIndex=0,e.aiArticleCount=0,a(),e.groups.length===0)try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((s,l)=>s+l.items.length,0);e.aiArticleCount=Math.min(r,50),a()}const o=await fetch(`${d}/grouped-news`);if(!o.ok)throw new Error("Failed to fetch grouped news");const i=await o.json();e.groups=i.groups,e.aiArticleCount=0,a()}catch(t){console.error("Failed to load grouped news for car mode:",t),e.aiArticleCount=0}x()};window.exitCarMode=function(){e.carMode=!1,m(),a()};window.carModeNext=function(){e.groups.length!==0&&(e.carModeIndex=(e.carModeIndex+1)%e.groups.length,m(),a(),x())};window.carModePrev=function(){e.groups.length!==0&&(e.carModeIndex=(e.carModeIndex-1+e.groups.length)%e.groups.length,m(),a(),x())};function x(){m(),e.carModeInterval=setInterval(()=>{e.groups.length>0&&(e.carModeIndex=(e.carModeIndex+1)%e.groups.length,a())},5e3)}function m(){e.carModeInterval&&(clearInterval(e.carModeInterval),e.carModeInterval=null)}document.addEventListener("keydown",t=>{if(e.carMode)switch(t.key){case"Escape":window.exitCarMode();break;case"ArrowRight":case" ":window.carModeNext();break;case"ArrowLeft":window.carModePrev();break}});f();setInterval(()=>{e.viewMode==="ai"?p():f()},300*1e3);
|
||||||
190
news-app/build/assets/index-DcnxZ66w.js
Normal file
190
news-app/build/assets/index-DcnxZ66w.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const n of r)if(n.type==="childList")for(const l of n.addedNodes)l.tagName==="LINK"&&l.rel==="modulepreload"&&s(l)}).observe(document,{childList:!0,subtree:!0});function a(r){const n={};return r.integrity&&(n.integrity=r.integrity),r.referrerPolicy&&(n.referrerPolicy=r.referrerPolicy),r.crossOrigin==="use-credentials"?n.credentials="include":r.crossOrigin==="anonymous"?n.credentials="omit":n.credentials="same-origin",n}function s(r){if(r.ep)return;r.ep=!0;const n=a(r);fetch(r.href,n)}})();const d="/carnews/api",v=document.querySelector("#app");async function h(t,o=12e4){const a=new AbortController,s=setTimeout(()=>a.abort(),o);try{const r=await fetch(t,{signal:a.signal});return clearTimeout(s),r}catch(r){throw clearTimeout(s),r.name==="AbortError"?new Error("Request timed out. Please try again."):r}}const e={articles:[],groups:[],loading:!0,error:null,filter:"all",viewMode:"regular",carMode:!1,carModeIndex:0,carModeInterval:null,aiArticleCount:0};function k(t){const o=new Date(t),s=new Date-o,r=Math.floor(s/(1e3*60*60)),n=Math.floor(s/(1e3*60));return n<60?n+"m ago":r<24?r+"h ago":o.toLocaleDateString("en-US",{month:"short",day:"numeric"})}function M(t,o=150){return!t||t.length<=o?t||"":t.slice(0,o).trim()+"..."}const w={abc:{name:"ABC News",color:"bg-blue-100 text-blue-800"},npr:{name:"NPR",color:"bg-indigo-100 text-indigo-800"},cnn:{name:"CNN",color:"bg-orange-100 text-orange-800"},nbc:{name:"NBC News",color:"bg-purple-100 text-purple-800"},cbs:{name:"CBS News",color:"bg-emerald-100 text-emerald-800"},nytimes:{name:"NY Times",color:"bg-slate-100 text-slate-800"}};function g(t){return w[t]?.color||"bg-gray-100 text-gray-800"}function u(t){return w[t]?.name||t}const b={politics:"bg-red-100 text-red-800",business:"bg-green-100 text-green-800",technology:"bg-blue-100 text-blue-800",sports:"bg-orange-100 text-orange-800",entertainment:"bg-pink-100 text-pink-800",health:"bg-teal-100 text-teal-800",science:"bg-purple-100 text-purple-800",world:"bg-indigo-100 text-indigo-800",other:"bg-gray-100 text-gray-800"};function C(t){return b[t]||b.other}function $(t){const o=t.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${t.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`:"",a=t.sources.slice(0,3).map(r=>`<span class="px-2 py-0.5 text-xs font-medium rounded-full ${g(r)}">${u(r)}</span>`).join(""),s=t.sources.length>3?`<span class="text-xs text-gray-500">+${t.sources.length-3}</span>`:"";return`<article class="group bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden border border-gray-100 border-l-4 border-l-blue-500">
|
||||||
|
${o}
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3 flex-wrap">
|
||||||
|
<span class="px-2.5 py-1 text-xs font-medium rounded-full ${C(t.category)}">
|
||||||
|
${t.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${t.articleCount} articles</span>
|
||||||
|
<span class="ml-auto px-2 py-0.5 text-xs font-medium rounded-full bg-blue-600 text-white">AI Summary</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 mb-3">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10] mb-4">
|
||||||
|
${t.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
${a}${s}
|
||||||
|
</div>
|
||||||
|
<details class="text-sm">
|
||||||
|
<summary class="cursor-pointer text-blue-600 hover:text-blue-800 font-medium">View source articles</summary>
|
||||||
|
<ul class="mt-2 space-y-1 pl-4">
|
||||||
|
${t.articles.map(r=>`<li><a href="${r.link}" target="_blank" rel="noopener noreferrer" class="text-gray-600 hover:text-blue-600 hover:underline">${M(r.title,60)} <span class="text-gray-400">(${u(r.source)})</span></a></li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>`}function A(t){const o=t.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${t.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`:"";return`<article class="group bg-white rounded-xl shadow-sm hover:shadow-lg transition-all duration-300 overflow-hidden border border-gray-100">
|
||||||
|
<a href="${t.link}" target="_blank" rel="noopener noreferrer" class="block">
|
||||||
|
${o}
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<span class="px-2.5 py-1 text-xs font-medium rounded-full ${g(t.source)}">
|
||||||
|
${u(t.source)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${k(t.pubDate)}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2 mb-2">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10]">
|
||||||
|
${M(t.content,500)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>`}function j(){let t="Loading latest news...";return(e.viewMode==="ai"||e.carMode)&&(e.aiArticleCount>0?t=`Sending ${e.aiArticleCount} articles to AI for analysis. Please wait patiently...`:t="Fetching news from sources..."),`<div class="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
|
<div class="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
|
||||||
|
<p class="text-gray-600">${t}</p>
|
||||||
|
</div>`}function N(t){return`<div class="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-red-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
|
||||||
|
</svg></div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">Failed to load news</h3>
|
||||||
|
<p class="text-gray-600 mb-4">${t}</p>
|
||||||
|
<button onclick="fetchNews()" class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors">Try Again</button>
|
||||||
|
</div>`}function y(){return`<div class="col-span-full flex flex-col items-center justify-center py-20 text-center">
|
||||||
|
<div class="w-16 h-16 bg-gray-100 rounded-full flex items-center justify-center mb-4">
|
||||||
|
<svg class="w-8 h-8 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
|
||||||
|
</svg></div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-2">No articles found</h3>
|
||||||
|
<p class="text-gray-600">Try selecting a different source filter.</p>
|
||||||
|
</div>`}function I(t,o,a){const s=t.sources.slice(0,4).map(n=>`<span class="px-3 py-1 text-sm font-medium rounded-full ${g(n)} bg-opacity-90">${u(n)}</span>`).join(""),r=t.sources.length>4?`<span class="text-sm text-gray-300">+${t.sources.length-4} more</span>`:"";return`
|
||||||
|
<div class="car-mode-card max-w-4xl w-full bg-white/95 backdrop-blur-sm rounded-3xl shadow-2xl overflow-hidden">
|
||||||
|
${t.image?`
|
||||||
|
<div class="h-64 sm:h-80 overflow-hidden">
|
||||||
|
<img src="${t.image}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.style.display='none'" />
|
||||||
|
</div>
|
||||||
|
`:""}
|
||||||
|
<div class="p-8 sm:p-10">
|
||||||
|
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
||||||
|
<span class="px-4 py-1.5 text-sm font-semibold rounded-full ${C(t.category)}">
|
||||||
|
${t.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">${t.articleCount} articles</span>
|
||||||
|
<span class="ml-auto px-3 py-1 text-sm font-medium rounded-full bg-blue-600 text-white">AI Summary</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4 leading-tight">
|
||||||
|
${t.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 leading-relaxed mb-6 line-clamp-6">
|
||||||
|
${t.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
${s}${r}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Progress bar -->
|
||||||
|
<div class="h-1 bg-gray-200">
|
||||||
|
<div class="car-mode-progress h-full bg-blue-600"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Card counter -->
|
||||||
|
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2">
|
||||||
|
${Array.from({length:a},(n,l)=>`
|
||||||
|
<div class="w-2 h-2 rounded-full transition-all duration-300 ${l===o?"bg-white w-6":"bg-white/40"}"></div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`}function E(){if(e.groups.length===0)return`
|
||||||
|
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center">
|
||||||
|
<div class="car-mode-blur-1"></div>
|
||||||
|
<div class="car-mode-blur-2"></div>
|
||||||
|
<div class="car-mode-blur-3"></div>
|
||||||
|
<div class="relative z-10 text-center text-white">
|
||||||
|
<div class="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-6"></div>
|
||||||
|
<p class="text-xl">${e.aiArticleCount>0?`Sending ${e.aiArticleCount} articles to AI for analysis.<br>Please wait patiently...`:"Fetching news from sources..."}</p>
|
||||||
|
</div>
|
||||||
|
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`;const t=e.groups[e.carModeIndex];return`
|
||||||
|
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center p-6">
|
||||||
|
<div class="car-mode-blur-1"></div>
|
||||||
|
<div class="car-mode-blur-2"></div>
|
||||||
|
<div class="car-mode-blur-3"></div>
|
||||||
|
|
||||||
|
<!-- Exit button -->
|
||||||
|
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
||||||
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Navigation buttons -->
|
||||||
|
<button onclick="carModePrev()" class="absolute left-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Previous">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="carModeNext()" class="absolute right-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Next">
|
||||||
|
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Card -->
|
||||||
|
<div class="relative z-10 flex items-center justify-center w-full">
|
||||||
|
${I(t,e.carModeIndex,e.groups.length)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Car mode indicator -->
|
||||||
|
<div class="absolute top-6 left-6 z-20 flex items-center gap-2 text-white/70">
|
||||||
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-medium">Car Mode</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`}function i(){if(e.carMode){v.innerHTML=E();return}const t=e.filter==="all"?e.articles:e.articles.filter(c=>c.source===e.filter),o=e.loading?"animate-spin":"",a=[...new Set(e.articles.map(c=>c.source))];let s="";e.loading?s=j():e.error?s=N(e.error):e.viewMode==="ai"?e.groups.length===0?s=y():s=e.groups.map($).join(""):t.length===0?s=y():s=t.map(A).join("");const r=e.viewMode==="regular"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200",n=e.viewMode==="ai"?"bg-blue-600 text-white":"bg-blue-100 text-blue-700 hover:bg-blue-200",l=e.viewMode==="regular"?`
|
||||||
|
<button onclick="setFilter('all')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${e.filter==="all"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200"}">All</button>
|
||||||
|
${a.map(c=>`<button onclick="setFilter('${c}')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${e.filter===c?"bg-gray-900 text-white":g(c)}">${w[c]?.name||c}</button>`).join("")}
|
||||||
|
`:"";v.innerHTML=`<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">
|
||||||
|
<header class="bg-white shadow-sm sticky top-0 z-10">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
|
||||||
|
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">News Feed</h1>
|
||||||
|
<p class="text-sm text-gray-500">${e.viewMode==="ai"?"AI-grouped summaries from multiple sources":"Latest headlines from multiple sources"}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
<div class="flex items-center gap-1 mr-2 p-1 bg-gray-100 rounded-lg">
|
||||||
|
<button onclick="setViewMode('regular')" class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${r}">Regular</button>
|
||||||
|
<button onclick="setViewMode('ai')" class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${n}">AI Grouped</button>
|
||||||
|
</div>
|
||||||
|
${l}
|
||||||
|
${e.viewMode==="ai"?'<button onclick="clearCacheAndRefresh()" class="ml-2 px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800 hover:bg-red-50 rounded-lg transition-colors" title="Clear cache and fetch fresh AI groupings">Clear Cache</button>':""}
|
||||||
|
<button onclick="enterCarMode()" class="ml-2 px-3 py-1.5 text-sm font-medium bg-indigo-100 text-indigo-700 hover:bg-indigo-200 rounded-lg transition-colors flex items-center gap-1.5" title="Enter Car Mode - Auto-cycling news display">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
||||||
|
</svg>
|
||||||
|
Car Mode
|
||||||
|
</button>
|
||||||
|
<button onclick="refresh()" class="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
|
||||||
|
<svg class="w-5 h-5 ${o}" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
|
||||||
|
</svg></button>
|
||||||
|
</div></div></div></header>
|
||||||
|
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
${s}
|
||||||
|
</div></main>
|
||||||
|
<footer class="bg-white border-t border-gray-200 mt-auto">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 text-center text-sm text-gray-500">
|
||||||
|
News aggregated from ABC, NPR, CNN, NBC, CBS & NY Times
|
||||||
|
</div></footer></div>`}async function m(){e.loading=!0,e.error=null,i();try{const t=await fetch(`${d}/news`);if(!t.ok)throw new Error("Failed to fetch news");const o=await t.json(),a=o.feeds.flatMap(s=>s.items).sort((s,r)=>new Date(r.pubDate)-new Date(s.pubDate));e.articles=a,e.loading=!1,o.errors&&o.errors.length>0&&console.warn("Some feeds failed:",o.errors)}catch(t){e.error=t.message,e.loading=!1}i()}async function f(){e.loading=!0,e.error=null,e.aiArticleCount=0,i();try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((n,l)=>n+l.items.length,0);e.aiArticleCount=Math.min(r,50),i()}const o=await h(`${d}/grouped-news`);if(!o.ok)throw new Error("Failed to fetch grouped news");const a=await o.json();e.groups=a.groups,e.loading=!1,e.aiArticleCount=0}catch(t){e.error=t.message,e.loading=!1,e.aiArticleCount=0}i()}window.setFilter=function(t){e.filter=t,i()};window.setViewMode=function(t){e.viewMode=t,t==="ai"&&e.groups.length===0?f():i()};window.refresh=function(){e.viewMode==="ai"?f():m()};window.clearCacheAndRefresh=async function(){e.loading=!0,e.error=null,e.aiArticleCount=0,i();try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((n,l)=>n+l.items.length,0);e.aiArticleCount=Math.min(r,50),i()}const o=await h(`${d}/grouped-news?refresh=true`);if(!o.ok)throw new Error("Failed to fetch grouped news");const a=await o.json();e.groups=a.groups,e.loading=!1,e.aiArticleCount=0}catch(t){e.error=t.message,e.loading=!1,e.aiArticleCount=0}i()};window.fetchNews=m;window.fetchGroupedNews=f;window.enterCarMode=async function(){if(e.carMode=!0,e.carModeIndex=0,e.aiArticleCount=0,i(),e.groups.length===0)try{const t=await fetch(`${d}/news`);if(t.ok){const r=(await t.json()).feeds.reduce((n,l)=>n+l.items.length,0);e.aiArticleCount=Math.min(r,50),i()}const o=await h(`${d}/grouped-news`);if(!o.ok)throw new Error("Failed to fetch grouped news");const a=await o.json();e.groups=a.groups,e.aiArticleCount=0,i()}catch(t){console.error("Failed to load grouped news for car mode:",t),e.aiArticleCount=0}x()};window.exitCarMode=function(){e.carMode=!1,p(),i()};window.carModeNext=function(){e.groups.length!==0&&(e.carModeIndex=(e.carModeIndex+1)%e.groups.length,p(),i(),x())};window.carModePrev=function(){e.groups.length!==0&&(e.carModeIndex=(e.carModeIndex-1+e.groups.length)%e.groups.length,p(),i(),x())};function x(){p(),e.carModeInterval=setInterval(()=>{e.groups.length>0&&(e.carModeIndex=(e.carModeIndex+1)%e.groups.length,i())},5e3)}function p(){e.carModeInterval&&(clearInterval(e.carModeInterval),e.carModeInterval=null)}document.addEventListener("keydown",t=>{if(e.carMode)switch(t.key){case"Escape":window.exitCarMode();break;case"ArrowRight":case" ":window.carModeNext();break;case"ArrowLeft":window.carModePrev();break}});m();setInterval(()=>{e.viewMode==="ai"?f():m()},300*1e3);
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📰</text></svg>" />
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📰</text></svg>" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>News Feed - Yahoo & ABC News</title>
|
<title>News Feed - Yahoo & ABC News</title>
|
||||||
<script type="module" crossorigin src="/carnews/assets/index-BvWUS-em.js"></script>
|
<script type="module" crossorigin src="/carnews/assets/index-DcnxZ66w.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/carnews/assets/index-BcP3wp7F.css">
|
<link rel="stylesheet" crossorigin href="/carnews/assets/index-BcP3wp7F.css">
|
||||||
</head>
|
</head>
|
||||||
<body class="antialiased">
|
<body class="antialiased">
|
||||||
|
|||||||
@@ -42,14 +42,13 @@ Based on the codebase analysis, here's what needs to change for your Ubuntu depl
|
|||||||
|
|
||||||
4. Update paths if directory structure differs
|
4. Update paths if directory structure differs
|
||||||
|
|
||||||
Current macOS path: /opt/homebrew/var/www/news-app
|
On Ubuntu: /var/www/news-feed-car-mode/news-app
|
||||||
On Ubuntu you might use: /var/www/news-app or similar
|
|
||||||
|
|
||||||
No Changes Needed
|
No Changes Needed
|
||||||
|
|
||||||
- vite.config.js - base: '/carnews/' stays the same
|
- vite.config.js - base: '/carnews/' stays the same
|
||||||
- server.js - Port 5555 works as-is
|
- server.js - Port 5555 works as-is
|
||||||
- .env file - Copy it over
|
- .env file - copied over
|
||||||
- All source code - Uses relative paths
|
- All source code - Uses relative paths
|
||||||
|
|
||||||
Deployment Steps
|
Deployment Steps
|
||||||
|
|||||||
@@ -225,6 +225,7 @@ app.get('/api/grouped-news', async (req, res) => {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
console.log(`Sending ${articlesForAI.length} articles to OpenAI gpt-5-mini...`)
|
console.log(`Sending ${articlesForAI.length} articles to OpenAI gpt-5-mini...`)
|
||||||
|
const openaiStartTime = Date.now()
|
||||||
const completion = await openai.chat.completions.create({
|
const completion = await openai.chat.completions.create({
|
||||||
model: 'gpt-5-mini',
|
model: 'gpt-5-mini',
|
||||||
messages: [
|
messages: [
|
||||||
@@ -266,9 +267,13 @@ Only return valid JSON.`
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
const openaiDuration = ((Date.now() - openaiStartTime) / 1000).toFixed(2)
|
||||||
|
|
||||||
const aiResponse = JSON.parse(completion.choices[0].message.content)
|
const aiResponse = JSON.parse(completion.choices[0].message.content)
|
||||||
console.log(`✓ OpenAI returned ${aiResponse.groups.length} groups`)
|
const usage = completion.usage || {}
|
||||||
|
console.log(`✓ OpenAI response received in ${openaiDuration}s`)
|
||||||
|
console.log(` - Groups returned: ${aiResponse.groups.length}`)
|
||||||
|
console.log(` - Tokens: ${usage.prompt_tokens || 'N/A'} prompt, ${usage.completion_tokens || 'N/A'} completion, ${usage.total_tokens || 'N/A'} total`)
|
||||||
|
|
||||||
// Enrich groups with source articles and images
|
// Enrich groups with source articles and images
|
||||||
const enrichedGroups = aiResponse.groups.map((group) => {
|
const enrichedGroups = aiResponse.groups.map((group) => {
|
||||||
|
|||||||
@@ -1,8 +1,47 @@
|
|||||||
import './style.css'
|
import './style.css'
|
||||||
|
|
||||||
const API_BASE = '/api'
|
const API_BASE = '/carnews/api'
|
||||||
const app = document.querySelector('#app')
|
const app = document.querySelector('#app')
|
||||||
|
|
||||||
|
// Fetch with timeout helper (default 2 minutes for AI calls)
|
||||||
|
async function fetchWithTimeout(url, timeoutMs = 120000) {
|
||||||
|
const controller = new AbortController()
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: controller.signal })
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
if (error.name === 'AbortError') {
|
||||||
|
throw new Error('Request timed out. Please try again.')
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cookie helpers
|
||||||
|
function setCookie(name, value, days) {
|
||||||
|
let expires = ""
|
||||||
|
if (days) {
|
||||||
|
const date = new Date()
|
||||||
|
date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000))
|
||||||
|
expires = "; expires=" + date.toUTCString()
|
||||||
|
}
|
||||||
|
document.cookie = name + "=" + (value || "") + expires + "; path=/"
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCookie(name) {
|
||||||
|
const nameEQ = name + "="
|
||||||
|
const ca = document.cookie.split(';')
|
||||||
|
for(let i=0;i < ca.length;i++) {
|
||||||
|
let c = ca[i]
|
||||||
|
while (c.charAt(0)==' ') c = c.substring(1,c.length)
|
||||||
|
if (c.indexOf(nameEQ) == 0) return c.substring(nameEQ.length,c.length)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
articles: [],
|
articles: [],
|
||||||
groups: [],
|
groups: [],
|
||||||
@@ -13,6 +52,10 @@ const state = {
|
|||||||
carMode: false,
|
carMode: false,
|
||||||
carModeIndex: 0,
|
carModeIndex: 0,
|
||||||
carModeInterval: null,
|
carModeInterval: null,
|
||||||
|
aiArticleCount: 0,
|
||||||
|
locked: !getCookie('news_feed_unlocked'),
|
||||||
|
unlockClicks: 0,
|
||||||
|
isTransitioning: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
@@ -133,9 +176,14 @@ function renderArticle(article) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderLoading() {
|
function renderLoading() {
|
||||||
const message = state.viewMode === 'ai'
|
let message = 'Loading latest news...'
|
||||||
? 'AI is analyzing and grouping news...'
|
if (state.viewMode === 'ai' || state.carMode) {
|
||||||
: 'Loading latest news...'
|
if (state.aiArticleCount > 0) {
|
||||||
|
message = `Sending ${state.aiArticleCount} articles to AI for analysis. Please wait patiently...`
|
||||||
|
} else {
|
||||||
|
message = 'Fetching news from sources...'
|
||||||
|
}
|
||||||
|
}
|
||||||
return `<div class="col-span-full flex flex-col items-center justify-center py-20">
|
return `<div class="col-span-full flex flex-col items-center justify-center py-20">
|
||||||
<div class="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
|
<div class="w-12 h-12 border-4 border-blue-200 border-t-blue-600 rounded-full animate-spin mb-4"></div>
|
||||||
<p class="text-gray-600">${message}</p>
|
<p class="text-gray-600">${message}</p>
|
||||||
@@ -166,45 +214,33 @@ function renderEmpty() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderCarModeCard(group, index, total) {
|
function renderCarModeCard(group, index, total) {
|
||||||
const sourceBadges = group.sources
|
const fadeClass = state.isTransitioning ? 'fading-out' : ''
|
||||||
.slice(0, 4)
|
|
||||||
.map(s => `<span class="px-3 py-1 text-sm font-medium rounded-full ${getSourceColor(s)} bg-opacity-90">${getSourceName(s)}</span>`)
|
|
||||||
.join('')
|
|
||||||
|
|
||||||
const moreCount = group.sources.length > 4 ? `<span class="text-sm text-gray-300">+${group.sources.length - 4} more</span>` : ''
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="car-mode-card max-w-4xl w-full bg-white/95 backdrop-blur-sm rounded-3xl shadow-2xl overflow-hidden">
|
<div class="car-mode-card ${fadeClass} w-full max-w-[350px] sm:max-w-3xl bg-black/90 backdrop-blur-md rounded-[2rem] shadow-2xl overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[92vh] transition-all duration-500 mb-2 border border-white/10">
|
||||||
${group.image ? `
|
${group.image ? `
|
||||||
<div class="h-64 sm:h-80 overflow-hidden">
|
<div class="h-48 sm:h-80 shrink-0 overflow-hidden relative group-image-container">
|
||||||
<img src="${group.image}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.style.display='none'" />
|
<img src="${group.image}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.style.display='none'" />
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
|
||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
<div class="p-8 sm:p-10">
|
<div class="p-6 sm:p-10 flex flex-col flex-1 overflow-hidden relative">
|
||||||
<div class="flex items-center gap-3 mb-4 flex-wrap">
|
<h2 class="text-2xl sm:text-4xl font-bold text-white mb-4 leading-tight shrink-0 tracking-tight">
|
||||||
<span class="px-4 py-1.5 text-sm font-semibold rounded-full ${getCategoryColor(group.category)}">
|
|
||||||
${group.category}
|
|
||||||
</span>
|
|
||||||
<span class="text-gray-500">${group.articleCount} articles</span>
|
|
||||||
<span class="ml-auto px-3 py-1 text-sm font-medium rounded-full bg-blue-600 text-white">AI Summary</span>
|
|
||||||
</div>
|
|
||||||
<h2 class="text-2xl sm:text-3xl font-bold text-gray-900 mb-4 leading-tight">
|
|
||||||
${group.title}
|
${group.title}
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-lg text-gray-600 leading-relaxed mb-6 line-clamp-6">
|
|
||||||
${group.summary}
|
<div class="overflow-y-auto pr-2 custom-scrollbar flex-1 min-h-0">
|
||||||
|
<p class="text-xl sm:text-3xl text-gray-200 leading-relaxed mb-4">
|
||||||
|
${group.summary.split(/(?<=[.!?])\s+/).join('<br><br>')}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
|
||||||
${sourceBadges}${moreCount}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Progress bar -->
|
<!-- Progress bar -->
|
||||||
<div class="h-1 bg-gray-200">
|
<div class="h-1.5 bg-white/10 shrink-0">
|
||||||
<div class="car-mode-progress h-full bg-blue-600"></div>
|
<div class="car-mode-progress h-full bg-yellow-400"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<!-- Card counter -->
|
<!-- Card counter -->
|
||||||
<div class="absolute bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2">
|
<div class="absolute bottom-6 sm:bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2 z-30">
|
||||||
${Array.from({ length: total }, (_, i) => `
|
${Array.from({ length: total }, (_, i) => `
|
||||||
<div class="w-2 h-2 rounded-full transition-all duration-300 ${i === index ? 'bg-white w-6' : 'bg-white/40'}"></div>
|
<div class="w-2 h-2 rounded-full transition-all duration-300 ${i === index ? 'bg-white w-6' : 'bg-white/40'}"></div>
|
||||||
`).join('')}
|
`).join('')}
|
||||||
@@ -214,6 +250,9 @@ function renderCarModeCard(group, index, total) {
|
|||||||
|
|
||||||
function renderCarMode() {
|
function renderCarMode() {
|
||||||
if (state.groups.length === 0) {
|
if (state.groups.length === 0) {
|
||||||
|
const loadingMessage = state.aiArticleCount > 0
|
||||||
|
? `Sending ${state.aiArticleCount} articles to AI for analysis.<br>Please wait patiently...`
|
||||||
|
: 'Fetching news from sources...'
|
||||||
return `
|
return `
|
||||||
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center">
|
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center">
|
||||||
<div class="car-mode-blur-1"></div>
|
<div class="car-mode-blur-1"></div>
|
||||||
@@ -221,9 +260,9 @@ function renderCarMode() {
|
|||||||
<div class="car-mode-blur-3"></div>
|
<div class="car-mode-blur-3"></div>
|
||||||
<div class="relative z-10 text-center text-white">
|
<div class="relative z-10 text-center text-white">
|
||||||
<div class="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-6"></div>
|
<div class="w-16 h-16 border-4 border-white/30 border-t-white rounded-full animate-spin mx-auto mb-6"></div>
|
||||||
<p class="text-xl">Loading AI grouped news...</p>
|
<p class="text-xl">${loadingMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
<button onclick="exitCarMode()" class="absolute top-14 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
@@ -235,48 +274,76 @@ function renderCarMode() {
|
|||||||
const currentGroup = state.groups[state.carModeIndex]
|
const currentGroup = state.groups[state.carModeIndex]
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="fixed inset-0 z-50 car-mode-bg flex items-center justify-center p-6">
|
<div class="fixed inset-0 z-50 car-mode-bg flex flex-col items-center justify-start py-4 px-2 sm:p-6 pt-2">
|
||||||
<div class="car-mode-blur-1"></div>
|
<div class="car-mode-blur-1"></div>
|
||||||
<div class="car-mode-blur-2"></div>
|
<div class="car-mode-blur-2"></div>
|
||||||
<div class="car-mode-blur-3"></div>
|
<div class="car-mode-blur-3"></div>
|
||||||
|
|
||||||
<!-- Exit button -->
|
<!-- Exit button -->
|
||||||
<button onclick="exitCarMode()" class="absolute top-6 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
|
<button onclick="exitCarMode()" class="absolute top-2 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Exit Car Mode (Esc)">
|
||||||
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<!-- Navigation buttons -->
|
|
||||||
<button onclick="carModePrev()" class="absolute left-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Previous">
|
|
||||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<button onclick="carModeNext()" class="absolute right-6 top-1/2 -translate-y-1/2 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Next">
|
|
||||||
<svg class="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Card -->
|
|
||||||
<div class="relative z-10 flex items-center justify-center w-full">
|
|
||||||
${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Car mode indicator -->
|
<!-- Car mode indicator -->
|
||||||
<div class="absolute top-6 left-6 z-20 flex items-center gap-2 text-white/70">
|
<div class="absolute top-2 left-6 z-20 flex items-center gap-2 text-white/70 backdrop-blur-sm px-3 py-1.5 rounded-full bg-black/10">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 17a2 2 0 11-4 0 2 2 0 014 0zM19 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16V6a1 1 0 00-1-1H4a1 1 0 00-1 1v10a1 1 0 001 1h1m8-1a1 1 0 01-1 1H9m4-1V8a1 1 0 011-1h2.586a1 1 0 01.707.293l3.414 3.414a1 1 0 01.293.707V16a1 1 0 01-1 1h-1m-6-1a1 1 0 001 1h1M5 17a2 2 0 104 0m-4 0a2 2 0 114 0m6 0a2 2 0 104 0m-4 0a2 2 0 114 0"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-sm font-medium">Car Mode</span>
|
<span class="text-sm font-medium">Car Mode</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Card Container -->
|
||||||
|
<div class="relative z-10 flex flex-col items-center w-full h-full pt-16">
|
||||||
|
${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
|
||||||
|
|
||||||
|
<!-- Navigation buttons moved below card -->
|
||||||
|
<div class="flex items-center gap-12 mt-0 z-20">
|
||||||
|
<button onclick="carModePrev()" class="p-4 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Previous">
|
||||||
|
<svg class="w-8 h-8 sm:w-10 sm:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button onclick="carModeNext()" class="p-4 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Next">
|
||||||
|
<svg class="w-8 h-8 sm:w-10 sm:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderLockScreen() {
|
||||||
|
return `<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 onclick="handleUnlockClick()" class="text-4xl font-bold text-gray-900 cursor-pointer select-none">
|
||||||
|
News Feed
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUnlockClick() {
|
||||||
|
state.unlockClicks++
|
||||||
|
if (state.unlockClicks >= 3) {
|
||||||
|
state.locked = false
|
||||||
|
state.unlockClicks = 0
|
||||||
|
setCookie('news_feed_unlocked', 'true', 30) // Unlock for 30 days
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
// If locked, render lock screen
|
||||||
|
if (state.locked) {
|
||||||
|
app.innerHTML = renderLockScreen()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// If car mode is active, render car mode overlay
|
// If car mode is active, render car mode overlay
|
||||||
if (state.carMode) {
|
if (state.carMode) {
|
||||||
app.innerHTML = renderCarMode()
|
app.innerHTML = renderCarMode()
|
||||||
@@ -384,18 +451,32 @@ async function fetchNews() {
|
|||||||
async function fetchGroupedNews() {
|
async function fetchGroupedNews() {
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = null
|
state.error = null
|
||||||
|
state.aiArticleCount = 0
|
||||||
render()
|
render()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/grouped-news`)
|
// First fetch article count to show in loading message
|
||||||
|
const newsResponse = await fetch(`${API_BASE}/news`)
|
||||||
|
if (newsResponse.ok) {
|
||||||
|
const newsData = await newsResponse.json()
|
||||||
|
const totalArticles = newsData.feeds.reduce((sum, feed) => sum + feed.items.length, 0)
|
||||||
|
// Backend uses min 5 per source, then fills to 50 total
|
||||||
|
state.aiArticleCount = Math.min(totalArticles, 50)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fetch the AI grouped news (2 minute timeout)
|
||||||
|
const response = await fetchWithTimeout(`${API_BASE}/grouped-news`)
|
||||||
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
state.groups = data.groups
|
state.groups = data.groups
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
state.aiArticleCount = 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.error = error.message
|
state.error = error.message
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
state.aiArticleCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
@@ -426,18 +507,31 @@ window.refresh = function() {
|
|||||||
window.clearCacheAndRefresh = async function() {
|
window.clearCacheAndRefresh = async function() {
|
||||||
state.loading = true
|
state.loading = true
|
||||||
state.error = null
|
state.error = null
|
||||||
|
state.aiArticleCount = 0
|
||||||
render()
|
render()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/grouped-news?refresh=true`)
|
// First fetch article count to show in loading message
|
||||||
|
const newsResponse = await fetch(`${API_BASE}/news`)
|
||||||
|
if (newsResponse.ok) {
|
||||||
|
const newsData = await newsResponse.json()
|
||||||
|
const totalArticles = newsData.feeds.reduce((sum, feed) => sum + feed.items.length, 0)
|
||||||
|
state.aiArticleCount = Math.min(totalArticles, 50)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fetch the AI grouped news with cache refresh (2 minute timeout)
|
||||||
|
const response = await fetchWithTimeout(`${API_BASE}/grouped-news?refresh=true`)
|
||||||
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
state.groups = data.groups
|
state.groups = data.groups
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
state.aiArticleCount = 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.error = error.message
|
state.error = error.message
|
||||||
state.loading = false
|
state.loading = false
|
||||||
|
state.aiArticleCount = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
render()
|
render()
|
||||||
@@ -445,27 +539,52 @@ window.clearCacheAndRefresh = async function() {
|
|||||||
|
|
||||||
window.fetchNews = fetchNews
|
window.fetchNews = fetchNews
|
||||||
window.fetchGroupedNews = fetchGroupedNews
|
window.fetchGroupedNews = fetchGroupedNews
|
||||||
|
window.handleUnlockClick = handleUnlockClick
|
||||||
|
|
||||||
// Car Mode Functions
|
// Car Mode Functions
|
||||||
window.enterCarMode = async function() {
|
window.enterCarMode = async function() {
|
||||||
state.carMode = true
|
state.carMode = true
|
||||||
state.carModeIndex = 0
|
state.carModeIndex = 0
|
||||||
|
state.aiArticleCount = 0
|
||||||
render()
|
render()
|
||||||
|
|
||||||
// Fetch AI grouped news if not already loaded
|
// Always check for updates when entering car mode
|
||||||
if (state.groups.length === 0) {
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE}/grouped-news`)
|
// First fetch article count to show in loading message if we need to load
|
||||||
|
if (state.groups.length === 0) {
|
||||||
|
const newsResponse = await fetch(`${API_BASE}/news`)
|
||||||
|
if (newsResponse.ok) {
|
||||||
|
const newsData = await newsResponse.json()
|
||||||
|
const totalArticles = newsData.feeds.reduce((sum, feed) => sum + feed.items.length, 0)
|
||||||
|
state.aiArticleCount = Math.min(totalArticles, 50)
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now fetch the AI grouped news (2 minute timeout)
|
||||||
|
// The backend will handle caching logic (returning cached data if valid, or fresh if expired)
|
||||||
|
const response = await fetchWithTimeout(`${API_BASE}/grouped-news`)
|
||||||
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
if (!response.ok) throw new Error('Failed to fetch grouped news')
|
||||||
|
|
||||||
const data = await response.json()
|
const data = await response.json()
|
||||||
|
// Only update state if we got groups back
|
||||||
|
if (data.groups && data.groups.length > 0) {
|
||||||
state.groups = data.groups
|
state.groups = data.groups
|
||||||
|
}
|
||||||
|
|
||||||
|
state.aiArticleCount = 0
|
||||||
render()
|
render()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load grouped news for car mode:', error)
|
console.error('Failed to load grouped news for car mode:', error)
|
||||||
|
state.aiArticleCount = 0
|
||||||
|
// If we have no groups and failed to load, show error in render
|
||||||
|
if (state.groups.length === 0) {
|
||||||
|
state.error = error.message
|
||||||
}
|
}
|
||||||
|
render()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start auto-cycling every 5 seconds
|
// Start auto-cycling every 20 seconds
|
||||||
startCarModeCycle()
|
startCarModeCycle()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -476,31 +595,36 @@ window.exitCarMode = function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
window.carModeNext = function() {
|
window.carModeNext = function() {
|
||||||
if (state.groups.length === 0) return
|
if (state.groups.length === 0 || state.isTransitioning) return
|
||||||
state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
|
|
||||||
// Reset the cycle timer when manually navigating
|
|
||||||
stopCarModeCycle()
|
stopCarModeCycle()
|
||||||
render()
|
transitionCard((state.carModeIndex + 1) % state.groups.length)
|
||||||
startCarModeCycle()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.carModePrev = function() {
|
window.carModePrev = function() {
|
||||||
if (state.groups.length === 0) return
|
if (state.groups.length === 0 || state.isTransitioning) return
|
||||||
state.carModeIndex = (state.carModeIndex - 1 + state.groups.length) % state.groups.length
|
|
||||||
// Reset the cycle timer when manually navigating
|
|
||||||
stopCarModeCycle()
|
stopCarModeCycle()
|
||||||
render()
|
transitionCard((state.carModeIndex - 1 + state.groups.length) % state.groups.length)
|
||||||
|
}
|
||||||
|
|
||||||
|
function transitionCard(newIndex) {
|
||||||
|
state.isTransitioning = true
|
||||||
|
render() // This adds 'fading-out' class
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
state.carModeIndex = newIndex
|
||||||
|
state.isTransitioning = false
|
||||||
|
render() // This renders the new card, which triggers cardFadeIn and cardGlow animations
|
||||||
startCarModeCycle()
|
startCarModeCycle()
|
||||||
|
}, 500) // Match animation duration
|
||||||
}
|
}
|
||||||
|
|
||||||
function startCarModeCycle() {
|
function startCarModeCycle() {
|
||||||
stopCarModeCycle() // Clear any existing interval
|
stopCarModeCycle() // Clear any existing interval
|
||||||
state.carModeInterval = setInterval(() => {
|
state.carModeInterval = setInterval(() => {
|
||||||
if (state.groups.length > 0) {
|
if (state.groups.length > 0 && !state.isTransitioning) {
|
||||||
state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
|
transitionCard((state.carModeIndex + 1) % state.groups.length)
|
||||||
render()
|
|
||||||
}
|
}
|
||||||
}, 5000) // 5 seconds per card
|
}, 20000) // 20 seconds per card
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopCarModeCycle() {
|
function stopCarModeCycle() {
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Global Purple Gradient Background */
|
||||||
|
.bg-purple-gradient {
|
||||||
|
background: linear-gradient(135deg, #4c1d95 0%, #1e1b4b 100%);
|
||||||
|
background-attachment: fixed;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
/* Car Mode Animated Background */
|
/* Car Mode Animated Background */
|
||||||
.car-mode-bg {
|
.car-mode-bg {
|
||||||
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
background: #000000;
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,14 +19,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(80px);
|
filter: blur(80px);
|
||||||
opacity: 0.4;
|
opacity: 0.2;
|
||||||
animation: float 20s ease-in-out infinite;
|
animation: float 20s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.car-mode-bg::before {
|
.car-mode-bg::before {
|
||||||
width: 600px;
|
width: 600px;
|
||||||
height: 600px;
|
height: 600px;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #1a1a1a 0%, #000000 100%);
|
||||||
top: -200px;
|
top: -200px;
|
||||||
left: -200px;
|
left: -200px;
|
||||||
animation-delay: 0s;
|
animation-delay: 0s;
|
||||||
@@ -29,7 +35,7 @@
|
|||||||
.car-mode-bg::after {
|
.car-mode-bg::after {
|
||||||
width: 500px;
|
width: 500px;
|
||||||
height: 500px;
|
height: 500px;
|
||||||
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
background: linear-gradient(135deg, #262626 0%, #000000 100%);
|
||||||
bottom: -150px;
|
bottom: -150px;
|
||||||
right: -150px;
|
right: -150px;
|
||||||
animation-delay: -10s;
|
animation-delay: -10s;
|
||||||
@@ -41,14 +47,14 @@
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
filter: blur(100px);
|
filter: blur(100px);
|
||||||
opacity: 0.3;
|
opacity: 0.15;
|
||||||
animation: float 25s ease-in-out infinite;
|
animation: float 25s ease-in-out infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.car-mode-blur-1 {
|
.car-mode-blur-1 {
|
||||||
width: 400px;
|
width: 400px;
|
||||||
height: 400px;
|
height: 400px;
|
||||||
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
background: linear-gradient(135deg, #171717 0%, #000000 100%);
|
||||||
top: 50%;
|
top: 50%;
|
||||||
left: 20%;
|
left: 20%;
|
||||||
animation-delay: -5s;
|
animation-delay: -5s;
|
||||||
@@ -57,7 +63,7 @@
|
|||||||
.car-mode-blur-2 {
|
.car-mode-blur-2 {
|
||||||
width: 350px;
|
width: 350px;
|
||||||
height: 350px;
|
height: 350px;
|
||||||
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
background: linear-gradient(135deg, #262626 0%, #000000 100%);
|
||||||
top: 30%;
|
top: 30%;
|
||||||
right: 10%;
|
right: 10%;
|
||||||
animation-delay: -15s;
|
animation-delay: -15s;
|
||||||
@@ -66,7 +72,7 @@
|
|||||||
.car-mode-blur-3 {
|
.car-mode-blur-3 {
|
||||||
width: 300px;
|
width: 300px;
|
||||||
height: 300px;
|
height: 300px;
|
||||||
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
background: linear-gradient(135deg, #0a0a0a 0%, #000000 100%);
|
||||||
bottom: 20%;
|
bottom: 20%;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
animation-delay: -8s;
|
animation-delay: -8s;
|
||||||
@@ -89,13 +95,17 @@
|
|||||||
|
|
||||||
/* Car Mode Card Transitions */
|
/* Car Mode Card Transitions */
|
||||||
.car-mode-card {
|
.car-mode-card {
|
||||||
animation: cardFadeIn 0.8s ease-out;
|
animation: cardFadeIn 0.5s ease-out forwards, cardGlow 4s ease-out forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-card.fading-out {
|
||||||
|
animation: cardFadeOut 0.5s ease-in forwards;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes cardFadeIn {
|
@keyframes cardFadeIn {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(30px) scale(0.95);
|
transform: translateY(10px) scale(0.98);
|
||||||
}
|
}
|
||||||
to {
|
to {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -103,9 +113,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes cardGlow {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 80px 30px rgba(253, 224, 71, 0.95); /* Extremely bold start */
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
box-shadow: 0 0 100px 40px rgba(253, 224, 71, 0.8); /* Max spread early on */
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5); /* Settle to dark shadow */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardFadeOut {
|
||||||
|
from {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Progress bar animation */
|
/* Progress bar animation */
|
||||||
.car-mode-progress {
|
.car-mode-progress {
|
||||||
animation: progressFill 5s linear;
|
animation: progressFill 20s linear;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progressFill {
|
@keyframes progressFill {
|
||||||
|
|||||||
Reference in New Issue
Block a user