api backend
This commit is contained in:
30
news-app/.gitignore
vendored
Normal file
30
news-app/.gitignore
vendored
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Environment variables
|
||||||
|
.env
|
||||||
|
|
||||||
|
# SQLite cache
|
||||||
|
cache.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
80
news-app/How_To_MacOS.txt
Normal file
80
news-app/How_To_MacOS.txt
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
News App - macOS Service Management
|
||||||
|
====================================
|
||||||
|
|
||||||
|
Service: com.je.carnews
|
||||||
|
Port: 5555
|
||||||
|
Plist: /Library/LaunchDaemons/com.je.carnews.plist
|
||||||
|
|
||||||
|
|
||||||
|
BASIC COMMANDS
|
||||||
|
--------------
|
||||||
|
|
||||||
|
Start the service:
|
||||||
|
sudo launchctl kickstart system/com.je.carnews
|
||||||
|
|
||||||
|
Stop the service:
|
||||||
|
sudo launchctl kill SIGTERM system/com.je.carnews
|
||||||
|
|
||||||
|
Restart the service:
|
||||||
|
sudo launchctl kickstart -k system/com.je.carnews
|
||||||
|
|
||||||
|
Check status:
|
||||||
|
sudo launchctl print system/com.je.carnews
|
||||||
|
|
||||||
|
Check if running:
|
||||||
|
lsof -i :5555
|
||||||
|
|
||||||
|
|
||||||
|
ADD TO STARTUP (BOOT)
|
||||||
|
---------------------
|
||||||
|
|
||||||
|
1. Copy the plist to LaunchDaemons:
|
||||||
|
sudo cp /opt/homebrew/var/www/news-app/com.je.carnews.plist /Library/LaunchDaemons/
|
||||||
|
|
||||||
|
2. Load the service:
|
||||||
|
sudo launchctl bootstrap system /Library/LaunchDaemons/com.je.carnews.plist
|
||||||
|
|
||||||
|
3. Start it:
|
||||||
|
sudo launchctl kickstart system/com.je.carnews
|
||||||
|
|
||||||
|
The service will now start automatically on boot.
|
||||||
|
|
||||||
|
|
||||||
|
REMOVE FROM STARTUP (BOOT)
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
1. Stop and unload the service:
|
||||||
|
sudo launchctl bootout system/com.je.carnews
|
||||||
|
|
||||||
|
2. Remove the plist file:
|
||||||
|
sudo rm /Library/LaunchDaemons/com.je.carnews.plist
|
||||||
|
|
||||||
|
The service will no longer start on boot.
|
||||||
|
|
||||||
|
|
||||||
|
VIEW LOGS
|
||||||
|
---------
|
||||||
|
|
||||||
|
Output log:
|
||||||
|
tail -f /opt/homebrew/var/www/news-app/logs/out.log
|
||||||
|
|
||||||
|
Error log:
|
||||||
|
tail -f /opt/homebrew/var/www/news-app/logs/error.log
|
||||||
|
|
||||||
|
|
||||||
|
TROUBLESHOOTING
|
||||||
|
---------------
|
||||||
|
|
||||||
|
If the service fails to start, check:
|
||||||
|
|
||||||
|
1. Node version - the plist uses:
|
||||||
|
/Users/jared/.nvm/versions/node/v22.19.0/bin/node
|
||||||
|
|
||||||
|
2. If you update Node via nvm, rebuild native modules:
|
||||||
|
cd /opt/homebrew/var/www/news-app
|
||||||
|
npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
3. Then update the node path in the plist if needed and reload:
|
||||||
|
sudo launchctl bootout system/com.je.carnews
|
||||||
|
sudo launchctl bootstrap system /Library/LaunchDaemons/com.je.carnews.plist
|
||||||
|
sudo launchctl kickstart system/com.je.carnews
|
||||||
1
news-app/build/assets/index-BcP3wp7F.css
Normal file
1
news-app/build/assets/index-BcP3wp7F.css
Normal file
File diff suppressed because one or more lines are too long
190
news-app/build/assets/index-BvWUS-em.js
Normal file
190
news-app/build/assets/index-BvWUS-em.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
(function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const r of document.querySelectorAll('link[rel="modulepreload"]'))s(r);new MutationObserver(r=>{for(const n of r)if(n.type==="childList")for(const d of n.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&s(d)}).observe(document,{childList:!0,subtree:!0});function l(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=l(r);fetch(r.href,n)}})();const x=document.querySelector("#app"),t={articles:[],groups:[],loading:!0,error:null,filter:"all",viewMode:"regular",carMode:!1,carModeIndex:0,carModeInterval:null};function M(e){const o=new Date(e),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 b(e,o=150){return!e||e.length<=o?e||"":e.slice(0,o).trim()+"..."}const f={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 u(e){return f[e]?.color||"bg-gray-100 text-gray-800"}function c(e){return f[e]?.name||e}const w={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 y(e){return w[e]||w.other}function k(e){const o=e.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${e.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`:"",l=e.sources.slice(0,3).map(r=>`<span class="px-2 py-0.5 text-xs font-medium rounded-full ${u(r)}">${c(r)}</span>`).join(""),s=e.sources.length>3?`<span class="text-xs text-gray-500">+${e.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 ${y(e.category)}">
|
||||||
|
${e.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${e.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">
|
||||||
|
${e.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10] mb-4">
|
||||||
|
${e.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
${l}${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">
|
||||||
|
${e.articles.map(r=>`<li><a href="${r.link}" target="_blank" rel="noopener noreferrer" class="text-gray-600 hover:text-blue-600 hover:underline">${b(r.title,60)} <span class="text-gray-400">(${c(r.source)})</span></a></li>`).join("")}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>`}function C(e){const o=e.image?`<div class="aspect-video overflow-hidden bg-gray-100"><img src="${e.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="${e.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 ${u(e.source)}">
|
||||||
|
${c(e.source)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${M(e.pubDate)}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2 mb-2">
|
||||||
|
${e.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10]">
|
||||||
|
${b(e.content,500)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>`}function $(){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>
|
||||||
|
<p class="text-gray-600">${t.viewMode==="ai"?"AI is analyzing and grouping news...":"Loading latest news..."}</p>
|
||||||
|
</div>`}function j(e){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">${e}</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 v(){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(e,o,l){const s=e.sources.slice(0,4).map(n=>`<span class="px-3 py-1 text-sm font-medium rounded-full ${u(n)} bg-opacity-90">${c(n)}</span>`).join(""),r=e.sources.length>4?`<span class="text-sm text-gray-300">+${e.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">
|
||||||
|
${e.image?`
|
||||||
|
<div class="h-64 sm:h-80 overflow-hidden">
|
||||||
|
<img src="${e.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 ${y(e.category)}">
|
||||||
|
${e.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-gray-500">${e.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">
|
||||||
|
${e.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 leading-relaxed mb-6 line-clamp-6">
|
||||||
|
${e.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:l},(n,d)=>`
|
||||||
|
<div class="w-2 h-2 rounded-full transition-all duration-300 ${d===o?"bg-white w-6":"bg-white/40"}"></div>
|
||||||
|
`).join("")}
|
||||||
|
</div>
|
||||||
|
`}function I(){if(t.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">Loading AI grouped news...</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 e=t.groups[t.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(e,t.carModeIndex,t.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(t.carMode){x.innerHTML=I();return}const e=t.filter==="all"?t.articles:t.articles.filter(i=>i.source===t.filter),o=t.loading?"animate-spin":"",l=[...new Set(t.articles.map(i=>i.source))];let s="";t.loading?s=$():t.error?s=j(t.error):t.viewMode==="ai"?t.groups.length===0?s=v():s=t.groups.map(k).join(""):e.length===0?s=v():s=e.map(C).join("");const r=t.viewMode==="regular"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200",n=t.viewMode==="ai"?"bg-blue-600 text-white":"bg-blue-100 text-blue-700 hover:bg-blue-200",d=t.viewMode==="regular"?`
|
||||||
|
<button onclick="setFilter('all')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${t.filter==="all"?"bg-gray-900 text-white":"bg-gray-100 text-gray-700 hover:bg-gray-200"}">All</button>
|
||||||
|
${l.map(i=>`<button onclick="setFilter('${i}')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${t.filter===i?"bg-gray-900 text-white":u(i)}">${f[i]?.name||i}</button>`).join("")}
|
||||||
|
`:"";x.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">${t.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>
|
||||||
|
${d}
|
||||||
|
${t.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 g(){t.loading=!0,t.error=null,a();try{const e=await fetch("/api/news");if(!e.ok)throw new Error("Failed to fetch news");const o=await e.json(),l=o.feeds.flatMap(s=>s.items).sort((s,r)=>new Date(r.pubDate)-new Date(s.pubDate));t.articles=l,t.loading=!1,o.errors&&o.errors.length>0&&console.warn("Some feeds failed:",o.errors)}catch(e){t.error=e.message,t.loading=!1}a()}async function p(){t.loading=!0,t.error=null,a();try{const e=await fetch("/api/grouped-news");if(!e.ok)throw new Error("Failed to fetch grouped news");const o=await e.json();t.groups=o.groups,t.loading=!1}catch(e){t.error=e.message,t.loading=!1}a()}window.setFilter=function(e){t.filter=e,a()};window.setViewMode=function(e){t.viewMode=e,e==="ai"&&t.groups.length===0?p():a()};window.refresh=function(){t.viewMode==="ai"?p():g()};window.clearCacheAndRefresh=async function(){t.loading=!0,t.error=null,a();try{const e=await fetch("/api/grouped-news?refresh=true");if(!e.ok)throw new Error("Failed to fetch grouped news");const o=await e.json();t.groups=o.groups,t.loading=!1}catch(e){t.error=e.message,t.loading=!1}a()};window.fetchNews=g;window.fetchGroupedNews=p;window.enterCarMode=async function(){if(t.carMode=!0,t.carModeIndex=0,a(),t.groups.length===0)try{const e=await fetch("/api/grouped-news");if(!e.ok)throw new Error("Failed to fetch grouped news");const o=await e.json();t.groups=o.groups,a()}catch(e){console.error("Failed to load grouped news for car mode:",e)}h()};window.exitCarMode=function(){t.carMode=!1,m(),a()};window.carModeNext=function(){t.groups.length!==0&&(t.carModeIndex=(t.carModeIndex+1)%t.groups.length,m(),a(),h())};window.carModePrev=function(){t.groups.length!==0&&(t.carModeIndex=(t.carModeIndex-1+t.groups.length)%t.groups.length,m(),a(),h())};function h(){m(),t.carModeInterval=setInterval(()=>{t.groups.length>0&&(t.carModeIndex=(t.carModeIndex+1)%t.groups.length,a())},5e3)}function m(){t.carModeInterval&&(clearInterval(t.carModeInterval),t.carModeInterval=null)}document.addEventListener("keydown",e=>{if(t.carMode)switch(e.key){case"Escape":window.exitCarMode();break;case"ArrowRight":case" ":window.carModeNext();break;case"ArrowLeft":window.carModePrev();break}});g();setInterval(()=>{t.viewMode==="ai"?p():g()},300*1e3);
|
||||||
14
news-app/build/index.html
Normal file
14
news-app/build/index.html
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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" />
|
||||||
|
<title>News Feed - Yahoo & ABC News</title>
|
||||||
|
<script type="module" crossorigin src="/carnews/assets/index-BvWUS-em.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/carnews/assets/index-BcP3wp7F.css">
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
news-app/build/vite.svg
Normal file
1
news-app/build/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
34
news-app/com.je.carnews.plist
Normal file
34
news-app/com.je.carnews.plist
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>com.je.carnews</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/Users/jared/.nvm/versions/node/v22.19.0/bin/node</string>
|
||||||
|
<string>/opt/homebrew/var/www/news-app/server.js</string>
|
||||||
|
</array>
|
||||||
|
<key>WorkingDirectory</key>
|
||||||
|
<string>/opt/homebrew/var/www/news-app</string>
|
||||||
|
<key>UserName</key>
|
||||||
|
<string>jared</string>
|
||||||
|
<key>GroupName</key>
|
||||||
|
<string>staff</string>
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/opt/homebrew/var/www/news-app/logs/out.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/opt/homebrew/var/www/news-app/logs/error.log</string>
|
||||||
|
<key>EnvironmentVariables</key>
|
||||||
|
<dict>
|
||||||
|
<key>PATH</key>
|
||||||
|
<string>/opt/homebrew/bin:/usr/bin:/bin</string>
|
||||||
|
<key>OPENAI_API_KEY</key>
|
||||||
|
<string>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</string>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
62
news-app/for_ubuntu.txt
Normal file
62
news-app/for_ubuntu.txt
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
Based on the codebase analysis, here's what needs to change for your Ubuntu deployment:
|
||||||
|
|
||||||
|
Required Changes
|
||||||
|
|
||||||
|
Create a systemd service file instead:
|
||||||
|
|
||||||
|
# /etc/systemd/system/carnews.service
|
||||||
|
[Unit]
|
||||||
|
Description=Car News API Server
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=<your-ubuntu-user>
|
||||||
|
WorkingDirectory=/path/to/news-app
|
||||||
|
ExecStart=/usr/bin/node server.js
|
||||||
|
Restart=on-failure
|
||||||
|
EnvironmentFile=/path/to/news-app/.env
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
||||||
|
2. Rebuild native modules
|
||||||
|
|
||||||
|
The better-sqlite3 package requires recompilation on Ubuntu:
|
||||||
|
npm rebuild better-sqlite3
|
||||||
|
|
||||||
|
3. Nginx configuration for Ubuntu
|
||||||
|
|
||||||
|
You'll need to create an Nginx config (not included in this repo). Example:
|
||||||
|
|
||||||
|
location /carnews/ {
|
||||||
|
alias /path/to/news-app/dist/;
|
||||||
|
try_files $uri $uri/ /carnews/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /carnews/api/ {
|
||||||
|
proxy_pass http://localhost:5555/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
No Changes Needed
|
||||||
|
|
||||||
|
- vite.config.js - base: '/carnews/' stays the same
|
||||||
|
- server.js - Port 5555 works as-is
|
||||||
|
- .env file - Copy it over
|
||||||
|
- All source code - Uses relative paths
|
||||||
|
|
||||||
|
Deployment Steps
|
||||||
|
|
||||||
|
1. Copy the codebase to Ubuntu
|
||||||
|
2. Run npm install and npm rebuild better-sqlite3
|
||||||
|
3. Run npm run build to generate fresh /dist files
|
||||||
|
4. Create the systemd service file
|
||||||
|
5. Configure Nginx with the proxy rules
|
||||||
|
6. Enable and start the service: sudo systemctl enable --now carnews
|
||||||
13
news-app/index.html
Normal file
13
news-app/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<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" />
|
||||||
|
<title>News Feed - Yahoo & ABC News</title>
|
||||||
|
</head>
|
||||||
|
<body class="antialiased">
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3289
news-app/package-lock.json
generated
Normal file
3289
news-app/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
news-app/package.json
Normal file
27
news-app/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "news-app",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "node server.js & vite",
|
||||||
|
"server": "node server.js",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
|
"autoprefixer": "^10.4.23",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"vite": "^7.2.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.6.2",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"openai": "^6.16.0",
|
||||||
|
"rss-parser": "^3.13.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
news-app/public/vite.svg
Normal file
1
news-app/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
324
news-app/server.js
Normal file
324
news-app/server.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
import 'dotenv/config'
|
||||||
|
import express from 'express'
|
||||||
|
import cors from 'cors'
|
||||||
|
import Parser from 'rss-parser'
|
||||||
|
import OpenAI from 'openai'
|
||||||
|
import Database from 'better-sqlite3'
|
||||||
|
|
||||||
|
const app = express()
|
||||||
|
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })
|
||||||
|
const parser = new Parser()
|
||||||
|
const PORT = 5555
|
||||||
|
|
||||||
|
// Initialize SQLite database for caching
|
||||||
|
const db = new Database('cache.db')
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS grouped_news_cache (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
|
||||||
|
const CACHE_DURATION_MS = 3 * 60 * 60 * 1000 // 3 hours
|
||||||
|
|
||||||
|
function getCachedGroupedNews() {
|
||||||
|
const row = db.prepare('SELECT data, created_at FROM grouped_news_cache WHERE id = 1').get()
|
||||||
|
if (!row) return null
|
||||||
|
|
||||||
|
const age = Date.now() - row.created_at
|
||||||
|
if (age > CACHE_DURATION_MS) return null
|
||||||
|
|
||||||
|
return { data: JSON.parse(row.data), age }
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCachedGroupedNews(data) {
|
||||||
|
const stmt = db.prepare('INSERT OR REPLACE INTO grouped_news_cache (id, data, created_at) VALUES (1, ?, ?)')
|
||||||
|
stmt.run(JSON.stringify(data), Date.now())
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearCache() {
|
||||||
|
db.prepare('DELETE FROM grouped_news_cache').run()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source names to filter out from AI summaries
|
||||||
|
const SOURCE_NAMES = [
|
||||||
|
'ABC News', 'ABC', 'NPR', 'CNN', 'Reuters', 'NBC News', 'NBC',
|
||||||
|
'CBS News', 'CBS', 'NY Times', 'New York Times', 'NYT', 'AP News',
|
||||||
|
'Associated Press', 'AP', 'BBC', 'Guardian', 'The Guardian'
|
||||||
|
]
|
||||||
|
|
||||||
|
function replaceSourceNames(text) {
|
||||||
|
if (!text) return text
|
||||||
|
let result = text
|
||||||
|
// Sort by length descending to replace longer names first (e.g., "New York Times" before "NY")
|
||||||
|
const sortedNames = [...SOURCE_NAMES].sort((a, b) => b.length - a.length)
|
||||||
|
for (const name of sortedNames) {
|
||||||
|
// Use word boundaries to only match whole words, not parts of other words
|
||||||
|
const regex = new RegExp(`\\b${name}\\b`, 'gi')
|
||||||
|
result = result.replace(regex, '[news]')
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeGroups(groups) {
|
||||||
|
return groups.map(group => {
|
||||||
|
const newTitle = replaceSourceNames(group.title)
|
||||||
|
const newSummary = replaceSourceNames(group.summary)
|
||||||
|
if (newTitle !== group.title || newSummary !== group.summary) {
|
||||||
|
console.log(`Replaced source names in group: "${group.title}"`)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
title: newTitle,
|
||||||
|
summary: newSummary,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
app.use(cors())
|
||||||
|
|
||||||
|
const RSS_FEEDS = {
|
||||||
|
abc: 'https://abcnews.go.com/abcnews/topstories',
|
||||||
|
npr: 'https://feeds.npr.org/1001/rss.xml',
|
||||||
|
cnn: 'http://rss.cnn.com/rss/cnn_topstories.rss',
|
||||||
|
nbc: 'https://feeds.nbcnews.com/nbcnews/public/news',
|
||||||
|
cbs: 'https://www.cbsnews.com/latest/rss/main',
|
||||||
|
nytimes: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml',
|
||||||
|
}
|
||||||
|
|
||||||
|
app.get('/api/news', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Object.entries(RSS_FEEDS).map(async ([source, url]) => {
|
||||||
|
const feed = await parser.parseURL(url)
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
title: feed.title,
|
||||||
|
items: feed.items.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
link: item.link,
|
||||||
|
pubDate: item.pubDate,
|
||||||
|
content: item.contentSnippet || item.content || '',
|
||||||
|
source,
|
||||||
|
image: extractImage(item),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const feeds = results
|
||||||
|
.filter((r) => r.status === 'fulfilled')
|
||||||
|
.map((r) => r.value)
|
||||||
|
|
||||||
|
const errors = results
|
||||||
|
.filter((r) => r.status === 'rejected')
|
||||||
|
.map((r, i) => ({ source: Object.keys(RSS_FEEDS)[i], error: r.reason.message }))
|
||||||
|
|
||||||
|
res.json({ feeds, errors })
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Endpoint to clear the cache
|
||||||
|
app.post('/api/clear-cache', (req, res) => {
|
||||||
|
clearCache()
|
||||||
|
console.log('Cache cleared by user')
|
||||||
|
res.json({ success: true, message: 'Cache cleared' })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/grouped-news', async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Check if user wants to force refresh
|
||||||
|
const forceRefresh = req.query.refresh === 'true'
|
||||||
|
|
||||||
|
if (forceRefresh) {
|
||||||
|
clearCache()
|
||||||
|
console.log('Force refresh requested - cache cleared')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cached = getCachedGroupedNews()
|
||||||
|
if (cached) {
|
||||||
|
const remainingMs = CACHE_DURATION_MS - cached.age
|
||||||
|
const remainingMins = Math.round(remainingMs / 60000)
|
||||||
|
console.log(`Serving cached grouped news (${remainingMins} minutes until refresh)`)
|
||||||
|
return res.json({ groups: cached.data, cached: true, cacheExpiresIn: remainingMins })
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Cache miss - fetching RSS feeds...')
|
||||||
|
|
||||||
|
// Fetch all news first
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Object.entries(RSS_FEEDS).map(async ([source, url]) => {
|
||||||
|
console.log(` Fetching ${source}...`)
|
||||||
|
try {
|
||||||
|
const feed = await parser.parseURL(url)
|
||||||
|
console.log(` ✓ ${source}: ${feed.items.length} articles`)
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
items: feed.items.map((item) => ({
|
||||||
|
title: item.title,
|
||||||
|
link: item.link,
|
||||||
|
pubDate: item.pubDate,
|
||||||
|
content: item.contentSnippet || item.content || '',
|
||||||
|
source,
|
||||||
|
image: extractImage(item),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log(` ✗ ${source}: ${err.message}`)
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const feedResults = results
|
||||||
|
.filter((r) => r.status === 'fulfilled')
|
||||||
|
.map((r) => r.value)
|
||||||
|
|
||||||
|
// Ensure at least 5 articles from each source, then fill rest by date
|
||||||
|
const MIN_PER_SOURCE = 5
|
||||||
|
const TOTAL_LIMIT = 50
|
||||||
|
|
||||||
|
let selectedArticles = []
|
||||||
|
const usedIds = new Set()
|
||||||
|
|
||||||
|
// First pass: take up to MIN_PER_SOURCE from each source (sorted by date)
|
||||||
|
for (const feed of feedResults) {
|
||||||
|
const sorted = [...feed.items].sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
const toTake = sorted.slice(0, MIN_PER_SOURCE)
|
||||||
|
for (const article of toTake) {
|
||||||
|
const id = article.link
|
||||||
|
if (!usedIds.has(id)) {
|
||||||
|
usedIds.add(id)
|
||||||
|
selectedArticles.push(article)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second pass: fill remaining slots with newest articles across all sources
|
||||||
|
const allRemaining = feedResults
|
||||||
|
.flatMap((f) => f.items)
|
||||||
|
.filter((a) => !usedIds.has(a.link))
|
||||||
|
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
|
||||||
|
const remaining = TOTAL_LIMIT - selectedArticles.length
|
||||||
|
selectedArticles.push(...allRemaining.slice(0, remaining))
|
||||||
|
|
||||||
|
// Final sort by date
|
||||||
|
const allArticles = selectedArticles.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
|
||||||
|
console.log(`Selected ${allArticles.length} articles (min ${MIN_PER_SOURCE}/source, then by date)`)
|
||||||
|
|
||||||
|
if (allArticles.length === 0) {
|
||||||
|
return res.json({ groups: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send to OpenAI for grouping
|
||||||
|
const articlesForAI = allArticles.map((a, i) => ({
|
||||||
|
id: i,
|
||||||
|
title: a.title,
|
||||||
|
content: a.content?.slice(0, 200) || '',
|
||||||
|
source: a.source,
|
||||||
|
}))
|
||||||
|
|
||||||
|
console.log(`Sending ${articlesForAI.length} articles to OpenAI gpt-5-mini...`)
|
||||||
|
const completion = await openai.chat.completions.create({
|
||||||
|
model: 'gpt-5-mini',
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'system',
|
||||||
|
content: `You are a news analyst. Group articles that cover THE SAME SPECIFIC NEWS STORY together.
|
||||||
|
|
||||||
|
IMPORTANT RULES:
|
||||||
|
- Each group must contain articles about ONE specific news event or story
|
||||||
|
- Do NOT combine unrelated topics into a single group
|
||||||
|
- Do NOT create broad category groups (e.g., "Various Political News")
|
||||||
|
- Articles about different events should be in SEPARATE groups, even if they share a category
|
||||||
|
- It's better to have more specific groups than fewer broad ones
|
||||||
|
- If an article doesn't match any group, put it in its own single-article group
|
||||||
|
|
||||||
|
WRITING RULES:
|
||||||
|
- Write ORIGINAL titles - do NOT copy or closely paraphrase headlines from the source articles
|
||||||
|
- Write ORIGINAL summaries in your own words - do NOT copy sentences from the articles
|
||||||
|
- Synthesize information from multiple sources into a fresh, unique narrative
|
||||||
|
- Use different phrasing and sentence structure than the originals
|
||||||
|
- Never mention the news source names (ABC, CNN, NPR, etc.) in titles or summaries
|
||||||
|
|
||||||
|
Return JSON in this exact format:
|
||||||
|
{
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"title": "Your original headline for this story (max 80 chars)",
|
||||||
|
"summary": "Your original summary synthesizing the story in your own words (max 500 chars)",
|
||||||
|
"articleIds": [0, 1, 2],
|
||||||
|
"category": "politics|business|technology|sports|entertainment|health|science|world|other"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
Only return valid JSON.`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: JSON.stringify(articlesForAI)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const aiResponse = JSON.parse(completion.choices[0].message.content)
|
||||||
|
console.log(`✓ OpenAI returned ${aiResponse.groups.length} groups`)
|
||||||
|
|
||||||
|
// Enrich groups with source articles and images
|
||||||
|
const enrichedGroups = aiResponse.groups.map((group) => {
|
||||||
|
const groupArticles = group.articleIds
|
||||||
|
.map((id) => allArticles[id])
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const images = groupArticles
|
||||||
|
.map((a) => a.image)
|
||||||
|
.filter(Boolean)
|
||||||
|
|
||||||
|
const sources = [...new Set(groupArticles.map((a) => a.source))]
|
||||||
|
const links = groupArticles.map((a) => ({ title: a.title, link: a.link, source: a.source }))
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: group.title,
|
||||||
|
summary: group.summary,
|
||||||
|
category: group.category,
|
||||||
|
image: images[0] || null,
|
||||||
|
sources,
|
||||||
|
articles: links,
|
||||||
|
articleCount: groupArticles.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Replace any source name mentions with [news]
|
||||||
|
const sanitizedGroups = sanitizeGroups(enrichedGroups)
|
||||||
|
|
||||||
|
// Cache the results
|
||||||
|
setCachedGroupedNews(sanitizedGroups)
|
||||||
|
console.log(`Cached ${sanitizedGroups.length} grouped news for 3 hours`)
|
||||||
|
|
||||||
|
res.json({ groups: sanitizedGroups, cached: false })
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Grouped news error:', error)
|
||||||
|
res.status(500).json({ error: error.message })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function extractImage(item) {
|
||||||
|
if (item.enclosure?.url) return item.enclosure.url
|
||||||
|
if (item['media:content']?.$.url) return item['media:content'].$.url
|
||||||
|
if (item['media:thumbnail']?.$.url) return item['media:thumbnail'].$.url
|
||||||
|
|
||||||
|
const contentMatch = (item.content || '').match(/<img[^>]+src="([^"]+)"/)
|
||||||
|
if (contentMatch) return contentMatch[1]
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`API server running on http://localhost:${PORT}`)
|
||||||
|
})
|
||||||
9
news-app/src/counter.js
Normal file
9
news-app/src/counter.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
export function setupCounter(element) {
|
||||||
|
let counter = 0
|
||||||
|
const setCounter = (count) => {
|
||||||
|
counter = count
|
||||||
|
element.innerHTML = `count is ${counter}`
|
||||||
|
}
|
||||||
|
element.addEventListener('click', () => setCounter(counter + 1))
|
||||||
|
setCounter(0)
|
||||||
|
}
|
||||||
1
news-app/src/javascript.svg
Normal file
1
news-app/src/javascript.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="32" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"></path><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"></path></svg>
|
||||||
|
After Width: | Height: | Size: 995 B |
539
news-app/src/main.js
Normal file
539
news-app/src/main.js
Normal file
@@ -0,0 +1,539 @@
|
|||||||
|
import './style.css'
|
||||||
|
|
||||||
|
const API_BASE = import.meta.env.BASE_URL + 'api'
|
||||||
|
const app = document.querySelector('#app')
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
articles: [],
|
||||||
|
groups: [],
|
||||||
|
loading: true,
|
||||||
|
error: null,
|
||||||
|
filter: 'all',
|
||||||
|
viewMode: 'regular', // 'regular' or 'ai'
|
||||||
|
carMode: false,
|
||||||
|
carModeIndex: 0,
|
||||||
|
carModeInterval: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString) {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now - date
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
|
||||||
|
if (minutes < 60) return minutes + 'm ago'
|
||||||
|
if (hours < 24) return hours + 'h ago'
|
||||||
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(text, maxLength = 150) {
|
||||||
|
if (!text || text.length <= maxLength) return text || ''
|
||||||
|
return text.slice(0, maxLength).trim() + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
const SOURCE_CONFIG = {
|
||||||
|
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 getSourceColor(source) {
|
||||||
|
return SOURCE_CONFIG[source]?.color || 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceName(source) {
|
||||||
|
return SOURCE_CONFIG[source]?.name || source
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_COLORS = {
|
||||||
|
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 getCategoryColor(category) {
|
||||||
|
return CATEGORY_COLORS[category] || CATEGORY_COLORS.other
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupCard(group) {
|
||||||
|
const imageHtml = group.image
|
||||||
|
? `<div class="aspect-video overflow-hidden bg-gray-100"><img src="${group.image}" alt="" class="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300" onerror="this.parentElement.style.display='none'" /></div>`
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const sourceBadges = group.sources
|
||||||
|
.slice(0, 3)
|
||||||
|
.map(s => `<span class="px-2 py-0.5 text-xs font-medium rounded-full ${getSourceColor(s)}">${getSourceName(s)}</span>`)
|
||||||
|
.join('')
|
||||||
|
|
||||||
|
const moreCount = group.sources.length > 3 ? `<span class="text-xs text-gray-500">+${group.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">
|
||||||
|
${imageHtml}
|
||||||
|
<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 ${getCategoryColor(group.category)}">
|
||||||
|
${group.category}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${group.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">
|
||||||
|
${group.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10] mb-4">
|
||||||
|
${group.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap mb-3">
|
||||||
|
${sourceBadges}${moreCount}
|
||||||
|
</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">
|
||||||
|
${group.articles.map(a => `<li><a href="${a.link}" target="_blank" rel="noopener noreferrer" class="text-gray-600 hover:text-blue-600 hover:underline">${truncate(a.title, 60)} <span class="text-gray-400">(${getSourceName(a.source)})</span></a></li>`).join('')}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</article>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderArticle(article) {
|
||||||
|
const imageHtml = article.image
|
||||||
|
? `<div class="aspect-video overflow-hidden bg-gray-100"><img src="${article.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="${article.link}" target="_blank" rel="noopener noreferrer" class="block">
|
||||||
|
${imageHtml}
|
||||||
|
<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 ${getSourceColor(article.source)}">
|
||||||
|
${getSourceName(article.source)}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-gray-500">${formatDate(article.pubDate)}</span>
|
||||||
|
</div>
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors line-clamp-2 mb-2">
|
||||||
|
${article.title}
|
||||||
|
</h2>
|
||||||
|
<p class="text-sm text-gray-600 line-clamp-[10]">
|
||||||
|
${truncate(article.content, 500)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</article>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLoading() {
|
||||||
|
const message = state.viewMode === 'ai'
|
||||||
|
? 'AI is analyzing and grouping news...'
|
||||||
|
: 'Loading latest news...'
|
||||||
|
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>
|
||||||
|
<p class="text-gray-600">${message}</p>
|
||||||
|
</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderError(error) {
|
||||||
|
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">${error}</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 renderEmpty() {
|
||||||
|
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 renderCarModeCard(group, index, total) {
|
||||||
|
const sourceBadges = group.sources
|
||||||
|
.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 `
|
||||||
|
<div class="car-mode-card max-w-4xl w-full bg-white/95 backdrop-blur-sm rounded-3xl shadow-2xl overflow-hidden">
|
||||||
|
${group.image ? `
|
||||||
|
<div class="h-64 sm:h-80 overflow-hidden">
|
||||||
|
<img src="${group.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 ${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}
|
||||||
|
</h2>
|
||||||
|
<p class="text-lg text-gray-600 leading-relaxed mb-6 line-clamp-6">
|
||||||
|
${group.summary}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
|
${sourceBadges}${moreCount}
|
||||||
|
</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: total }, (_, i) => `
|
||||||
|
<div class="w-2 h-2 rounded-full transition-all duration-300 ${i === index ? 'bg-white w-6' : 'bg-white/40'}"></div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCarMode() {
|
||||||
|
if (state.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">Loading AI grouped news...</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 currentGroup = state.groups[state.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">
|
||||||
|
${renderCarModeCard(currentGroup, state.carModeIndex, state.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 render() {
|
||||||
|
// If car mode is active, render car mode overlay
|
||||||
|
if (state.carMode) {
|
||||||
|
app.innerHTML = renderCarMode()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredArticles = state.filter === 'all'
|
||||||
|
? state.articles
|
||||||
|
: state.articles.filter(a => a.source === state.filter)
|
||||||
|
|
||||||
|
const spinClass = state.loading ? 'animate-spin' : ''
|
||||||
|
const sources = [...new Set(state.articles.map(a => a.source))]
|
||||||
|
|
||||||
|
let content = ''
|
||||||
|
if (state.loading) {
|
||||||
|
content = renderLoading()
|
||||||
|
} else if (state.error) {
|
||||||
|
content = renderError(state.error)
|
||||||
|
} else if (state.viewMode === 'ai') {
|
||||||
|
if (state.groups.length === 0) {
|
||||||
|
content = renderEmpty()
|
||||||
|
} else {
|
||||||
|
content = state.groups.map(renderGroupCard).join('')
|
||||||
|
}
|
||||||
|
} else if (filteredArticles.length === 0) {
|
||||||
|
content = renderEmpty()
|
||||||
|
} else {
|
||||||
|
content = filteredArticles.map(renderArticle).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularBtnClass = state.viewMode === 'regular' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||||
|
const aiBtnClass = state.viewMode === 'ai' ? 'bg-blue-600 text-white' : 'bg-blue-100 text-blue-700 hover:bg-blue-200'
|
||||||
|
|
||||||
|
const filterButtons = state.viewMode === 'regular' ? `
|
||||||
|
<button onclick="setFilter('all')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${state.filter === 'all' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'}">All</button>
|
||||||
|
${sources.map(source => `<button onclick="setFilter('${source}')" class="px-3 py-1.5 text-sm font-medium rounded-lg transition-colors ${state.filter === source ? 'bg-gray-900 text-white' : getSourceColor(source)}">${SOURCE_CONFIG[source]?.name || source}</button>`).join('')}
|
||||||
|
` : ''
|
||||||
|
|
||||||
|
app.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">${state.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 ${regularBtnClass}">Regular</button>
|
||||||
|
<button onclick="setViewMode('ai')" class="px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${aiBtnClass}">AI Grouped</button>
|
||||||
|
</div>
|
||||||
|
${filterButtons}
|
||||||
|
${state.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 ${spinClass}" 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">
|
||||||
|
${content}
|
||||||
|
</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 fetchNews() {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
render()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/news`)
|
||||||
|
if (!response.ok) throw new Error('Failed to fetch news')
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
|
||||||
|
const allArticles = data.feeds
|
||||||
|
.flatMap(feed => feed.items)
|
||||||
|
.sort((a, b) => new Date(b.pubDate) - new Date(a.pubDate))
|
||||||
|
|
||||||
|
state.articles = allArticles
|
||||||
|
state.loading = false
|
||||||
|
|
||||||
|
if (data.errors && data.errors.length > 0) {
|
||||||
|
console.warn('Some feeds failed:', data.errors)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error.message
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGroupedNews() {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
render()
|
||||||
|
|
||||||
|
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
|
||||||
|
state.loading = false
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error.message
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setFilter = function(filter) {
|
||||||
|
state.filter = filter
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setViewMode = function(mode) {
|
||||||
|
state.viewMode = mode
|
||||||
|
if (mode === 'ai' && state.groups.length === 0) {
|
||||||
|
fetchGroupedNews()
|
||||||
|
} else {
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.refresh = function() {
|
||||||
|
if (state.viewMode === 'ai') {
|
||||||
|
fetchGroupedNews()
|
||||||
|
} else {
|
||||||
|
fetchNews()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.clearCacheAndRefresh = async function() {
|
||||||
|
state.loading = true
|
||||||
|
state.error = null
|
||||||
|
render()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${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
|
||||||
|
} catch (error) {
|
||||||
|
state.error = error.message
|
||||||
|
state.loading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.fetchNews = fetchNews
|
||||||
|
window.fetchGroupedNews = fetchGroupedNews
|
||||||
|
|
||||||
|
// Car Mode Functions
|
||||||
|
window.enterCarMode = async function() {
|
||||||
|
state.carMode = true
|
||||||
|
state.carModeIndex = 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start auto-cycling every 5 seconds
|
||||||
|
startCarModeCycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.exitCarMode = function() {
|
||||||
|
state.carMode = false
|
||||||
|
stopCarModeCycle()
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.carModeNext = function() {
|
||||||
|
if (state.groups.length === 0) return
|
||||||
|
state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
|
||||||
|
// Reset the cycle timer when manually navigating
|
||||||
|
stopCarModeCycle()
|
||||||
|
render()
|
||||||
|
startCarModeCycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
stopCarModeCycle()
|
||||||
|
render()
|
||||||
|
startCarModeCycle()
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCarModeCycle() {
|
||||||
|
stopCarModeCycle() // Clear any existing interval
|
||||||
|
state.carModeInterval = setInterval(() => {
|
||||||
|
if (state.groups.length > 0) {
|
||||||
|
state.carModeIndex = (state.carModeIndex + 1) % state.groups.length
|
||||||
|
render()
|
||||||
|
}
|
||||||
|
}, 5000) // 5 seconds per card
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCarModeCycle() {
|
||||||
|
if (state.carModeInterval) {
|
||||||
|
clearInterval(state.carModeInterval)
|
||||||
|
state.carModeInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keyboard support for car mode
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (!state.carMode) return
|
||||||
|
|
||||||
|
switch (e.key) {
|
||||||
|
case 'Escape':
|
||||||
|
window.exitCarMode()
|
||||||
|
break
|
||||||
|
case 'ArrowRight':
|
||||||
|
case ' ':
|
||||||
|
window.carModeNext()
|
||||||
|
break
|
||||||
|
case 'ArrowLeft':
|
||||||
|
window.carModePrev()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
fetchNews()
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
if (state.viewMode === 'ai') {
|
||||||
|
fetchGroupedNews()
|
||||||
|
} else {
|
||||||
|
fetchNews()
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
118
news-app/src/style.css
Normal file
118
news-app/src/style.css
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
|
||||||
|
/* Car Mode Animated Background */
|
||||||
|
.car-mode-bg {
|
||||||
|
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-bg::before,
|
||||||
|
.car-mode-bg::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(80px);
|
||||||
|
opacity: 0.4;
|
||||||
|
animation: float 20s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-bg::before {
|
||||||
|
width: 600px;
|
||||||
|
height: 600px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
top: -200px;
|
||||||
|
left: -200px;
|
||||||
|
animation-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-bg::after {
|
||||||
|
width: 500px;
|
||||||
|
height: 500px;
|
||||||
|
background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%);
|
||||||
|
bottom: -150px;
|
||||||
|
right: -150px;
|
||||||
|
animation-delay: -10s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-blur-1,
|
||||||
|
.car-mode-blur-2,
|
||||||
|
.car-mode-blur-3 {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(100px);
|
||||||
|
opacity: 0.3;
|
||||||
|
animation: float 25s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-blur-1 {
|
||||||
|
width: 400px;
|
||||||
|
height: 400px;
|
||||||
|
background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%);
|
||||||
|
top: 50%;
|
||||||
|
left: 20%;
|
||||||
|
animation-delay: -5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-blur-2 {
|
||||||
|
width: 350px;
|
||||||
|
height: 350px;
|
||||||
|
background: linear-gradient(135deg, #43e97b 0%, #38f9d7 100%);
|
||||||
|
top: 30%;
|
||||||
|
right: 10%;
|
||||||
|
animation-delay: -15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.car-mode-blur-3 {
|
||||||
|
width: 300px;
|
||||||
|
height: 300px;
|
||||||
|
background: linear-gradient(135deg, #fa709a 0%, #fee140 100%);
|
||||||
|
bottom: 20%;
|
||||||
|
left: 50%;
|
||||||
|
animation-delay: -8s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes float {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate(0, 0) scale(1);
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translate(50px, -30px) scale(1.1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate(-20px, 40px) scale(0.95);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translate(-40px, -20px) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Car Mode Card Transitions */
|
||||||
|
.car-mode-card {
|
||||||
|
animation: cardFadeIn 0.8s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cardFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px) scale(0.95);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Progress bar animation */
|
||||||
|
.car-mode-progress {
|
||||||
|
animation: progressFill 5s linear;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes progressFill {
|
||||||
|
from {
|
||||||
|
width: 0%;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
15
news-app/vite.config.js
Normal file
15
news-app/vite.config.js
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
base: '/carnews/',
|
||||||
|
plugins: [tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:5555',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user