Files
news-feed-car-mode/news-app/src/main.js
jared cd06b8f25c Add clickable navigation dots to car mode
Users can now click on any dot in the card counter to jump directly
to that news card, making navigation more convenient.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 00:30:58 +00:00

779 lines
28 KiB
JavaScript

import './style.css'
// Check if we're on the dedicated car mode URL
const isCarModeUrl = window.location.pathname === '/carmode' || window.location.pathname === '/carmode/'
// Use appropriate API base depending on URL
const API_BASE = isCarModeUrl ? '/carmode/api' : '/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: isCarModeUrl, // Auto-enter car mode if on /carmode URL
carModeIndex: 0,
carModeInterval: null,
carModeScrollTimeout: null,
carModeScrollInterval: null,
aiArticleCount: 0,
locked: isCarModeUrl ? false : !getCookie('news_feed_unlocked'), // Skip lock for car mode URL
unlockClicks: 0,
isTransitioning: false,
isCarModeUrl: isCarModeUrl, // Track if we're on dedicated car mode URL
}
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>
<!-- Source article links -->
<div class="flex items-center justify-center gap-2 sm:gap-3 mt-3 mb-2 z-20">
${group.articles.slice(0, 5).map((article, i) => `
<a href="${article.link}" target="_blank" rel="noopener noreferrer" class="px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base font-medium text-white/80 hover:text-white bg-white/10 hover:bg-white/20 rounded-full transition-colors backdrop-blur-sm" title="${article.title} (${article.source})">
Link ${i + 1}
</a>
`).join('')}
</div>
<!-- Card counter -->
<div class="absolute bottom-6 sm:bottom-8 left-1/2 -translate-x-1/2 flex items-center justify-center z-30 max-w-[90vw] px-4">
${Array.from({ length: total }, (_, i) => `
<div onclick="carModeGoTo(${i})" class="shrink-0 rounded-full transition-all duration-300 cursor-pointer hover:bg-white/70 ${i === index ? 'bg-white' : 'bg-white/40'}" style="width: ${i === index ? Math.max(12, Math.min(24, 200 / total)) : Math.max(6, Math.min(8, 120 / total))}px; height: ${Math.max(6, Math.min(8, 120 / total))}px; margin: 0 ${Math.max(2, Math.min(4, 60 / total))}px;"></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>
${!state.isCarModeUrl ? `
<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>
${!state.isCarModeUrl ? `
<!-- 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)}
</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() {
// On dedicated car mode URL, just refresh the page to restart
if (state.isCarModeUrl) {
window.location.reload()
return
}
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)
}
window.carModeGoTo = function(index) {
if (state.groups.length === 0 || state.isTransitioning) return
if (index < 0 || index >= state.groups.length) return
if (index === state.carModeIndex) return // Already on this card
stopCarModeCycle()
transitionCard(index)
}
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':
// Don't allow escape on dedicated car mode URL
if (!state.isCarModeUrl) {
window.exitCarMode()
}
break
case 'ArrowRight':
case ' ':
window.carModeNext()
break
case 'ArrowLeft':
window.carModePrev()
break
}
})
// Touch/swipe support for car mode
let touchStartX = 0
let touchStartY = 0
let touchEndX = 0
let touchEndY = 0
document.addEventListener('touchstart', (e) => {
if (!state.carMode) return
touchStartX = e.changedTouches[0].screenX
touchStartY = e.changedTouches[0].screenY
}, { passive: true })
document.addEventListener('touchend', (e) => {
if (!state.carMode) return
touchEndX = e.changedTouches[0].screenX
touchEndY = e.changedTouches[0].screenY
handleSwipe()
}, { passive: true })
function handleSwipe() {
const deltaX = touchEndX - touchStartX
const deltaY = touchEndY - touchStartY
const minSwipeDistance = 50
// Only register as horizontal swipe if horizontal movement is greater than vertical
if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > minSwipeDistance) {
if (deltaX < 0) {
// Swiped left - go to next card
window.carModeNext()
} else {
// Swiped right - go to previous card
window.carModePrev()
}
}
}
// Initialize: either start car mode or fetch regular news
if (isCarModeUrl) {
// On dedicated car mode URL, directly enter car mode
window.enterCarMode()
} else {
fetchNews()
}
setInterval(() => {
// Don't auto-refresh if in car mode (it handles its own data)
if (state.carMode) return
if (state.viewMode === 'ai') {
fetchGroupedNews()
} else {
fetchNews()
}
}, 5 * 60 * 1000)