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 ? `
` : '' 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 `

${message}

` } function renderError(error) { return `

Failed to load news

${error}

` } 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.title}

${group.summary.split(/(?
')}

${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 `

${loadingMessage}

` } const currentGroup = state.groups[state.carModeIndex] return `
Car Mode | News ${state.carModeIndex + 1} / ${state.groups.length}
${renderCarModeCard(currentGroup, state.carModeIndex, state.groups.length)}
` } function renderLockScreen() { return `

News Feed

` } 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'}

${state.viewMode === 'ai' ? `` : ''}
${content}
` } 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)