Overhaul RSS feed widget: persistence, multi-feed management, search, bookmarks

- Add rss_feeds + rss_items tables with indexes and HN default seed
- Add 5-min background RSS sync loop in monitor.js with 90-day prune
- New /api/rss/feeds route for feed CRUD with immediate fetch on add
- Rewrite /api/rss route with search, feed filter, pagination, read/bookmark PATCH
- Full NewsFeed component rewrite: feed manager, search bar, filter pills,
  read/unread tracking, bookmarks, favicons, auto-refresh with new items badge
- Remove placeholder widget, NewsFeed now spans 4 cols / 3 rows
- Add rss-parser deps to Dockerfile for standalone monitor
This commit is contained in:
Shivam Patel
2026-02-09 04:50:06 -05:00
parent e47a719d79
commit f95e28202d
7 changed files with 795 additions and 64 deletions

View File

@@ -1,56 +1,531 @@
'use client';
import { Rss, ExternalLink } from 'lucide-react';
import { useState, useEffect } from 'react';
import { Rss, Search, Star, Settings, Plus, Trash2, X, ExternalLink } from 'lucide-react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { formatDistanceToNow } from 'date-fns';
export function NewsFeed() {
const [news, setNews] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
// --- Types ---
useEffect(() => {
async function fetchNews() {
try {
const res = await fetch('/api/rss');
const data = await res.json();
if (Array.isArray(data)) {
setNews(data);
}
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
}
fetchNews();
}, []);
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
<img
src={src}
alt=""
width={size}
height={size}
className="shrink-0 rounded-sm"
onError={(e) => { (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<ReturnType<typeof setTimeout>>();
useEffect(() => { setLocal(value); }, [value]);
const handleChange = (v: string) => {
setLocal(v);
clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => onChange(v), 300);
};
return (
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-2 bg-neutral-900 border border-neutral-800 rounded-xl p-6 flex flex-col hover:border-neutral-700 transition-colors">
<div className="flex items-center gap-2 text-neutral-400 mb-4">
<Rss size={18} />
<span className="text-sm font-medium">Hacker News</span>
<div className="relative flex-1 max-w-[200px]">
<Search size={13} className="absolute left-2 top-1/2 -translate-y-1/2 text-neutral-500" />
<input
type="text"
placeholder="Search..."
value={local}
onChange={(e) => 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"
/>
</div>
);
}
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 (
<div className="flex gap-1.5 overflow-x-auto pb-1 scrollbar-none">
<button
onClick={() => 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})
</button>
{feeds.map((f) => (
<button
key={f.id}
onClick={() => 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'
}`}
>
<FeedFavicon url={f.url} size={12} />
{f.name} ({f.item_count})
</button>
))}
</div>
);
}
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 (
<form onSubmit={handleSubmit} className="flex gap-2 items-start">
<div className="flex-1 min-w-0">
<input
type="url"
placeholder="Paste RSS feed URL..."
value={url}
onChange={(e) => 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 && <p className="text-[10px] text-red-400 mt-1">{error}</p>}
</div>
<button
type="submit"
disabled={submitting}
className="shrink-0 flex items-center gap-1 text-xs bg-neutral-700 hover:bg-neutral-600 text-white px-3 py-1.5 rounded-lg transition-colors disabled:opacity-50"
>
<Plus size={12} />
{submitting ? 'Adding...' : 'Add'}
</button>
</form>
);
}
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 (
<div className="border border-neutral-800 rounded-lg p-3 space-y-3 bg-neutral-900/50">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-neutral-400">Manage Feeds</span>
<button onClick={onClose} className="text-neutral-500 hover:text-neutral-300 transition-colors">
<X size={14} />
</button>
</div>
<AddFeedForm onAdded={onChanged} />
<div className="space-y-1.5 max-h-[150px] overflow-y-auto scrollbar-thin">
{feeds.map((f) => (
<div key={f.id} className="flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg hover:bg-neutral-800/40 group">
<div className="flex items-center gap-2 min-w-0">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${feedHealthDot(f.last_fetched)}`} />
<FeedFavicon url={f.url} size={14} />
<span className="text-xs text-neutral-300 truncate">{f.name}</span>
<span className="text-[10px] text-neutral-600 truncate hidden sm:inline">{f.url}</span>
</div>
<div className="flex items-center gap-2 shrink-0">
{f.last_fetched && (
<span className="text-[10px] text-neutral-600 font-mono">
{formatDistanceToNow(new Date(f.last_fetched), { addSuffix: true })}
</span>
)}
<button
onClick={() => handleDelete(f.id)}
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-400 transition-all"
>
<Trash2 size={12} />
</button>
</div>
</div>
))}
</div>
</div>
);
}
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 (
<div
className={`flex items-start gap-3 px-2 py-2.5 rounded-lg hover:bg-neutral-800/40 transition-colors group cursor-pointer ${
item.read ? 'opacity-50' : 'border-l-2 border-l-amber-500/50'
}`}
onClick={handleClick}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`shrink-0 px-1.5 py-0.5 rounded text-[9px] font-medium flex items-center gap-1 ${feedColor(item.feed_name || '')}`}>
{item.feed_url && <FeedFavicon url={item.feed_url} size={10} />}
{item.feed_name || 'Unknown'}
</span>
{item.creator && (
<span className="text-[10px] text-neutral-600 truncate">{item.creator}</span>
)}
</div>
<h3 className="text-sm text-neutral-300 font-medium group-hover:text-amber-400 transition-colors line-clamp-2 leading-snug">
{item.title}
</h3>
{item.snippet && (
<p className="text-[11px] text-neutral-600 line-clamp-1 mt-0.5">{item.snippet}</p>
)}
<div className="flex items-center gap-2 mt-1">
{item.pub_date && (
<span className="text-[10px] text-neutral-600 font-mono">
{formatDistanceToNow(new Date(item.pub_date), { addSuffix: true })}
</span>
)}
</div>
</div>
<button
onClick={(e) => {
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'
}`}
>
<Star size={14} fill={item.bookmarked ? 'currentColor' : 'none'} />
</button>
</div>
);
}
// --- Main Component ---
export function NewsFeed() {
const [items, setItems] = useState<FeedItem[]>([]);
const [feeds, setFeeds] = useState<Feed[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [activeFeedId, setActiveFeedId] = useState<number | null>(null);
const [showBookmarked, setShowBookmarked] = useState(false);
const [showFeedManager, setShowFeedManager] = useState(false);
const [loading, setLoading] = useState(true);
const [newCount, setNewCount] = useState(0);
const prevItemIdsRef = useRef<Set<number>>(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');
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]);
// 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 (
<div className="col-span-1 md:col-span-4 lg:col-span-4 row-span-3 bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-colors overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between gap-3 shrink-0 mb-3">
<div className="flex items-center gap-2 text-neutral-400">
<Rss size={18} />
<span className="text-sm font-medium">Feed</span>
</div>
<div className="flex items-center gap-2">
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<button
onClick={() => 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"
>
<Star size={15} fill={showBookmarked ? 'currentColor' : 'none'} />
</button>
<button
onClick={() => 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"
>
<Settings size={15} />
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto pr-2 space-y-4 scrollbar-thin scrollbar-thumb-neutral-800 max-h-[300px]">
{/* Feed filter pills */}
{hasFeeds && (
<div className="shrink-0 mb-2">
<FeedFilterPills feeds={feeds} activeFeedId={activeFeedId} onSelect={setActiveFeedId} />
</div>
)}
{/* Feed manager (collapsible) */}
{showFeedManager && (
<div className="shrink-0 mb-3">
<FeedManager feeds={feeds} onChanged={handleFeedChanged} onClose={() => setShowFeedManager(false)} />
</div>
)}
{/* New items badge */}
{newCount > 0 && (
<button
onClick={handleDismissNew}
className="shrink-0 mb-2 text-xs text-amber-400 bg-amber-500/10 rounded-lg py-1.5 px-3 text-center hover:bg-amber-500/20 transition-colors"
>
{newCount} new item{newCount > 1 ? 's' : ''} click to refresh
</button>
)}
{/* Item list */}
<div className="flex-1 overflow-y-auto min-h-0 pr-1 scrollbar-thin scrollbar-thumb-neutral-800">
{loading ? (
[1, 2, 3].map(i => (
<div key={i} className="space-y-2 animate-pulse">
<div className="h-4 bg-neutral-800 rounded w-3/4"></div>
<div className="h-3 bg-neutral-800 rounded w-1/2"></div>
</div>
))
) : (
news.map((item) => (
<a key={item.link} href={item.link} target="_blank" rel="noopener noreferrer" className="block group cursor-pointer border-b border-neutral-800/50 pb-3 last:border-0 last:pb-0">
<h3 className="text-sm text-neutral-300 font-medium group-hover:text-amber-400 transition-colors line-clamp-2">
{item.title}
</h3>
<div className="flex gap-3 mt-1 text-xs text-neutral-500">
<span className="font-mono">{item.pubDate ? formatDistanceToNow(new Date(item.pubDate), { addSuffix: true }) : ''}</span>
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="space-y-2 animate-pulse px-2">
<div className="h-3 bg-neutral-800 rounded w-16" />
<div className="h-4 bg-neutral-800 rounded w-3/4" />
<div className="h-3 bg-neutral-800 rounded w-1/2" />
</div>
</a>
))
))}
</div>
) : !hasFeeds ? (
<div className="flex flex-col items-center justify-center h-full text-neutral-500 gap-3">
<Rss size={32} className="opacity-30" />
<span className="text-sm">No feeds yet</span>
<button
onClick={() => setShowFeedManager(true)}
className="flex items-center gap-1.5 text-xs text-amber-400 hover:text-amber-300 transition-colors"
>
<Plus size={14} />
Add your first feed
</button>
</div>
) : items.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-neutral-500 gap-2">
<Search size={24} className="opacity-30" />
<span className="text-sm">No items found</span>
</div>
) : (
<div className="space-y-0.5">
{items.map((item) => (
<FeedItemRow
key={item.id}
item={item}
onRead={handleRead}
onBookmark={handleBookmark}
/>
))}
</div>
)}
</div>
</div>