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/monitor.js ./
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/sqlite ./node_modules/sqlite
|
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/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 ./
|
COPY --chown=nextjs:nodejs start.sh ./
|
||||||
RUN chmod +x 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 { NextRequest, NextResponse } from 'next/server';
|
||||||
import Parser from 'rss-parser';
|
import { getDb } from '@/lib/db';
|
||||||
|
|
||||||
export const dynamic = 'force-dynamic';
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET(req: NextRequest) {
|
||||||
const parser = new Parser();
|
const { searchParams } = new URL(req.url);
|
||||||
try {
|
const q = searchParams.get('q');
|
||||||
const feed = await parser.parseURL('https://news.ycombinator.com/rss');
|
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 => ({
|
const db = await getDb();
|
||||||
title: item.title,
|
|
||||||
link: item.link,
|
const conditions: string[] = [];
|
||||||
pubDate: item.pubDate,
|
const params: (string | number)[] = [];
|
||||||
creator: item.creator || 'Unknown',
|
|
||||||
contentSnippet: item.contentSnippet,
|
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);
|
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) */}
|
{/* Row 1 & 2 (Globe spans 2 rows) */}
|
||||||
<GlobeCard />
|
<GlobeCard />
|
||||||
|
|
||||||
{/* Row 2 */}
|
{/* Row 2-4: NewsFeed spans 4 cols, 3 rows */}
|
||||||
<NewsFeed />
|
<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>
|
</GridShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,531 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Rss, ExternalLink } from 'lucide-react';
|
import { Rss, Search, Star, Settings, Plus, Trash2, X, ExternalLink } from 'lucide-react';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { formatDistanceToNow } from 'date-fns';
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
export function NewsFeed() {
|
// --- Types ---
|
||||||
const [news, setNews] = useState<any[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
interface FeedItem {
|
||||||
async function fetchNews() {
|
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 {
|
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();
|
const data = await res.json();
|
||||||
if (Array.isArray(data)) {
|
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) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error('Failed to fetch items:', e);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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 (
|
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="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">
|
||||||
<div className="flex items-center gap-2 text-neutral-400 mb-4">
|
{/* 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} />
|
<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>
|
||||||
|
|
||||||
<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 ? (
|
{loading ? (
|
||||||
[1, 2, 3].map(i => (
|
<div className="space-y-3">
|
||||||
<div key={i} className="space-y-2 animate-pulse">
|
{[1, 2, 3, 4, 5].map((i) => (
|
||||||
<div className="h-4 bg-neutral-800 rounded w-3/4"></div>
|
<div key={i} className="space-y-2 animate-pulse px-2">
|
||||||
<div className="h-3 bg-neutral-800 rounded w-1/2"></div>
|
<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>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
) : (
|
||||||
news.map((item) => (
|
<div className="space-y-0.5">
|
||||||
<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">
|
{items.map((item) => (
|
||||||
<h3 className="text-sm text-neutral-300 font-medium group-hover:text-amber-400 transition-colors line-clamp-2">
|
<FeedItemRow
|
||||||
{item.title}
|
key={item.id}
|
||||||
</h3>
|
item={item}
|
||||||
<div className="flex gap-3 mt-1 text-xs text-neutral-500">
|
onRead={handleRead}
|
||||||
<span className="font-mono">{item.pubDate ? formatDistanceToNow(new Date(item.pubDate), { addSuffix: true }) : ''}</span>
|
onBookmark={handleBookmark}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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
|
CREATE INDEX IF NOT EXISTS idx_uptime_logs_service_timestamp
|
||||||
ON uptime_logs(service_name, 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;
|
return db;
|
||||||
}
|
}
|
||||||
|
|||||||
80
monitor.js
80
monitor.js
@@ -1,5 +1,6 @@
|
|||||||
const sqlite3 = require('sqlite3');
|
const sqlite3 = require('sqlite3');
|
||||||
const { open } = require('sqlite');
|
const { open } = require('sqlite');
|
||||||
|
const RSSParser = require('rss-parser');
|
||||||
// Node 18+ has global fetch built-in
|
// Node 18+ has global fetch built-in
|
||||||
|
|
||||||
const DEFAULT_SERVICES = [
|
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() {
|
async function monitor() {
|
||||||
console.log('Starting monitoring loop...');
|
console.log('Starting monitoring loop...');
|
||||||
|
|
||||||
@@ -66,6 +139,10 @@ async function monitor() {
|
|||||||
|
|
||||||
await seedDefaults(db);
|
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 () => {
|
setInterval(async () => {
|
||||||
console.log('Running checks...');
|
console.log('Running checks...');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -114,6 +191,9 @@ async function monitor() {
|
|||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
|
|
||||||
}, 60000); // Run every minute
|
}, 60000); // Run every minute
|
||||||
|
|
||||||
|
// RSS sync every 5 minutes
|
||||||
|
setInterval(() => syncRssFeeds(db), 5 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
monitor();
|
monitor();
|
||||||
|
|||||||
Reference in New Issue
Block a user