Files
news-feed-car-mode/news-app/src/main.js
jared 8adbee6e4f Improve car mode UI and fix glow animation
- Add news counter showing "News 1 / 25" next to Car Mode indicator
- Replace truck icon with sedan-style car icon
- Refactor glow animations to use CSS custom properties
- Fix double-glow issue by using single animation with variable colors

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 18:04:39 +00:00

716 lines
26 KiB
JavaScript

import './style.css'
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: [],
loading: true,
error: null,
filter: 'all',
viewMode: 'regular', // 'regular' or 'ai'
carMode: false,
carModeIndex: 0,
carModeInterval: null,
carModeScrollTimeout: null,
carModeScrollInterval: null,
aiArticleCount: 0,
locked: !getCookie('news_feed_unlocked'),
unlockClicks: 0,
isTransitioning: false,
}
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() {
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 `<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 fadeClass = state.isTransitioning ? 'fading-out' : ''
// Determine glow color based on position from end
// Last 2 cards: red glow
// 3 cards before the last 2 (3rd, 4th, 5th from end): orange glow
// Others: yellow glow (default via CSS variables)
let glowClass = ''
if (index >= total - 2) {
glowClass = 'glow-red'
} else if (index >= total - 5) {
glowClass = 'glow-orange'
}
return `
<div class="car-mode-card ${fadeClass} ${glowClass} w-full max-w-[350px] sm:max-w-3xl bg-black/90 backdrop-blur-md rounded-[2rem] shadow-2xl overflow-hidden flex flex-col max-h-[92vh] sm:max-h-[92vh] transition-all duration-500 mb-2 border border-white/10">
${group.image ? `
<div class="h-48 sm:h-80 shrink-0 overflow-hidden relative group-image-container">
<img src="${group.image}" alt="" class="w-full h-full object-cover" onerror="this.parentElement.style.display='none'" />
<div class="absolute inset-0 bg-gradient-to-t from-black/80 via-transparent to-transparent"></div>
</div>
` : ''}
<div class="p-6 sm:p-10 flex flex-col flex-1 overflow-hidden relative">
<h2 class="text-2xl sm:text-4xl font-bold text-white mb-4 leading-tight shrink-0 tracking-tight">
${group.title}
</h2>
<div id="car-mode-scroll-container" class="overflow-y-auto pr-2 custom-scrollbar flex-1 min-h-0 max-h-[50vh] sm:max-h-[55vh]">
<p class="text-xl sm:text-3xl text-gray-200 leading-relaxed mb-4 font-bold">
${group.summary.split(/(?<![A-Z]\.)(?<=[.!?])\s+/).join('<br><br>')}
</p>
</div>
</div>
<!-- Progress bar -->
<div class="h-1.5 bg-white/10 shrink-0">
<div class="car-mode-progress h-full bg-yellow-400"></div>
</div>
</div>
<!-- Card counter -->
<div class="absolute bottom-6 sm:bottom-8 left-1/2 -translate-x-1/2 flex items-center gap-2 z-30">
${Array.from({ length: total }, (_, i) => `
<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) {
const loadingMessage = state.aiArticleCount > 0
? `Sending ${state.aiArticleCount} articles to AI for analysis.<br>Please wait patiently...`
: 'Fetching news from sources...'
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">${loadingMessage}</p>
</div>
<button onclick="exitCarMode()" class="absolute top-14 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors" title="Exit Car Mode (Esc)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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 flex-col items-center justify-start py-4 px-2 sm:p-6 pt-2">
<div class="car-mode-blur-1"></div>
<div class="car-mode-blur-2"></div>
<div class="car-mode-blur-3"></div>
<!-- Exit button -->
<button onclick="exitCarMode()" class="absolute top-2 right-6 z-20 p-3 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Exit Car Mode (Esc)">
<svg class="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
<!-- Car mode indicator -->
<div class="absolute top-2 left-6 z-20 flex items-center gap-2 text-white/70 backdrop-blur-sm px-3 py-1.5 rounded-full bg-black/10">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 17a2 2 0 11-4 0 2 2 0 014 0zM20 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 15V12a1 1 0 011-1h1l2-4h8l2 4h1a1 1 0 011 1v3M6 15h12"></path>
</svg>
<span class="text-sm font-medium">Car Mode</span>
<span class="text-white/50">|</span>
<span class="text-sm font-medium">News ${state.carModeIndex + 1} / ${state.groups.length}</span>
</div>
<!-- Card Container -->
<div class="relative z-10 flex flex-col items-center w-full h-full pt-16">
${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
<!-- Navigation buttons moved below card -->
<div class="flex items-center gap-12 mt-4 sm:mt-6 z-20">
<button onclick="carModePrev()" class="p-4 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Previous">
<svg class="w-8 h-8 sm:w-10 sm:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<button onclick="carModeNext()" class="p-4 text-white/70 hover:text-white hover:bg-white/10 rounded-full transition-colors backdrop-blur-sm bg-black/10" title="Next">
<svg class="w-8 h-8 sm:w-10 sm:h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
</div>
`
}
function renderLockScreen() {
return `<div class="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100 flex items-center justify-center">
<div class="text-center">
<h1 onclick="handleUnlockClick()" class="text-4xl font-bold text-gray-900 cursor-pointer select-none">
News Feed
</h1>
</div>
</div>`
}
function handleUnlockClick() {
state.unlockClicks++
if (state.unlockClicks >= 3) {
state.locked = false
state.unlockClicks = 0
setCookie('news_feed_unlocked', 'true', 30) // Unlock for 30 days
render()
}
}
function render() {
// 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()
return
}
const filteredArticles = state.filter === 'all'
? state.articles
: state.articles.filter(a => a.source === state.filter)
const spinClass = state.loading ? 'animate-spin' : ''
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'
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 items-start justify-between gap-4">
<div class="flex flex-col sm:flex-row sm:items-center gap-4 flex-1">
<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>
${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="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>
<button onclick="enterCarMode()" class="px-4 py-2.5 text-base font-semibold bg-indigo-600 text-white hover:bg-indigo-700 rounded-xl transition-colors flex items-center gap-2 shadow-lg hover:shadow-xl" title="Enter Car Mode - Auto-cycling news display">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 17a2 2 0 11-4 0 2 2 0 014 0zM20 17a2 2 0 11-4 0 2 2 0 014 0z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 15V12a1 1 0 011-1h1l2-4h8l2 4h1a1 1 0 011 1v3M6 15h12"></path>
</svg>
Car Mode
</button>
</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
state.aiArticleCount = 0
render()
try {
// 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()
}
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
state.aiArticleCount = 0
render()
try {
// 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()
}
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()
// 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 20 seconds
startCarModeCycle()
}
window.exitCarMode = function() {
state.carMode = false
stopCarModeCycle()
render()
}
window.carModeNext = function() {
if (state.groups.length === 0 || state.isTransitioning) return
stopCarModeCycle()
transitionCard((state.carModeIndex + 1) % state.groups.length)
}
window.carModePrev = function() {
if (state.groups.length === 0 || state.isTransitioning) return
stopCarModeCycle()
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
startCarModeAutoScroll() // Start auto-scroll for long text
state.carModeInterval = setInterval(() => {
if (state.groups.length > 0 && !state.isTransitioning) {
transitionCard((state.carModeIndex + 1) % state.groups.length)
}
}, 20000) // 20 seconds per card
}
function stopCarModeCycle() {
if (state.carModeInterval) {
clearInterval(state.carModeInterval)
state.carModeInterval = null
}
stopCarModeAutoScroll()
}
function startCarModeAutoScroll() {
stopCarModeAutoScroll()
// Wait 10 seconds, then check if scrolling is needed
state.carModeScrollTimeout = setTimeout(() => {
const container = document.getElementById('car-mode-scroll-container')
if (!container) return
const maxScroll = container.scrollHeight - container.clientHeight
if (maxScroll <= 5) return
// Scroll in steps over ~8 seconds
let scrolled = 0
const totalSteps = 160 // 8 seconds at 50ms intervals
const stepSize = maxScroll / totalSteps
state.carModeScrollInterval = setInterval(() => {
scrolled += stepSize
if (scrolled >= maxScroll) {
container.scrollTop = maxScroll
clearInterval(state.carModeScrollInterval)
state.carModeScrollInterval = null
return
}
container.scrollTop = scrolled
}, 50)
}, 10000)
}
function stopCarModeAutoScroll() {
if (state.carModeScrollTimeout) {
clearTimeout(state.carModeScrollTimeout)
state.carModeScrollTimeout = null
}
if (state.carModeScrollInterval) {
clearInterval(state.carModeScrollInterval)
state.carModeScrollInterval = 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)