2026-02-08 02:32:45 -05:00
|
|
|
'use client';
|
|
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
import { Rss, Search, Star, Settings, Plus, Trash2, X, ExternalLink } from 'lucide-react';
|
|
|
|
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
2026-02-08 02:32:45 -05:00
|
|
|
import { formatDistanceToNow } from 'date-fns';
|
|
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
// --- Types ---
|
|
|
|
|
|
|
|
|
|
interface FeedItem {
|
|
|
|
|
id: number;
|
|
|
|
|
feed_id: number;
|
|
|
|
|
title: string;
|
|
|
|
|
link: string;
|
|
|
|
|
pub_date: string | null;
|
|
|
|
|
creator: string | null;
|
|
|
|
|
snippet: string | null;
|
|
|
|
|
read: number;
|
|
|
|
|
bookmarked: number;
|
|
|
|
|
feed_name: string;
|
|
|
|
|
feed_url: string | null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface Feed {
|
|
|
|
|
id: number;
|
|
|
|
|
name: string;
|
|
|
|
|
url: string;
|
|
|
|
|
last_fetched: string | null;
|
|
|
|
|
item_count: number;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Helpers ---
|
|
|
|
|
|
|
|
|
|
function feedColor(name: string): string {
|
|
|
|
|
let hash = 0;
|
|
|
|
|
for (let i = 0; i < name.length; i++) {
|
|
|
|
|
hash = name.charCodeAt(i) + ((hash << 5) - hash);
|
|
|
|
|
}
|
|
|
|
|
const colors = [
|
|
|
|
|
'bg-amber-500/20 text-amber-400',
|
|
|
|
|
'bg-blue-500/20 text-blue-400',
|
|
|
|
|
'bg-emerald-500/20 text-emerald-400',
|
|
|
|
|
'bg-purple-500/20 text-purple-400',
|
|
|
|
|
'bg-rose-500/20 text-rose-400',
|
|
|
|
|
'bg-cyan-500/20 text-cyan-400',
|
|
|
|
|
'bg-orange-500/20 text-orange-400',
|
|
|
|
|
'bg-indigo-500/20 text-indigo-400',
|
|
|
|
|
];
|
|
|
|
|
return colors[Math.abs(hash) % colors.length];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function feedHealthDot(lastFetched: string | null): string {
|
|
|
|
|
if (!lastFetched) return 'bg-neutral-600';
|
|
|
|
|
const diff = Date.now() - new Date(lastFetched).getTime();
|
|
|
|
|
if (diff < 10 * 60 * 1000) return 'bg-emerald-500';
|
|
|
|
|
if (diff < 30 * 60 * 1000) return 'bg-amber-500';
|
|
|
|
|
return 'bg-red-500';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function faviconUrl(feedUrl: string): string {
|
|
|
|
|
try {
|
|
|
|
|
const domain = new URL(feedUrl).hostname;
|
|
|
|
|
return `https://www.google.com/s2/favicons?domain=${domain}&sz=32`;
|
|
|
|
|
} catch {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function FeedFavicon({ url, size = 14 }: { url: string; size?: number }) {
|
|
|
|
|
const src = faviconUrl(url);
|
|
|
|
|
if (!src) return null;
|
|
|
|
|
return (
|
|
|
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
|
|
|
<img
|
|
|
|
|
src={src}
|
|
|
|
|
alt=""
|
|
|
|
|
width={size}
|
|
|
|
|
height={size}
|
|
|
|
|
className="shrink-0 rounded-sm"
|
|
|
|
|
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Sub-components ---
|
|
|
|
|
|
|
|
|
|
function SearchBar({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
|
|
|
|
const [local, setLocal] = useState(value);
|
|
|
|
|
const timerRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
|
|
|
|
|
|
useEffect(() => { setLocal(value); }, [value]);
|
|
|
|
|
|
|
|
|
|
const handleChange = (v: string) => {
|
|
|
|
|
setLocal(v);
|
|
|
|
|
clearTimeout(timerRef.current);
|
|
|
|
|
timerRef.current = setTimeout(() => onChange(v), 300);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="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>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-09 17:51:06 -05:00
|
|
|
type TimeFilter = '12h' | '24h' | '7d' | '30d';
|
2026-02-09 17:25:39 -05:00
|
|
|
|
|
|
|
|
const TIME_FILTER_OPTIONS: { value: TimeFilter; label: string }[] = [
|
2026-02-09 17:51:06 -05:00
|
|
|
{ value: '12h', label: '12h' },
|
2026-02-09 17:25:39 -05:00
|
|
|
{ value: '24h', label: '24h' },
|
|
|
|
|
{ value: '7d', label: '7d' },
|
|
|
|
|
{ value: '30d', label: '30d' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
function TimeFilterPills({
|
|
|
|
|
active,
|
|
|
|
|
onSelect,
|
|
|
|
|
}: {
|
|
|
|
|
active: TimeFilter | null;
|
|
|
|
|
onSelect: (v: TimeFilter | null) => void;
|
|
|
|
|
}) {
|
|
|
|
|
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 ${
|
|
|
|
|
active === null
|
|
|
|
|
? 'bg-neutral-700 text-white'
|
|
|
|
|
: 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
Any time
|
|
|
|
|
</button>
|
|
|
|
|
{TIME_FILTER_OPTIONS.map((opt) => (
|
|
|
|
|
<button
|
|
|
|
|
key={opt.value}
|
|
|
|
|
onClick={() => onSelect(opt.value)}
|
|
|
|
|
className={`shrink-0 px-2.5 py-1 rounded-full text-[11px] font-medium transition-colors ${
|
|
|
|
|
active === opt.value
|
|
|
|
|
? 'bg-neutral-700 text-white'
|
|
|
|
|
: 'bg-neutral-800/60 text-neutral-400 hover:text-neutral-200'
|
|
|
|
|
}`}
|
|
|
|
|
>
|
|
|
|
|
{opt.label}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
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 ---
|
|
|
|
|
|
2026-02-08 02:32:45 -05:00
|
|
|
export function NewsFeed() {
|
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
2026-02-09 04:50:06 -05:00
|
|
|
const [items, setItems] = useState<FeedItem[]>([]);
|
|
|
|
|
const [feeds, setFeeds] = useState<Feed[]>([]);
|
|
|
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
|
|
|
const [activeFeedId, setActiveFeedId] = useState<number | null>(null);
|
2026-02-09 17:25:39 -05:00
|
|
|
const [timeFilter, setTimeFilter] = useState<TimeFilter | null>(null);
|
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
2026-02-09 04:50:06 -05:00
|
|
|
const [showBookmarked, setShowBookmarked] = useState(false);
|
|
|
|
|
const [showFeedManager, setShowFeedManager] = useState(false);
|
2026-02-08 02:32:45 -05:00
|
|
|
const [loading, setLoading] = useState(true);
|
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
2026-02-09 04:50:06 -05:00
|
|
|
const [newCount, setNewCount] = useState(0);
|
|
|
|
|
const prevItemIdsRef = useRef<Set<number>>(new Set());
|
2026-02-08 02:32:45 -05:00
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
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');
|
2026-02-09 17:25:39 -05:00
|
|
|
if (timeFilter) params.set('since', timeFilter);
|
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
2026-02-09 04:50:06 -05:00
|
|
|
params.set('limit', '50');
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`/api/rss?${params}`);
|
|
|
|
|
const data = await res.json();
|
|
|
|
|
if (Array.isArray(data)) {
|
|
|
|
|
if (opts?.silent && prevItemIdsRef.current.size > 0) {
|
|
|
|
|
const newIds = data.filter((i: FeedItem) => !prevItemIdsRef.current.has(i.id));
|
|
|
|
|
if (newIds.length > 0) {
|
|
|
|
|
setNewCount((c) => c + newIds.length);
|
|
|
|
|
}
|
2026-02-08 02:32:45 -05:00
|
|
|
}
|
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
2026-02-09 04:50:06 -05:00
|
|
|
prevItemIdsRef.current = new Set(data.map((i: FeedItem) => i.id));
|
|
|
|
|
setItems(data);
|
2026-02-08 02:32:45 -05:00
|
|
|
}
|
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
2026-02-09 04:50:06 -05:00
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to fetch items:', e);
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
2026-02-08 02:32:45 -05:00
|
|
|
}
|
2026-02-09 17:25:39 -05:00
|
|
|
}, [searchQuery, activeFeedId, showBookmarked, timeFilter]);
|
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
2026-02-09 04:50:06 -05:00
|
|
|
|
|
|
|
|
// Initial load + refresh
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
fetchFeeds();
|
|
|
|
|
fetchItems();
|
|
|
|
|
}, [fetchFeeds, fetchItems]);
|
|
|
|
|
|
|
|
|
|
// Auto-refresh every 30s
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
fetchItems({ silent: true });
|
|
|
|
|
fetchFeeds();
|
|
|
|
|
}, 30000);
|
|
|
|
|
return () => clearInterval(interval);
|
|
|
|
|
}, [fetchItems, fetchFeeds]);
|
|
|
|
|
|
|
|
|
|
const handleRead = async (id: number) => {
|
|
|
|
|
setItems((prev) => prev.map((i) => (i.id === id ? { ...i, read: 1 } : i)));
|
|
|
|
|
try {
|
|
|
|
|
await fetch('/api/rss', {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ id, read: true }),
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to mark as read:', e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleBookmark = async (id: number, bookmarked: boolean) => {
|
|
|
|
|
setItems((prev) =>
|
|
|
|
|
prev.map((i) => (i.id === id ? { ...i, bookmarked: bookmarked ? 1 : 0 } : i))
|
|
|
|
|
);
|
|
|
|
|
try {
|
|
|
|
|
await fetch('/api/rss', {
|
|
|
|
|
method: 'PATCH',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify({ id, bookmarked }),
|
|
|
|
|
});
|
|
|
|
|
} catch (e) {
|
|
|
|
|
console.error('Failed to toggle bookmark:', e);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleFeedChanged = () => {
|
|
|
|
|
setActiveFeedId(null);
|
|
|
|
|
fetchFeeds();
|
|
|
|
|
fetchItems({ resetFeedFilter: true });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleDismissNew = () => {
|
|
|
|
|
setNewCount(0);
|
|
|
|
|
fetchItems();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hasFeeds = feeds.length > 0;
|
2026-02-08 02:32:45 -05:00
|
|
|
|
|
|
|
|
return (
|
2026-02-09 17:51:06 -05:00
|
|
|
<div className="h-full bg-neutral-900 border border-neutral-800 rounded-xl p-5 flex flex-col hover:border-neutral-700 transition-colors overflow-hidden">
|
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
2026-02-09 04:50:06 -05:00
|
|
|
{/* Header */}
|
2026-02-09 17:51:06 -05:00
|
|
|
<div className="widget-drag-handle flex items-center justify-between gap-3 shrink-0 mb-3">
|
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
2026-02-09 04:50:06 -05:00
|
|
|
<div className="flex items-center gap-2 text-neutral-400">
|
|
|
|
|
<Rss size={18} />
|
|
|
|
|
<span className="text-sm font-medium">Feed</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<SearchBar value={searchQuery} onChange={setSearchQuery} />
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowBookmarked(!showBookmarked)}
|
|
|
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
|
|
|
showBookmarked ? 'bg-amber-500/10 text-amber-400' : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
|
|
|
|
|
}`}
|
|
|
|
|
title="Bookmarks"
|
|
|
|
|
>
|
|
|
|
|
<Star size={15} fill={showBookmarked ? 'currentColor' : 'none'} />
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowFeedManager(!showFeedManager)}
|
|
|
|
|
className={`p-1.5 rounded-lg transition-colors ${
|
|
|
|
|
showFeedManager ? 'bg-neutral-700 text-white' : 'text-neutral-500 hover:text-neutral-300 hover:bg-neutral-800'
|
|
|
|
|
}`}
|
|
|
|
|
title="Manage feeds"
|
|
|
|
|
>
|
|
|
|
|
<Settings size={15} />
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
2026-02-08 02:32:45 -05:00
|
|
|
</div>
|
|
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
{/* Feed filter pills */}
|
|
|
|
|
{hasFeeds && (
|
|
|
|
|
<div className="shrink-0 mb-2">
|
|
|
|
|
<FeedFilterPills feeds={feeds} activeFeedId={activeFeedId} onSelect={setActiveFeedId} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2026-02-09 17:25:39 -05:00
|
|
|
{/* Time filter pills */}
|
|
|
|
|
{hasFeeds && (
|
|
|
|
|
<div className="shrink-0 mb-2">
|
|
|
|
|
<TimeFilterPills active={timeFilter} onSelect={setTimeFilter} />
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
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
2026-02-09 04:50:06 -05:00
|
|
|
{/* 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">
|
2026-02-08 02:32:45 -05:00
|
|
|
{loading ? (
|
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
2026-02-09 04:50:06 -05:00
|
|
|
<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" />
|
2026-02-08 02:32:45 -05:00
|
|
|
</div>
|
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
2026-02-09 04:50:06 -05:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : !hasFeeds ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full text-neutral-500 gap-3">
|
|
|
|
|
<Rss size={32} className="opacity-30" />
|
|
|
|
|
<span className="text-sm">No feeds yet</span>
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setShowFeedManager(true)}
|
|
|
|
|
className="flex items-center gap-1.5 text-xs text-amber-400 hover:text-amber-300 transition-colors"
|
|
|
|
|
>
|
|
|
|
|
<Plus size={14} />
|
|
|
|
|
Add your first feed
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
) : items.length === 0 ? (
|
|
|
|
|
<div className="flex flex-col items-center justify-center h-full text-neutral-500 gap-2">
|
|
|
|
|
<Search size={24} className="opacity-30" />
|
|
|
|
|
<span className="text-sm">No items found</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="space-y-0.5">
|
|
|
|
|
{items.map((item) => (
|
|
|
|
|
<FeedItemRow
|
|
|
|
|
key={item.id}
|
|
|
|
|
item={item}
|
|
|
|
|
onRead={handleRead}
|
|
|
|
|
onBookmark={handleBookmark}
|
|
|
|
|
/>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
2026-02-08 02:32:45 -05:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|