diff --git a/news-app/.claude/settings.local.json b/news-app/.claude/settings.local.json
new file mode 100644
index 0000000..3538060
--- /dev/null
+++ b/news-app/.claude/settings.local.json
@@ -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:*)"
+ ]
+ }
+}
diff --git a/news-app/build/assets/index-BurtuQum.js b/news-app/build/assets/index-BurtuQum.js
new file mode 100644
index 0000000..56bebfc
--- /dev/null
+++ b/news-app/build/assets/index-BurtuQum.js
@@ -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?`
`:"",i=t.sources.slice(0,3).map(r=>`${u(r)} `).join(""),n=t.sources.length>3?`+${t.sources.length-3} `:"";return`
+ ${o}
+
+
+
+ ${t.category}
+
+ ${t.articleCount} articles
+ AI Summary
+
+
+ ${t.title}
+
+
+ ${t.summary}
+
+
+ ${i}${n}
+
+
+ View source articles
+
+
+
+ `}function $(t){const o=t.image?``:"";return`
+
+ ${o}
+
+
+
+ ${u(t.source)}
+
+ ${C(t.pubDate)}
+
+
+ ${t.title}
+
+
+ ${y(t.content,500)}
+
+
+
+ `}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..."),``}function j(t){return`
+
+
Failed to load news
+
${t}
+
Try Again
+
`}function b(){return`
+
+
No articles found
+
Try selecting a different source filter.
+
`}function N(t,o,i){const n=t.sources.slice(0,4).map(s=>`${u(s)} `).join(""),r=t.sources.length>4?`+${t.sources.length-4} more `:"";return`
+
+ ${t.image?`
+
+
+
+ `:""}
+
+
+
+ ${t.category}
+
+ ${t.articleCount} articles
+ AI Summary
+
+
+ ${t.title}
+
+
+ ${t.summary}
+
+
+ ${n}${r}
+
+
+
+
+
+
+
+ ${Array.from({length:i},(s,l)=>`
+
+ `).join("")}
+
+ `}function I(){if(e.groups.length===0)return`
+
+
+
+
+
+
+
${e.aiArticleCount>0?`Sending ${e.aiArticleCount} articles to AI for analysis. Please wait patiently...`:"Fetching news from sources..."}
+
+
+
+
+
+
+
+ `;const t=e.groups[e.carModeIndex];return`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${N(t,e.carModeIndex,e.groups.length)}
+
+
+
+
+
+ `}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"?`
+ All
+ ${i.map(c=>`${h[c]?.name||c} `).join("")}
+ `:"";w.innerHTML=`
+
+
+
+
+
News Feed
+
${e.viewMode==="ai"?"AI-grouped summaries from multiple sources":"Latest headlines from multiple sources"}
+
+
+
+ Regular
+ AI Grouped
+
+ ${l}
+ ${e.viewMode==="ai"?'
Clear Cache ':""}
+
+
+
+
+
+ Car Mode
+
+
+
+
+
+
+
+
+ ${n}
+
+
+
+ News aggregated from ABC, NPR, CNN, NBC, CBS & NY Times
+
`}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);
diff --git a/news-app/build/assets/index-DcnxZ66w.js b/news-app/build/assets/index-DcnxZ66w.js
new file mode 100644
index 0000000..d9cae0a
--- /dev/null
+++ b/news-app/build/assets/index-DcnxZ66w.js
@@ -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?``:"",a=t.sources.slice(0,3).map(r=>`${u(r)} `).join(""),s=t.sources.length>3?`+${t.sources.length-3} `:"";return`
+ ${o}
+
+
+
+ ${t.category}
+
+ ${t.articleCount} articles
+ AI Summary
+
+
+ ${t.title}
+
+
+ ${t.summary}
+
+
+ ${a}${s}
+
+
+ View source articles
+
+
+
+ `}function A(t){const o=t.image?``:"";return`
+
+ ${o}
+
+
+
+ ${u(t.source)}
+
+ ${k(t.pubDate)}
+
+
+ ${t.title}
+
+
+ ${M(t.content,500)}
+
+
+
+ `}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..."),``}function N(t){return`
+
+
Failed to load news
+
${t}
+
Try Again
+
`}function y(){return`
+
+
No articles found
+
Try selecting a different source filter.
+
`}function I(t,o,a){const s=t.sources.slice(0,4).map(n=>`${u(n)} `).join(""),r=t.sources.length>4?`+${t.sources.length-4} more `:"";return`
+
+ ${t.image?`
+
+
+
+ `:""}
+
+
+
+ ${t.category}
+
+ ${t.articleCount} articles
+ AI Summary
+
+
+ ${t.title}
+
+
+ ${t.summary}
+
+
+ ${s}${r}
+
+
+
+
+
+
+
+ ${Array.from({length:a},(n,l)=>`
+
+ `).join("")}
+
+ `}function E(){if(e.groups.length===0)return`
+
+
+
+
+
+
+
${e.aiArticleCount>0?`Sending ${e.aiArticleCount} articles to AI for analysis. Please wait patiently...`:"Fetching news from sources..."}
+
+
+
+
+
+
+
+ `;const t=e.groups[e.carModeIndex];return`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ${I(t,e.carModeIndex,e.groups.length)}
+
+
+
+
+
+ `}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"?`
+ All
+ ${a.map(c=>`${w[c]?.name||c} `).join("")}
+ `:"";v.innerHTML=`
+
+
+
+
+
News Feed
+
${e.viewMode==="ai"?"AI-grouped summaries from multiple sources":"Latest headlines from multiple sources"}
+
+
+
+ Regular
+ AI Grouped
+
+ ${l}
+ ${e.viewMode==="ai"?'
Clear Cache ':""}
+
+
+
+
+
+ Car Mode
+
+
+
+
+
+
+
+
+ ${s}
+
+
+
+ News aggregated from ABC, NPR, CNN, NBC, CBS & NY Times
+
`}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);
diff --git a/news-app/build/index.html b/news-app/build/index.html
index dc1df58..e84640a 100644
--- a/news-app/build/index.html
+++ b/news-app/build/index.html
@@ -5,7 +5,7 @@
News Feed - Yahoo & ABC News
-
+
diff --git a/news-app/for_ubuntu.txt b/news-app/for_ubuntu.txt
index 36ca44b..0b523a5 100644
--- a/news-app/for_ubuntu.txt
+++ b/news-app/for_ubuntu.txt
@@ -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
- Current macOS path: /opt/homebrew/var/www/news-app
- On Ubuntu you might use: /var/www/news-app or similar
+ On Ubuntu: /var/www/news-feed-car-mode/news-app
No Changes Needed
- vite.config.js - base: '/carnews/' stays the same
- server.js - Port 5555 works as-is
- - .env file - Copy it over
+ - .env file - copied over
- All source code - Uses relative paths
Deployment Steps
diff --git a/news-app/server.js b/news-app/server.js
index f809109..c13191f 100644
--- a/news-app/server.js
+++ b/news-app/server.js
@@ -225,6 +225,7 @@ app.get('/api/grouped-news', async (req, res) => {
}))
console.log(`Sending ${articlesForAI.length} articles to OpenAI gpt-5-mini...`)
+ const openaiStartTime = Date.now()
const completion = await openai.chat.completions.create({
model: 'gpt-5-mini',
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)
- 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
const enrichedGroups = aiResponse.groups.map((group) => {
diff --git a/news-app/src/main.js b/news-app/src/main.js
index 343e03c..4c65895 100644
--- a/news-app/src/main.js
+++ b/news-app/src/main.js
@@ -1,8 +1,47 @@
import './style.css'
-const API_BASE = '/api'
+const API_BASE = '/carnews/api'
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 = {
articles: [],
groups: [],
@@ -13,6 +52,10 @@ const state = {
carMode: false,
carModeIndex: 0,
carModeInterval: null,
+ aiArticleCount: 0,
+ locked: !getCookie('news_feed_unlocked'),
+ unlockClicks: 0,
+ isTransitioning: false,
}
function formatDate(dateString) {
@@ -133,9 +176,14 @@ function renderArticle(article) {
}
function renderLoading() {
- const message = state.viewMode === 'ai'
- ? 'AI is analyzing and grouping news...'
- : 'Loading latest news...'
+ let message = 'Loading latest news...'
+ if (state.viewMode === 'ai' || state.carMode) {
+ if (state.aiArticleCount > 0) {
+ message = `Sending ${state.aiArticleCount} articles to AI for analysis. Please wait patiently...`
+ } else {
+ message = 'Fetching news from sources...'
+ }
+ }
return `
${message}
@@ -166,45 +214,33 @@ function renderEmpty() {
}
function renderCarModeCard(group, index, total) {
- const sourceBadges = group.sources
- .slice(0, 4)
- .map(s => `
${getSourceName(s)} `)
- .join('')
-
- const moreCount = group.sources.length > 4 ? `
+${group.sources.length - 4} more ` : ''
-
+ const fadeClass = state.isTransitioning ? 'fading-out' : ''
return `
-
+
${group.image ? `
-
+
+
` : ''}
-
-
-
- ${group.category}
-
- ${group.articleCount} articles
- AI Summary
-
-
+
+
${group.title}
-
- ${group.summary}
-
-
- ${sourceBadges}${moreCount}
+
+
-
-
+
${Array.from({ length: total }, (_, i) => `
`).join('')}
@@ -214,6 +250,9 @@ function renderCarModeCard(group, index, total) {
function renderCarMode() {
if (state.groups.length === 0) {
+ const loadingMessage = state.aiArticleCount > 0
+ ? `Sending ${state.aiArticleCount} articles to AI for analysis.
Please wait patiently...`
+ : 'Fetching news from sources...'
return `
@@ -221,9 +260,9 @@ function renderCarMode() {
-
Loading AI grouped news...
+
${loadingMessage}
-
+
@@ -235,48 +274,76 @@ function renderCarMode() {
const currentGroup = state.groups[state.carModeIndex]
return `
-
+
-
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- ${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
-
-
-
-
+
+
+
+
+ ${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
+
+
+
+
`
}
+function renderLockScreen() {
+ return ``
+}
+
+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() {
+ // If locked, render lock screen
+ if (state.locked) {
+ app.innerHTML = renderLockScreen()
+ return
+ }
+
// If car mode is active, render car mode overlay
if (state.carMode) {
app.innerHTML = renderCarMode()
@@ -384,18 +451,32 @@ async function fetchNews() {
async function fetchGroupedNews() {
state.loading = true
state.error = null
+ state.aiArticleCount = 0
render()
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')
const data = await response.json()
state.groups = data.groups
state.loading = false
+ state.aiArticleCount = 0
} catch (error) {
state.error = error.message
state.loading = false
+ state.aiArticleCount = 0
}
render()
@@ -426,18 +507,31 @@ window.refresh = function() {
window.clearCacheAndRefresh = async function() {
state.loading = true
state.error = null
+ state.aiArticleCount = 0
render()
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')
const data = await response.json()
state.groups = data.groups
state.loading = false
+ state.aiArticleCount = 0
} catch (error) {
state.error = error.message
state.loading = false
+ state.aiArticleCount = 0
}
render()
@@ -445,27 +539,52 @@ window.clearCacheAndRefresh = async function() {
window.fetchNews = fetchNews
window.fetchGroupedNews = fetchGroupedNews
+window.handleUnlockClick = handleUnlockClick
// Car Mode Functions
window.enterCarMode = async function() {
state.carMode = true
state.carModeIndex = 0
+ state.aiArticleCount = 0
render()
- // Fetch AI grouped news if not already loaded
- if (state.groups.length === 0) {
- try {
- const response = await fetch(`${API_BASE}/grouped-news`)
- if (!response.ok) throw new Error('Failed to fetch grouped news')
- const data = await response.json()
- state.groups = data.groups
- render()
- } catch (error) {
- console.error('Failed to load grouped news for car mode:', error)
+ // Always check for updates when entering car mode
+ try {
+ // 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')
+
+ 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.aiArticleCount = 0
+ render()
+ } catch (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()
}
@@ -476,31 +595,36 @@ window.exitCarMode = function() {
}
window.carModeNext = function() {
- if (state.groups.length === 0) return
- state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
- // Reset the cycle timer when manually navigating
+ if (state.groups.length === 0 || state.isTransitioning) return
stopCarModeCycle()
- render()
- startCarModeCycle()
+ transitionCard((state.carModeIndex + 1) % state.groups.length)
}
window.carModePrev = function() {
- if (state.groups.length === 0) return
- state.carModeIndex = (state.carModeIndex - 1 + state.groups.length) % state.groups.length
- // Reset the cycle timer when manually navigating
+ if (state.groups.length === 0 || state.isTransitioning) return
stopCarModeCycle()
- render()
- startCarModeCycle()
+ 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()
+ }, 500) // Match animation duration
}
function startCarModeCycle() {
stopCarModeCycle() // Clear any existing interval
state.carModeInterval = setInterval(() => {
- if (state.groups.length > 0) {
- state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
- render()
+ if (state.groups.length > 0 && !state.isTransitioning) {
+ transitionCard((state.carModeIndex + 1) % state.groups.length)
}
- }, 5000) // 5 seconds per card
+ }, 20000) // 20 seconds per card
}
function stopCarModeCycle() {
diff --git a/news-app/src/style.css b/news-app/src/style.css
index fcf4460..3b95284 100644
--- a/news-app/src/style.css
+++ b/news-app/src/style.css
@@ -1,9 +1,15 @@
@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-bg {
- background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
- position: relative;
+ background: #000000;
overflow: hidden;
}
@@ -13,14 +19,14 @@
position: absolute;
border-radius: 50%;
filter: blur(80px);
- opacity: 0.4;
+ opacity: 0.2;
animation: float 20s ease-in-out infinite;
}
.car-mode-bg::before {
width: 600px;
height: 600px;
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+ background: linear-gradient(135deg, #1a1a1a 0%, #000000 100%);
top: -200px;
left: -200px;
animation-delay: 0s;
@@ -29,7 +35,7 @@
.car-mode-bg::after {
width: 500px;
height: 500px;
- background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
+ background: linear-gradient(135deg, #262626 0%, #000000 100%);
bottom: -150px;
right: -150px;
animation-delay: -10s;
@@ -41,14 +47,14 @@
position: absolute;
border-radius: 50%;
filter: blur(100px);
- opacity: 0.3;
+ opacity: 0.15;
animation: float 25s ease-in-out infinite;
}
.car-mode-blur-1 {
width: 400px;
height: 400px;
- background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
+ background: linear-gradient(135deg, #171717 0%, #000000 100%);
top: 50%;
left: 20%;
animation-delay: -5s;
@@ -57,7 +63,7 @@
.car-mode-blur-2 {
width: 350px;
height: 350px;
- background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
+ background: linear-gradient(135deg, #262626 0%, #000000 100%);
top: 30%;
right: 10%;
animation-delay: -15s;
@@ -66,7 +72,7 @@
.car-mode-blur-3 {
width: 300px;
height: 300px;
- background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
+ background: linear-gradient(135deg, #0a0a0a 0%, #000000 100%);
bottom: 20%;
left: 50%;
animation-delay: -8s;
@@ -89,13 +95,17 @@
/* Car Mode Card Transitions */
.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 {
from {
opacity: 0;
- transform: translateY(30px) scale(0.95);
+ transform: translateY(10px) scale(0.98);
}
to {
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 */
.car-mode-progress {
- animation: progressFill 5s linear;
+ animation: progressFill 20s linear;
}
@keyframes progressFill {