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>
779 lines
28 KiB
JavaScript
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)
|