This commit is contained in:
2026-01-27 22:15:15 +00:00
parent e3842fb784
commit aef56eb315
8 changed files with 661 additions and 97 deletions

View 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:*)"
]
}
}

View 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);

View 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);

View File

@@ -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">

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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() {

View File

@@ -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 {