'use client'; import { Rss, Search, Star, Settings, Plus, Trash2, X, ExternalLink } from 'lucide-react'; import { useState, useEffect, useCallback, useRef } from 'react'; import { formatDistanceToNow } from 'date-fns'; // --- Types --- interface FeedItem { id: number; feed_id: number; title: string; link: string; pub_date: string | null; creator: string | null; snippet: string | null; read: number; bookmarked: number; feed_name: string; feed_url: string | null; } interface Feed { id: number; name: string; url: string; last_fetched: string | null; item_count: number; } // --- Helpers --- function feedColor(name: string): string { let hash = 0; for (let i = 0; i < name.length; i++) { hash = name.charCodeAt(i) + ((hash << 5) - hash); } const colors = [ 'bg-amber-500/20 text-amber-400', 'bg-blue-500/20 text-blue-400', 'bg-emerald-500/20 text-emerald-400', 'bg-purple-500/20 text-purple-400', 'bg-rose-500/20 text-rose-400', 'bg-cyan-500/20 text-cyan-400', 'bg-orange-500/20 text-orange-400', 'bg-indigo-500/20 text-indigo-400', ]; return colors[Math.abs(hash) % colors.length]; } function feedHealthDot(lastFetched: string | null): string { if (!lastFetched) return 'bg-neutral-600'; const diff = Date.now() - new Date(lastFetched).getTime(); if (diff < 10 * 60 * 1000) return 'bg-emerald-500'; if (diff < 30 * 60 * 1000) return 'bg-amber-500'; return 'bg-red-500'; } function faviconUrl(feedUrl: string): string { try { const domain = new URL(feedUrl).hostname; return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`; } catch { return ''; } } function FeedFavicon({ url, size = 14 }: { url: string; size?: number }) { const src = faviconUrl(url); if (!src) return null; return ( // eslint-disable-next-line @next/next/no-img-element { (e.target as HTMLImageElement).style.display = 'none'; }} /> ); } // --- Sub-components --- function SearchBar({ value, onChange }: { value: string; onChange: (v: string) => void }) { const [local, setLocal] = useState(value); const timerRef = useRef>(); useEffect(() => { setLocal(value); }, [value]); const handleChange = (v: string) => { setLocal(v); clearTimeout(timerRef.current); timerRef.current = setTimeout(() => onChange(v), 300); }; return ( handleChange(e.target.value)} className="w-full bg-neutral-800 border border-neutral-700 rounded-lg pl-7 pr-2 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" /> ); } function FeedFilterPills({ feeds, activeFeedId, onSelect, }: { feeds: Feed[]; activeFeedId: number | null; onSelect: (id: number | null) => void; }) { const totalItems = feeds.reduce((s, f) => s + f.item_count, 0); return ( onSelect(null)} className={`shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${ activeFeedId === null ? 'bg-neutral-700 text-white' : 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200' }`} > All ({totalItems}) {feeds.map((f) => ( onSelect(f.id)} className={`shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors flex items-center gap-1.5 ${ activeFeedId === f.id ? 'bg-neutral-700 text-white' : 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200' }`} > {f.name} ({f.item_count}) ))} ); } type TimeFilter = '12h' | '24h' | '7d' | '30d'; const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [ { value: '12h', label: '12h' }, { value: '24h', label: '24h' }, { value: '7d', label: '7d' }, { value: '30d', label: '30d' }, ]; function TimeFilterPills({ active, onSelect, }: { active: TimeFilter | null; onSelect: (v: TimeFilter | null) => void; }) { return ( onSelect(null)} className={`shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${ active === null ? 'bg-neutral-700 text-white' : 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200' }`} > Any time {TIME_FILTER_OPTIONS.map((opt) => ( onSelect(opt.value)} className={`shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${ active === opt.value ? 'bg-neutral-700 text-white' : 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200' }`} > {opt.label} ))} ); } function AddFeedForm({ onAdded }: { onAdded: () => void }) { const [url, setUrl] = useState(''); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(''); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(''); setSubmitting(true); try { const res = await fetch('/api/rss/feeds', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ url: url.trim() }), }); if (!res.ok) { const data = await res.json(); throw new Error(data.error || 'Failed to add feed'); } setUrl(''); onAdded(); } catch (err: unknown) { setError(err instanceof Error ? err.message : 'Failed to add feed'); } finally { setSubmitting(false); } }; return ( setUrl(e.target.value)} required className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-3 py-1.5 text-xs text-white placeholder-neutral-500 focus:outline-none focus:border-neutral-500" /> {error && {error}} {submitting ? 'Adding...' : 'Add'} ); } function FeedManager({ feeds, onChanged, onClose, }: { feeds: Feed[]; onChanged: () => void; onClose: () => void; }) { const handleDelete = async (id: number) => { try { await fetch('/api/rss/feeds', { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }), }); onChanged(); } catch (e) { console.error('Failed to delete feed:', e); } }; return ( Manage Feeds {feeds.map((f) => ( {f.name} {f.url} {f.last_fetched && ( {formatDistanceToNow(new Date(f.last_fetched), { addSuffix: true })} )} handleDelete(f.id)} className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-400 transition-all" > ))} ); } function FeedItemRow({ item, onRead, onBookmark, }: { item: FeedItem; onRead: (id: number) => void; onBookmark: (id: number, bookmarked: boolean) => void; }) { const handleClick = () => { window.open(item.link, '_blank', 'noopener,noreferrer'); if (!item.read) onRead(item.id); }; return ( {item.feed_url && } {item.feed_name || 'Unknown'} {item.creator && ( {item.creator} )} {item.title} {item.snippet && ( {item.snippet} )} {item.pub_date && ( {formatDistanceToNow(new Date(item.pub_date), { addSuffix: true })} )} { e.stopPropagation(); onBookmark(item.id, !item.bookmarked); }} className={`shrink-0 mt-1 transition-colors ${ item.bookmarked ? 'text-amber-400' : 'text-neutral-700 opacity-0 group-hover:opacity-100 hover:text-amber-400' }`} > ); } // --- Main Component --- export function NewsFeed() { const [items, setItems] = useState([]); const [feeds, setFeeds] = useState([]); const [searchQuery, setSearchQuery] = useState(''); const [activeFeedId, setActiveFeedId] = useState(null); const [timeFilter, setTimeFilter] = useState(null); const [showBookmarked, setShowBookmarked] = useState(false); const [showFeedManager, setShowFeedManager] = useState(false); const [loading, setLoading] = useState(true); const [newCount, setNewCount] = useState(0); const prevItemIdsRef = useRef>(new Set()); const fetchFeeds = useCallback(async () => { try { const res = await fetch('/api/rss/feeds'); const data = await res.json(); if (Array.isArray(data)) setFeeds(data); } catch (e) { console.error('Failed to fetch feeds:', e); } }, []); const fetchItems = useCallback(async (opts?: { silent?: boolean; resetFeedFilter?: boolean }) => { try { const params = new URLSearchParams(); if (searchQuery) params.set('q', searchQuery); if (!opts?.resetFeedFilter && activeFeedId !== null) params.set('feed_id', String(activeFeedId)); if (showBookmarked) params.set('bookmarked', '1'); if (timeFilter) params.set('since', timeFilter); params.set('limit', '50'); const res = await fetch(`/api/rss?${params}`); const data = await res.json(); if (Array.isArray(data)) { if (opts?.silent && prevItemIdsRef.current.size > 0) { const newIds = data.filter((i: FeedItem) => !prevItemIdsRef.current.has(i.id)); if (newIds.length > 0) { setNewCount((c) => c + newIds.length); } } prevItemIdsRef.current = new Set(data.map((i: FeedItem) => i.id)); setItems(data); } } catch (e) { console.error('Failed to fetch items:', e); } finally { setLoading(false); } }, [searchQuery, activeFeedId, showBookmarked, timeFilter]); // Initial load + refresh useEffect(() => { fetchFeeds(); fetchItems(); }, [fetchFeeds, fetchItems]); // Auto-refresh every 30s useEffect(() => { const interval = setInterval(() => { fetchItems({ silent: true }); fetchFeeds(); }, 30000); return () => clearInterval(interval); }, [fetchItems, fetchFeeds]); const handleRead = async (id: number) => { setItems((prev) => prev.map((i) => (i.id === id ? { ...i, read: 1 } : i))); try { await fetch('/api/rss', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, read: true }), }); } catch (e) { console.error('Failed to mark as read:', e); } }; const handleBookmark = async (id: number, bookmarked: boolean) => { setItems((prev) => prev.map((i) => (i.id === id ? { ...i, bookmarked: bookmarked ? 1 : 0 } : i)) ); try { await fetch('/api/rss', { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, bookmarked }), }); } catch (e) { console.error('Failed to toggle bookmark:', e); } }; const handleFeedChanged = () => { setActiveFeedId(null); fetchFeeds(); fetchItems({ resetFeedFilter: true }); }; const handleDismissNew = () => { setNewCount(0); fetchItems(); }; const hasFeeds = feeds.length > 0; return ( {/* Header */} Feed setShowBookmarked(!showBookmarked)} className={`p-1.5 rounded-lg transition-colors ${ showBookmarked ? 'bg-amber-500/10 text-amber-400' : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800' }`} title="Bookmarks" > setShowFeedManager(!showFeedManager)} className={`p-1.5 rounded-lg transition-colors ${ showFeedManager ? 'bg-neutral-700 text-white' : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800' }`} title="Manage feeds" > {/* Feed filter pills */} {hasFeeds && ( )} {/* Time filter pills */} {hasFeeds && ( )} {/* Feed manager (collapsible) */} {showFeedManager && ( setShowFeedManager(false)} /> )} {/* New items badge */} {newCount > 0 && ( {newCount} new item{newCount > 1 ? 's' : ''} — click to refresh )} {/* Item list */} {loading ? ( {[1, 2, 3, 4, 5].map((i) => ( ))} ) : !hasFeeds ? ( No feeds yet setShowFeedManager(true)} className="flex items-center gap-1.5 text-xs text-amber-400 hover:text-amber-300 transition-colors" > Add your first feed ) : items.length === 0 ? ( No items found ) : ( {items.map((item) => ( ))} )} ); }
{error}
{item.snippet}