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

@@ -51,6 +51,10 @@ RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
COPY --from=builder --chown=nextjs:nodejs /app/monitor.js ./
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sqlite ./node_modules/sqlite
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sqlite3 ./node_modules/sqlite3
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/rss-parser ./node_modules/rss-parser
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xml2js ./node_modules/xml2js
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sax ./node_modules/sax
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/xmlbuilder ./node_modules/xmlbuilder
COPY --chown=nextjs:nodejs start.sh ./
RUN chmod +x start.sh

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
import Parser from 'rss-parser';
export const dynamic = 'force-dynamic';
export async function GET() {
const db = await getDb();
const feeds = await db.all(`
SELECT f.*, (SELECT COUNT(*) FROM rss_items WHERE feed_id = f.id) as item_count
FROM rss_feeds f ORDER BY f.created_at ASC
`);
return NextResponse.json(feeds);
}
export async function POST(req: NextRequest) {
const { url, name } = await req.json();
if (!url || typeof url !== 'string') {
return NextResponse.json({ error: 'URL is required' }, { status: 400 });
}
// Validate URL format
try {
new URL(url);
} catch {
return NextResponse.json({ error: 'Invalid URL format' }, { status: 400 });
}
// Validate it's actually an RSS feed
const parser = new Parser({ timeout: 5000 });
let parsed;
try {
parsed = await parser.parseURL(url);
} catch {
return NextResponse.json({ error: 'Could not parse RSS feed at this URL' }, { status: 400 });
}
const feedName = name?.trim() || parsed.title || new URL(url).hostname;
const db = await getDb();
try {
const result = await db.run(
'INSERT INTO rss_feeds (name, url) VALUES (?, ?)',
feedName, url.trim()
);
const feedId = result.lastID;
// Immediately fetch and cache items
for (const item of parsed.items || []) {
if (!item.title || !item.link) continue;
await db.run(
`INSERT OR IGNORE INTO rss_items (feed_id, title, link, pub_date, creator, snippet)
VALUES (?, ?, ?, ?, ?, ?)`,
feedId,
item.title,
item.link,
item.pubDate || item.isoDate || null,
item.creator || item.author || null,
(item.contentSnippet || item.content || '').substring(0, 500) || null
);
}
await db.run(
'UPDATE rss_feeds SET last_fetched = ? WHERE id = ?',
new Date().toISOString(), feedId
);
const feed = await db.get('SELECT * FROM rss_feeds WHERE id = ?', feedId);
return NextResponse.json(feed, { status: 201 });
} catch (err: unknown) {
if (err instanceof Error && err.message.includes('UNIQUE')) {
return NextResponse.json({ error: 'Feed URL already exists' }, { status: 409 });
}
throw err;
}
}
export async function DELETE(req: NextRequest) {
const { id } = await req.json();
if (!id) {
return NextResponse.json({ error: 'Feed id is required' }, { status: 400 });
}
const db = await getDb();
await db.run('DELETE FROM rss_items WHERE feed_id = ?', id);
await db.run('DELETE FROM rss_feeds WHERE id = ?', id);
return NextResponse.json({ ok: true });
}

View File

@@ -1,23 +1,76 @@
import { NextResponse } from 'next/server';
import Parser from 'rss-parser';
import { NextRequest, NextResponse } from 'next/server';
import { getDb } from '@/lib/db';
export const dynamic = 'force-dynamic';
export async function GET() {
const parser = new Parser();
try {
const feed = await parser.parseURL('https://news.ycombinator.com/rss');
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const q = searchParams.get('q');
const feedId = searchParams.get('feed_id');
const bookmarked = searchParams.get('bookmarked');
const limit = parseInt(searchParams.get('limit') || '50', 10);
const offset = parseInt(searchParams.get('offset') || '0', 10);
const items = feed.items.slice(0, 10).map(item => ({
title: item.title,
link: item.link,
pubDate: item.pubDate,
creator: item.creator || 'Unknown',
contentSnippet: item.contentSnippet,
}));
const db = await getDb();
const conditions: string[] = [];
const params: (string | number)[] = [];
if (q) {
conditions.push('(i.title LIKE ? OR i.snippet LIKE ?)');
params.push(`%${q}%`, `%${q}%`);
}
if (feedId) {
conditions.push('i.feed_id = ?');
params.push(parseInt(feedId, 10));
}
if (bookmarked === '1') {
conditions.push('i.bookmarked = 1');
}
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
const items = await db.all(
`SELECT i.*, f.name as feed_name, f.url as feed_url
FROM rss_items i
LEFT JOIN rss_feeds f ON f.id = i.feed_id
${where}
ORDER BY i.pub_date DESC
LIMIT ? OFFSET ?`,
...params, limit, offset
);
return NextResponse.json(items);
} catch (error) {
return NextResponse.json({ error: 'Failed to parse RSS' }, { status: 500 });
}
}
export async function PATCH(req: NextRequest) {
const body = await req.json();
const { id, read, bookmarked } = body;
if (!id) {
return NextResponse.json({ error: 'Item id is required' }, { status: 400 });
}
const db = await getDb();
const updates: string[] = [];
const params: (number)[] = [];
if (read !== undefined) {
updates.push('read = ?');
params.push(read ? 1 : 0);
}
if (bookmarked !== undefined) {
updates.push('bookmarked = ?');
params.push(bookmarked ? 1 : 0);
}
if (updates.length === 0) {
return NextResponse.json({ error: 'Nothing to update' }, { status: 400 });
}
params.push(id);
await db.run(`UPDATE rss_items SET ${updates.join(', ')} WHERE id = ?`, ...params);
const item = await db.get('SELECT * FROM rss_items WHERE id = ?', id);
return NextResponse.json(item);
}

View File

@@ -14,13 +14,8 @@ export default function Home() {
{/* Row 1 & 2 (Globe spans 2 rows) */}
<GlobeCard />
{/* Row 2 */}
{/* Row 2-4: NewsFeed spans 4 cols, 3 rows */}
<NewsFeed />
{/* Future widget placeholder to fill grid if needed */}
<div className="col-span-1 md:col-span-2 lg:col-span-2 row-span-1 bg-neutral-900/50 border border-neutral-800/50 rounded-xl flex items-center justify-center border-dashed">
<span className="text-xs text-neutral-600 font-mono">System Metric Placeholder</span>
</div>
</GridShell>
);
}

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() {
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 res = await fetch('/api/rss');
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="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)) {
setNews(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(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);
}
fetchNews();
}, []);
};
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-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">
<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">Hacker News</span>
<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 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>
))}
</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>
))
) : (
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-0.5">
{items.map((item) => (
<FeedItemRow
key={item.id}
item={item}
onRead={handleRead}
onBookmark={handleBookmark}
/>
))}
</div>
</a>
))
)}
</div>
</div>

View File

@@ -45,7 +45,40 @@ export async function getDb() {
CREATE INDEX IF NOT EXISTS idx_uptime_logs_service_timestamp
ON uptime_logs(service_name, timestamp);
CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
last_fetched DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rss_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
pub_date DATETIME,
creator TEXT,
snippet TEXT,
read INTEGER DEFAULT 0,
bookmarked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rss_items_feed ON rss_items(feed_id);
CREATE INDEX IF NOT EXISTS idx_rss_items_pubdate ON rss_items(pub_date DESC);
`);
// Seed default HN feed if rss_feeds is empty
const feedCount = await db.get('SELECT COUNT(*) as cnt FROM rss_feeds');
if (feedCount?.cnt === 0) {
await db.run(
'INSERT INTO rss_feeds (name, url) VALUES (?, ?)',
'Hacker News', 'https://news.ycombinator.com/rss'
);
}
return db;
}

View File

@@ -1,5 +1,6 @@
const sqlite3 = require('sqlite3');
const { open } = require('sqlite');
const RSSParser = require('rss-parser');
// Node 18+ has global fetch built-in
const DEFAULT_SERVICES = [
@@ -42,6 +43,78 @@ async function seedDefaults(db) {
}
}
async function setupRssTables(db) {
await db.exec(`
CREATE TABLE IF NOT EXISTS rss_feeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL UNIQUE,
last_fetched DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS rss_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
feed_id INTEGER NOT NULL,
title TEXT NOT NULL,
link TEXT NOT NULL UNIQUE,
pub_date DATETIME,
creator TEXT,
snippet TEXT,
read INTEGER DEFAULT 0,
bookmarked INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_rss_items_feed ON rss_items(feed_id);
CREATE INDEX IF NOT EXISTS idx_rss_items_pubdate ON rss_items(pub_date DESC);
`);
// Seed default HN feed
const count = await db.get('SELECT COUNT(*) as cnt FROM rss_feeds');
if (count.cnt === 0) {
await db.run(
'INSERT INTO rss_feeds (name, url) VALUES (?, ?)',
'Hacker News', 'https://news.ycombinator.com/rss'
);
console.log('Seeded default RSS feed (Hacker News)');
}
}
async function syncRssFeeds(db) {
const parser = new RSSParser({ timeout: 5000 });
const feeds = await db.all('SELECT * FROM rss_feeds');
for (const feed of feeds) {
try {
const parsed = await parser.parseURL(feed.url);
for (const item of parsed.items || []) {
if (!item.title || !item.link) continue;
await db.run(
`INSERT OR IGNORE INTO rss_items (feed_id, title, link, pub_date, creator, snippet)
VALUES (?, ?, ?, ?, ?, ?)`,
feed.id,
item.title,
item.link,
item.pubDate || item.isoDate || null,
item.creator || item.author || null,
(item.contentSnippet || item.content || '').substring(0, 500) || null
);
}
await db.run(
'UPDATE rss_feeds SET last_fetched = ? WHERE id = ?',
new Date().toISOString(), feed.id
);
console.log(`RSS synced: ${feed.name} (${(parsed.items || []).length} items)`);
} catch (err) {
console.error(`RSS sync error for ${feed.name}:`, err.message);
}
}
// Prune items older than 90 days
try {
await db.run(`DELETE FROM rss_items WHERE created_at < datetime('now', '-90 days')`);
} catch (e) { }
}
async function monitor() {
console.log('Starting monitoring loop...');
@@ -66,6 +139,10 @@ async function monitor() {
await seedDefaults(db);
// Setup RSS tables and do initial sync
await setupRssTables(db);
syncRssFeeds(db); // initial sync, don't await to not block uptime start
setInterval(async () => {
console.log('Running checks...');
const now = new Date().toISOString();
@@ -114,6 +191,9 @@ async function monitor() {
} catch (e) { }
}, 60000); // Run every minute
// RSS sync every 5 minutes
setInterval(() => syncRssFeeds(db), 5 * 60 * 1000);
}
monitor();