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:
@@ -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
|
||||
|
||||
|
||||
91
app/api/rss/feeds/route.ts
Normal file
91
app/api/rss/feeds/route.ts
Normal 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 });
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
33
lib/db.ts
33
lib/db.ts
@@ -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;
|
||||
}
|
||||
|
||||
80
monitor.js
80
monitor.js
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user