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:
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();
|
||||
|
||||
return NextResponse.json(items);
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: 'Failed to parse RSS' }, { status: 500 });
|
||||
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);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user