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
? `
`
: ''
const sourceBadges = group.sources
.slice(0, 3)
.map(s => `${getSourceName(s)} `)
.join('')
const moreCount = group.sources.length > 3 ? `+${group.sources.length - 3} ` : ''
return `
${imageHtml}
${group.category}
${group.articleCount} articles
AI Summary
${group.title}
${group.summary}
${sourceBadges}${moreCount}
View source articles
`
}
function renderArticle(article) {
const imageHtml = article.image
? ``
: ''
return `
${imageHtml}
${getSourceName(article.source)}
${formatDate(article.pubDate)}
${article.title}
${truncate(article.content, 500)}
`
}
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 ``
}
function renderError(error) {
return `
Failed to load news
${error}
Try Again
`
}
function renderEmpty() {
return `
No articles found
Try selecting a different source filter.
`
}
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 `
${group.image ? `
` : ''}
${group.articles.slice(0, 5).map((article, i) => `
Link ${i + 1}
`).join('')}
${Array.from({ length: total }, (_, i) => `
`).join('')}
`
}
function renderCarMode() {
if (state.groups.length === 0) {
const loadingMessage = state.aiArticleCount > 0
? `Sending ${state.aiArticleCount} articles to AI for analysis. Please wait patiently...`
: 'Fetching news from sources...'
return `
${!state.isCarModeUrl ? `
` : ''}
`
}
const currentGroup = state.groups[state.carModeIndex]
return `
${!state.isCarModeUrl ? `
` : ''}
Car Mode
|
News ${state.carModeIndex + 1} / ${state.groups.length}
${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
`
}
function renderLockScreen() {
return ``
}
function handleUnlockClick() {
state.unlockClicks++
if (state.unlockClicks >= 3) {
state.locked = false
state.unlockClicks = 0
setCookie('news_feed_unlocked', 'true', 30) // Unlock for 30 days
render()
}
}
function render() {
// If locked, render lock screen
if (state.locked) {
app.innerHTML = renderLockScreen()
return
}
// If car mode is active, render car mode overlay
if (state.carMode) {
app.innerHTML = renderCarMode()
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 = `
News Feed
${state.viewMode === 'ai' ? 'AI-grouped summaries from multiple sources' : 'Latest headlines from multiple sources'}
Regular
AI Grouped
${state.viewMode === 'ai' ? `
Clear Cache ` : ''}
Car Mode
${content}
News aggregated from ABC, NPR, CNN, NBC, CBS & NY Times
`
}
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)