feat: scaffold initial RSS reader project with Go backend and React frontend

This commit is contained in:
2026-08-24 08:08:31 -07:00
commit 3742dbdce4
44 changed files with 9901 additions and 0 deletions
+246
View File
@@ -0,0 +1,246 @@
import React, { useState } from 'react';
import { Category, Feed, FilterState, Stats } from '../types';
import {
Inbox,
Star,
Layers,
Folder,
FolderOpen,
Rss,
ChevronRight,
ChevronDown,
} from 'lucide-react';
interface SidebarProps {
filter: FilterState;
onFilterChange: (newFilter: Partial<FilterState>) => void;
categories: Category[];
feeds: Feed[];
stats: Stats | null;
}
export const Sidebar: React.FC<SidebarProps> = ({
filter,
onFilterChange,
categories,
feeds,
stats,
}) => {
const [collapsedCats, setCollapsedCats] = useState<Record<string, boolean>>({});
const toggleCategoryCollapse = (catId: string, e: React.MouseEvent) => {
e.stopPropagation();
setCollapsedCats((prev) => ({ ...prev, [catId]: !prev[catId] }));
};
// Group feeds by category
const categorizedFeeds: Record<string, Feed[]> = {};
const uncategorizedFeeds: Feed[] = [];
feeds.forEach((f) => {
if (f.category_id) {
if (!categorizedFeeds[f.category_id]) {
categorizedFeeds[f.category_id] = [];
}
categorizedFeeds[f.category_id].push(f);
} else {
uncategorizedFeeds.push(f);
}
});
return (
<div className="sidebar-scroll-area">
{/* Core Navigation Items */}
<div
className={`sidebar-nav-item ${
filter.view === 'inbox' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'inbox',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Inbox size={16} />
<span>Inbox</span>
</div>
{stats && stats.total_unread > 0 && (
<span className="badge unread">{stats.total_unread}</span>
)}
</div>
<div
className={`sidebar-nav-item ${
filter.view === 'starred' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'starred',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Star size={16} style={{ color: 'var(--star)' }} />
<span>Starred</span>
</div>
{stats && stats.total_starred > 0 && (
<span className="badge">{stats.total_starred}</span>
)}
</div>
<div
className={`sidebar-nav-item ${
filter.view === 'all' && !filter.feedId && !filter.categoryId
? 'active'
: ''
}`}
onClick={() =>
onFilterChange({
view: 'all',
feedId: undefined,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Layers size={16} />
<span>All Articles</span>
</div>
</div>
{/* Categories Section */}
{categories.length > 0 && (
<div style={{ marginTop: '0.6rem' }}>
<div className="sidebar-section-header">
<span>Categories</span>
</div>
{categories.map((cat) => {
const catFeeds = categorizedFeeds[cat.id] || [];
const isCollapsed = collapsedCats[cat.id];
const isCatActive = filter.categoryId === cat.id && !filter.feedId;
const catUnread = catFeeds.reduce((sum, f) => sum + f.unread_count, 0);
return (
<div key={cat.id}>
<div
className={`sidebar-nav-item ${isCatActive ? 'active' : ''}`}
onClick={() =>
onFilterChange({
categoryId: cat.id,
feedId: undefined,
})
}
>
<div className="sidebar-nav-label">
<button
style={{
background: 'none',
border: 'none',
color: 'inherit',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
padding: 0,
marginRight: -3,
}}
onClick={(e) => toggleCategoryCollapse(cat.id, e)}
>
{isCollapsed ? <ChevronRight size={13} /> : <ChevronDown size={13} />}
</button>
{isCollapsed ? <Folder size={15} /> : <FolderOpen size={15} />}
<span>{cat.title}</span>
</div>
{catUnread > 0 && <span className="badge unread">{catUnread}</span>}
</div>
{/* Category feeds */}
{!isCollapsed &&
catFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<div
key={feed.id}
className={`feed-item ${isFeedActive ? 'active' : ''}`}
onClick={() =>
onFilterChange({
feedId: feed.id,
categoryId: undefined,
})
}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
overflow: 'hidden',
}}
>
<Rss size={12} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span className="feed-title-text" title={feed.title}>
{feed.title}
</span>
</div>
{feed.unread_count > 0 && (
<span className="badge unread">{feed.unread_count}</span>
)}
</div>
);
})}
</div>
);
})}
</div>
)}
{/* Uncategorized Feeds */}
{uncategorizedFeeds.length > 0 && (
<div style={{ marginTop: '0.6rem' }}>
<div className="sidebar-section-header">
<span>Feeds</span>
</div>
{uncategorizedFeeds.map((feed) => {
const isFeedActive = filter.feedId === feed.id;
return (
<div
key={feed.id}
className={`sidebar-nav-item ${isFeedActive ? 'active' : ''}`}
style={{ paddingLeft: '0.65rem' }}
onClick={() =>
onFilterChange({
feedId: feed.id,
categoryId: undefined,
})
}
>
<div className="sidebar-nav-label">
<Rss size={14} style={{ flexShrink: 0, color: 'var(--text-muted)' }} />
<span className="feed-title-text" title={feed.title}>
{feed.title}
</span>
</div>
{feed.unread_count > 0 && (
<span className="badge unread">{feed.unread_count}</span>
)}
</div>
);
})}
</div>
)}
</div>
);
};