first draft of the project

This commit is contained in:
2026-07-10 15:49:28 +02:00
parent 9cf0eda983
commit 8ab0a855c3
19 changed files with 3251 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
**/node_modules
backend/data
backend/data-test
backend/public
frontend/dist
.git
.gitignore
*.md
+7
View File
@@ -0,0 +1,7 @@
node_modules/
data/
data-test/
backend/public/
frontend/dist/
*.log
.DS_Store
+36
View File
@@ -0,0 +1,36 @@
# ---- Stage 1: build the frontend ----
FROM node:20-bookworm-slim AS frontend-build
WORKDIR /app/frontend
COPY frontend/package.json ./
RUN npm install
COPY frontend/ ./
RUN npm run build
# ---- Stage 2: backend + runtime ----
FROM node:20-bookworm-slim AS runtime
WORKDIR /app
# Native build tools needed to compile better-sqlite3, removed after install
RUN apt-get update && apt-get install -y --no-install-recommends python3 build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY backend/package.json ./
RUN npm install --omit=dev
COPY backend/ ./
COPY --from=frontend-build /app/backend/public ./public
RUN apt-get purge -y --auto-remove python3 build-essential 2>/dev/null || true
ENV NODE_ENV=production \
PORT=8080 \
DATA_DIR=/data \
FETCH_INTERVAL_MINUTES=15
VOLUME ["/data"]
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s \
CMD node -e "fetch('http://localhost:'+ (process.env.PORT||8080) +'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server.js"]
+1 -1
View File
@@ -1,4 +1,4 @@
# Dispatch — a self-hosted RSS reader
# Dispatch a self-hosted RSS reader
A clean, fast RSS/Atom reader you run yourself. Three-pane layout on desktop
(feeds → articles → reading pane), a single stacked view with swipe-style
+17
View File
@@ -0,0 +1,17 @@
services:
dispatch:
build: .
# Or, once you've pushed/pulled an image, replace `build: .` with:
# image: yourname/dispatch-rss:latest
container_name: dispatch-rss
restart: unless-stopped
ports:
- "8080:8080"
environment:
- PORT=8080
# How often (minutes) feeds are auto-refreshed in the background
- FETCH_INTERVAL_MINUTES=15
- TZ=Etc/UTC
volumes:
# Map this to a path on your Unraid array, e.g. /mnt/user/appdata/dispatch-rss
- ./data:/data
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#14181B" />
<title>Dispatch — RSS Reader</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+1719
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -0,0 +1,19 @@
{
"name": "dispatch-rss-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.3.1",
"vite": "^5.4.2"
}
}
+192
View File
@@ -0,0 +1,192 @@
import React, { useEffect, useState, useCallback, useRef } from 'react';
import Sidebar from './components/Sidebar.jsx';
import ArticleList from './components/ArticleList.jsx';
import ArticleView from './components/ArticleView.jsx';
import AddFeedModal from './components/AddFeedModal.jsx';
import ManageFeedsModal from './components/ManageFeedsModal.jsx';
import { api } from './api.js';
export default function App() {
const [feeds, setFeeds] = useState([]);
const [folders, setFolders] = useState([]);
const [stats, setStats] = useState({});
const [view, setView] = useState({ type: 'unread' });
const [articles, setArticles] = useState([]);
const [loadingArticles, setLoadingArticles] = useState(false);
const [selected, setSelected] = useState(null);
const [search, setSearch] = useState('');
const [showAddFeed, setShowAddFeed] = useState(false);
const [showManage, setShowManage] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [theme, setTheme] = useState(localStorage.getItem('dispatch-theme') || 'dark');
const [sidebarOpen, setSidebarOpen] = useState(false);
const [readerOpen, setReaderOpen] = useState(false);
const [toast, setToast] = useState(null);
const searchDebounce = useRef(null);
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('dispatch-theme', theme);
}, [theme]);
const loadSidebarData = useCallback(async () => {
const [f, fo, s] = await Promise.all([api.getFeeds(), api.getFolders(), api.getStats()]);
setFeeds(f);
setFolders(fo);
setStats(s);
}, []);
const loadArticles = useCallback(async () => {
setLoadingArticles(true);
const params = { search };
if (view.type === 'unread') params.status = 'unread';
if (view.type === 'starred') params.status = 'starred';
if (view.type === 'feed') params.feed_id = view.id;
if (view.type === 'folder') params.folder_id = view.id;
try {
const res = await api.getArticles(params);
setArticles(res.articles);
} finally {
setLoadingArticles(false);
}
}, [view, search]);
useEffect(() => { loadSidebarData(); }, [loadSidebarData]);
useEffect(() => { loadArticles(); setSelected(null); setReaderOpen(false); }, [view]);
useEffect(() => {
clearTimeout(searchDebounce.current);
searchDebounce.current = setTimeout(loadArticles, 250);
return () => clearTimeout(searchDebounce.current);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [search]);
function showToast(msg) {
setToast(msg);
setTimeout(() => setToast(null), 2500);
}
async function handleSelectArticle(article) {
setSelected(article);
setReaderOpen(true);
if (!article.is_read) {
await api.updateArticle(article.id, { is_read: true });
setArticles((prev) => prev.map((a) => (a.id === article.id ? { ...a, is_read: true } : a)));
loadSidebarData();
}
const full = await api.getArticle(article.id);
setSelected(full);
}
async function handleToggleStar(article) {
const updated = await api.updateArticle(article.id, { is_starred: !article.is_starred });
setSelected(updated);
setArticles((prev) => prev.map((a) => (a.id === article.id ? { ...a, is_starred: updated.is_starred } : a)));
loadSidebarData();
}
async function handleAddFeed(url, { folderId, newFolderName }) {
let finalFolderId = folderId;
if (newFolderName) {
const folder = await api.createFolder(newFolderName);
finalFolderId = folder.id;
}
await api.addFeed(url, finalFolderId || null);
showToast('Feed added — fetching articles…');
await loadSidebarData();
setTimeout(loadArticles, 1500);
}
async function handleRefreshAll() {
setRefreshing(true);
try {
await api.refreshAll();
await loadSidebarData();
await loadArticles();
showToast('Feeds refreshed');
} finally {
setRefreshing(false);
}
}
async function handleMarkAllRead() {
const payload = {};
if (view.type === 'feed') payload.feed_id = view.id;
else if (view.type === 'folder') payload.folder_id = view.id;
else payload.all = true;
await api.markRead(payload);
loadSidebarData();
loadArticles();
}
async function handleDeleteFeed(id) {
await api.deleteFeed(id);
await loadSidebarData();
if (view.type === 'feed' && view.id === id) setView({ type: 'unread' });
}
async function handleRefreshFeed(id) {
await api.refreshFeed(id);
loadSidebarData();
loadArticles();
}
const viewTitle = {
unread: 'Unread', starred: 'Starred', all: 'All articles',
}[view.type] || view.title || '';
return (
<div className="app">
{(sidebarOpen) && <div className="mobile-scrim" onClick={() => setSidebarOpen(false)} />}
<div className={`sidebar ${sidebarOpen ? 'open' : ''}`}>
<Sidebar
feeds={feeds}
folders={folders}
view={view}
stats={stats}
theme={theme}
refreshing={refreshing}
onSelectView={(v) => { setView(v); setSidebarOpen(false); }}
onAddFeed={() => setShowAddFeed(true)}
onRefreshAll={handleRefreshAll}
onOpenManage={() => setShowManage(true)}
onToggleTheme={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}
onClose={() => setSidebarOpen(false)}
/>
</div>
<ArticleList
title={viewTitle}
articles={articles}
selectedId={selected?.id}
onSelect={handleSelectArticle}
onOpenSidebar={() => setSidebarOpen(true)}
search={search}
onSearch={setSearch}
onMarkAllRead={handleMarkAllRead}
loading={loadingArticles}
/>
<div className={`reader-pane-wrap ${readerOpen ? 'open' : ''}`}>
<ArticleView
article={selected}
onBack={() => setReaderOpen(false)}
onToggleStar={handleToggleStar}
/>
</div>
{showAddFeed && (
<AddFeedModal folders={folders} onAdd={handleAddFeed} onClose={() => setShowAddFeed(false)} />
)}
{showManage && (
<ManageFeedsModal
feeds={feeds}
onClose={() => setShowManage(false)}
onDeleteFeed={handleDeleteFeed}
onRefreshFeed={handleRefreshFeed}
onImported={(result) => { showToast(`Imported ${result.results.filter(r => r.status === 'added').length} feeds`); loadSidebarData(); }}
/>
)}
{toast && <div className="toast">{toast}</div>}
</div>
);
}
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
const base = (props) => ({
width: 18, height: 18, viewBox: '0 0 24 24', fill: 'none',
stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
...props,
});
export const Menu = (p) => (
<svg {...base(p)}><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>
);
export const Back = (p) => (
<svg {...base(p)}><polyline points="15 18 9 12 15 6" /></svg>
);
export const Star = (p) => (
<svg {...base(p)}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /></svg>
);
export const StarFilled = (p) => (
<svg {...base({ ...p, fill: 'currentColor' })}><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2" /></svg>
);
export const Refresh = (p) => (
<svg {...base(p)}><polyline points="23 4 23 10 17 10" /><polyline points="1 20 1 14 7 14" /><path d="M3.51 9a9 9 0 0114.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0020.49 15" /></svg>
);
export const Plus = (p) => (
<svg {...base(p)}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
);
export const Folder = (p) => (
<svg {...base(p)}><path d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" /></svg>
);
export const Rss = (p) => (
<svg {...base(p)}><path d="M4 11a9 9 0 019 9" /><path d="M4 4a16 16 0 0116 16" /><circle cx="5" cy="19" r="1.5" fill="currentColor" stroke="none" /></svg>
);
export const Search = (p) => (
<svg {...base(p)}><circle cx="11" cy="11" r="7" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
);
export const ExternalLink = (p) => (
<svg {...base(p)}><path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6" /><polyline points="15 3 21 3 21 9" /><line x1="10" y1="14" x2="21" y2="3" /></svg>
);
export const Check = (p) => (
<svg {...base(p)}><polyline points="20 6 9 17 4 12" /></svg>
);
export const CheckCircle = (p) => (
<svg {...base(p)}><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
);
export const X = (p) => (
<svg {...base(p)}><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
);
export const Settings = (p) => (
<svg {...base(p)}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 11-2.83 2.83l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 11-2.83-2.83l.06-.06a1.65 1.65 0 00.33-1.82 1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 112.83-2.83l.06.06a1.65 1.65 0 001.82.33H9a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 112.83 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 010 4h-.09a1.65 1.65 0 00-1.51 1z" /></svg>
);
export const Trash = (p) => (
<svg {...base(p)}><polyline points="3 6 5 6 21 6" /><path d="M19 6l-1 14a2 2 0 01-2 2H8a2 2 0 01-2-2L5 6m5 0V4a2 2 0 012-2h0a2 2 0 012 2v2" /></svg>
);
export const Inbox = (p) => (
<svg {...base(p)}><polyline points="22 12 16 12 14 15 10 15 8 12 2 12" /><path d="M5.45 5.11L2 12v6a2 2 0 002 2h16a2 2 0 002-2v-6l-3.45-6.89A2 2 0 0016.76 4H7.24a2 2 0 00-1.79 1.11z" /></svg>
);
export const Upload = (p) => (
<svg {...base(p)}><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
);
export const Download = (p) => (
<svg {...base(p)}><path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
);
export const ChevronDown = (p) => (
<svg {...base(p)}><polyline points="6 9 12 15 18 9" /></svg>
);
export const Sun = (p) => (
<svg {...base(p)}><circle cx="12" cy="12" r="4" /><line x1="12" y1="1" x2="12" y2="3" /><line x1="12" y1="21" x2="12" y2="23" /><line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /><line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /><line x1="1" y1="12" x2="3" y2="12" /><line x1="21" y1="12" x2="23" y2="12" /><line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /><line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /></svg>
);
export const Moon = (p) => (
<svg {...base(p)}><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" /></svg>
);
+45
View File
@@ -0,0 +1,45 @@
const BASE = '/api';
async function request(path, options = {}) {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
let msg = `Request failed (${res.status})`;
try { const body = await res.json(); if (body.error) msg = body.error; } catch {}
throw new Error(msg);
}
if (res.status === 204) return null;
const ct = res.headers.get('content-type') || '';
if (ct.includes('application/json')) return res.json();
return res.text();
}
export const api = {
// Folders
getFolders: () => request('/folders'),
createFolder: (name) => request('/folders', { method: 'POST', body: JSON.stringify({ name }) }),
deleteFolder: (id) => request(`/folders/${id}`, { method: 'DELETE' }),
// Feeds
getFeeds: () => request('/feeds'),
addFeed: (url, folder_id) => request('/feeds', { method: 'POST', body: JSON.stringify({ url, folder_id }) }),
updateFeed: (id, data) => request(`/feeds/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteFeed: (id) => request(`/feeds/${id}`, { method: 'DELETE' }),
refreshFeed: (id) => request(`/feeds/${id}/refresh`, { method: 'POST' }),
refreshAll: () => request('/feeds/refresh-all', { method: 'POST' }),
exportOpml: () => `${BASE}/feeds/export/opml`,
importOpml: (xmlText) => request('/feeds/import/opml', { method: 'POST', headers: { 'Content-Type': 'text/xml' }, body: xmlText }),
// Articles
getArticles: (params = {}) => {
const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v !== undefined && v !== null && v !== '')).toString();
return request(`/articles?${qs}`);
},
getArticle: (id) => request(`/articles/${id}`),
updateArticle: (id, data) => request(`/articles/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
markRead: (payload) => request('/articles/mark-read', { method: 'POST', body: JSON.stringify(payload) }),
getStats: () => request('/stats'),
};
+71
View File
@@ -0,0 +1,71 @@
import React, { useState } from 'react';
export default function AddFeedModal({ folders, onAdd, onClose }) {
const [url, setUrl] = useState('');
const [folderId, setFolderId] = useState('');
const [newFolder, setNewFolder] = useState(false);
const [folderName, setFolderName] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
if (!url.trim()) return;
setSubmitting(true);
setError('');
try {
await onAdd(url.trim(), newFolder ? { newFolderName: folderName.trim() } : { folderId: folderId || null });
onClose();
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
<h3>Add a feed</h3>
<p className="modal-sub">Paste the URL of an RSS or Atom feed, or a site — we'll try to find it.</p>
{error && <div className="form-error">{error}</div>}
<form onSubmit={handleSubmit}>
<div className="field">
<label>Feed URL</label>
<input
autoFocus
placeholder="https://example.com/feed.xml"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
<div className="field">
<label>Folder</label>
{!newFolder ? (
<select value={folderId} onChange={(e) => {
if (e.target.value === '__new__') { setNewFolder(true); } else { setFolderId(e.target.value); }
}}>
<option value="">No folder</option>
{folders.map((f) => <option key={f.id} value={f.id}>{f.name}</option>)}
<option value="__new__">+ New folder…</option>
</select>
) : (
<input
autoFocus
placeholder="Folder name"
value={folderName}
onChange={(e) => setFolderName(e.target.value)}
/>
)}
</div>
<div className="modal-actions">
<button type="button" className="btn" onClick={onClose}>Cancel</button>
<button type="submit" className="btn primary" disabled={submitting || !url.trim()}>
{submitting ? 'Adding…' : 'Add feed'}
</button>
</div>
</form>
</div>
</div>
);
}
+72
View File
@@ -0,0 +1,72 @@
import React from 'react';
import { Menu, Search, StarFilled, Inbox, CheckCircle } from '../Icons.jsx';
function timeAgo(dateStr) {
if (!dateStr) return '';
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return 'now';
if (min < 60) return `${min}m`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr}h`;
const day = Math.floor(hr / 24);
if (day < 7) return `${day}d`;
return new Date(dateStr).toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
export default function ArticleList({
title, articles, selectedId, onSelect, onOpenSidebar, search, onSearch,
onMarkAllRead, loading,
}) {
return (
<div className="article-list-pane">
<div className="list-topbar mobile-topbar">
<button className="icon-btn" onClick={onOpenSidebar}><Menu width={18} height={18} /></button>
</div>
<div className="list-topbar">
<h2>{title}</h2>
<button className="icon-btn" title="Mark all as read" onClick={onMarkAllRead}>
<CheckCircle width={16} height={16} />
</button>
</div>
<div className="search-box">
<Search width={14} height={14} />
<input
placeholder="Search articles…"
value={search}
onChange={(e) => onSearch(e.target.value)}
/>
</div>
{loading && <div className="loading-line" />}
<div className="article-scroll">
{!loading && articles.length === 0 && (
<div className="empty-state">
<Inbox />
<h3>Nothing here</h3>
<p>You're all caught up, or this view is waiting on its first fetch.</p>
</div>
)}
{articles.map((a) => (
<div
key={a.id}
className={`article-row ${a.is_read ? 'read' : 'unread'} ${selectedId === a.id ? 'selected' : ''}`}
onClick={() => onSelect(a)}
>
<span className="signal" />
<div className="article-row-content">
<div className="row-meta">
<span>{a.feed_title}</span>
<span>·</span>
<span>{timeAgo(a.published_at)}</span>
</div>
<p className="row-title">{a.title}</p>
<p className="row-summary">{a.summary}</p>
</div>
{a.is_starred ? <StarFilled className="row-star" width={15} height={15} /> : null}
{a.image_url && <img className="row-thumb" src={a.image_url} loading="lazy" alt="" />}
</div>
))}
</div>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import React from 'react';
import { Back, Star, StarFilled, ExternalLink } from '../Icons.jsx';
function formatDate(dateStr) {
if (!dateStr) return '';
return new Date(dateStr).toLocaleDateString(undefined, { year: 'numeric', month: 'long', day: 'numeric' });
}
export default function ArticleView({ article, onBack, onToggleStar }) {
if (!article) {
return (
<div className="reader-pane">
<div className="empty-state" style={{ height: '100%' }}>
<ExternalLink />
<h3>Select an article</h3>
<p>Pick something from the list to start reading.</p>
</div>
</div>
);
}
return (
<div className="reader-pane">
<div className="reader-topbar">
<button className="icon-btn" onClick={onBack} style={{ display: window.innerWidth <= 860 ? 'inline-flex' : 'none' }}>
<Back width={18} height={18} />
</button>
<span className="spacer" />
<button className="icon-btn" onClick={() => onToggleStar(article)} title="Star article">
{article.is_starred ? <StarFilled width={17} height={17} style={{ color: 'var(--amber)' }} /> : <Star width={17} height={17} />}
</button>
{article.link && (
<a className="icon-btn" href={article.link} target="_blank" rel="noreferrer" title="Open original">
<ExternalLink width={16} height={16} />
</a>
)}
</div>
<article className="reader-article">
<div className="eyebrow">
{article.feed_favicon && <img src={article.feed_favicon} width="14" height="14" alt="" style={{ borderRadius: 3 }} />}
<span>{article.feed_title}</span>
</div>
<h1>{article.title}</h1>
<div className="reader-meta">
{article.author && <span>{article.author}</span>}
<span>{formatDate(article.published_at)}</span>
{article.link && (
<a href={article.link} target="_blank" rel="noreferrer">
Original <ExternalLink width={12} height={12} />
</a>
)}
</div>
{article.content
? <div className="reader-body" dangerouslySetInnerHTML={{ __html: article.content }} />
: <div className="reader-body"><p>{article.summary}</p></div>}
</article>
</div>
);
}
@@ -0,0 +1,68 @@
import React, { useRef } from 'react';
import { X, Trash, Upload, Download, Refresh } from '../Icons.jsx';
import { api } from '../api.js';
export default function ManageFeedsModal({ feeds, onClose, onDeleteFeed, onRefreshFeed, onImported }) {
const fileRef = useRef(null);
async function handleImport(e) {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
const result = await api.importOpml(text);
onImported(result);
}
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" style={{ width: 520 }} onClick={(e) => e.stopPropagation()}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<h3 style={{ flex: 1 }}>Manage feeds</h3>
<button className="icon-btn" onClick={onClose}><X width={16} height={16} /></button>
</div>
<p className="modal-sub">{feeds.length} feed{feeds.length === 1 ? '' : 's'} total</p>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<a className="btn" href={api.exportOpml()} download>
<Download width={13} height={13} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
Export OPML
</a>
<button className="btn" onClick={() => fileRef.current.click()}>
<Upload width={13} height={13} style={{ verticalAlign: 'text-bottom', marginRight: 6 }} />
Import OPML
</button>
<input ref={fileRef} type="file" accept=".opml,.xml" style={{ display: 'none' }} onChange={handleImport} />
</div>
<div style={{ maxHeight: 320, overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 10 }}>
{feeds.map((f) => (
<div key={f.id} style={{
display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
borderBottom: '1px solid var(--border)',
}}>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 13.5, fontWeight: 500, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{f.title}
</div>
<div style={{ fontSize: 11.5, color: 'var(--text-faint)', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{f.last_error ? <span style={{ color: 'var(--danger)' }}>Error: {f.last_error}</span> : f.url}
</div>
</div>
<button className="icon-btn" title="Refresh" onClick={() => onRefreshFeed(f.id)}>
<Refresh width={14} height={14} />
</button>
<button className="icon-btn" title="Delete feed" onClick={() => onDeleteFeed(f.id)}>
<Trash width={14} height={14} style={{ color: 'var(--danger)' }} />
</button>
</div>
))}
{feeds.length === 0 && (
<div style={{ padding: 20, textAlign: 'center', color: 'var(--text-faint)', fontSize: 13 }}>
No feeds yet.
</div>
)}
</div>
</div>
</div>
);
}
+120
View File
@@ -0,0 +1,120 @@
import React, { useState } from 'react';
import { Inbox, Star, Rss, Folder, Plus, Refresh, Settings, Sun, Moon, X } from '../Icons.jsx';
export default function Sidebar({
feeds, folders, view, onSelectView, stats, onAddFeed, onRefreshAll,
refreshing, theme, onToggleTheme, onOpenManage, onClose,
}) {
const feedsByFolder = {};
const unfoldered = [];
for (const f of feeds) {
if (f.folder_id) {
feedsByFolder[f.folder_id] = feedsByFolder[f.folder_id] || [];
feedsByFolder[f.folder_id].push(f);
} else {
unfoldered.push(f);
}
}
return (
<div className="sidebar">
<div className="sidebar-brand">
<span className="mark" style={{ display: 'flex' }}><Rss width={20} height={20} /></span>
<h1>Dispatch</h1>
<button className="icon-btn" onClick={onClose} style={{ marginLeft: 'auto', display: window.innerWidth <= 860 ? 'inline-flex' : 'none' }}>
<X width={16} height={16} />
</button>
</div>
<div className="sidebar-scroll">
<div className="nav-section">
<button
className={`nav-item ${view.type === 'unread' ? 'active' : ''}`}
onClick={() => onSelectView({ type: 'unread' })}
>
<Inbox width={16} height={16} />
<span className="label">Unread</span>
<span className="count-badge">{stats.unread || ''}</span>
</button>
<button
className={`nav-item ${view.type === 'starred' ? 'active' : ''}`}
onClick={() => onSelectView({ type: 'starred' })}
>
<Star width={16} height={16} />
<span className="label">Starred</span>
<span className="count-badge">{stats.starred || ''}</span>
</button>
<button
className={`nav-item ${view.type === 'all' ? 'active' : ''}`}
onClick={() => onSelectView({ type: 'all' })}
>
<Rss width={16} height={16} />
<span className="label">All articles</span>
</button>
</div>
<div className="sidebar-group-header"><span>Folders</span><span className="line" /></div>
{folders.map((folder) => (
<div key={folder.id}>
<div className="folder-row">
<button
className={`nav-item ${view.type === 'folder' && view.id === folder.id ? 'active' : ''}`}
onClick={() => onSelectView({ type: 'folder', id: folder.id, title: folder.name })}
>
<Folder width={15} height={15} />
<span className="label">{folder.name}</span>
</button>
</div>
<div className="folder-children">
{(feedsByFolder[folder.id] || []).map((feed) => (
<FeedRow key={feed.id} feed={feed} view={view} onSelectView={onSelectView} />
))}
</div>
</div>
))}
<div className="sidebar-group-header"><span>Feeds</span><span className="line" /></div>
{unfoldered.map((feed) => (
<FeedRow key={feed.id} feed={feed} view={view} onSelectView={onSelectView} />
))}
{feeds.length === 0 && (
<p style={{ color: 'var(--text-faint)', fontSize: 12.5, padding: '6px 10px' }}>
No feeds yet. Add your first one below.
</p>
)}
</div>
<div className="sidebar-footer">
<button className="add-feed-btn" onClick={onAddFeed}>
<Plus width={15} height={15} /> Add feed
</button>
<button className="icon-btn" title="Refresh all feeds" onClick={onRefreshAll} disabled={refreshing}>
<Refresh width={16} height={16} style={{ animation: refreshing ? 'spin 1s linear infinite' : 'none' }} />
</button>
<button className="icon-btn" title="Manage feeds" onClick={onOpenManage}>
<Settings width={16} height={16} />
</button>
<button className="icon-btn" title="Toggle theme" onClick={onToggleTheme}>
{theme === 'dark' ? <Sun width={16} height={16} /> : <Moon width={16} height={16} />}
</button>
</div>
<style>{`@keyframes spin { to { transform: rotate(360deg); } }`}</style>
</div>
);
}
function FeedRow({ feed, view, onSelectView }) {
return (
<button
className={`nav-item ${view.type === 'feed' && view.id === feed.id ? 'active' : ''}`}
onClick={() => onSelectView({ type: 'feed', id: feed.id, title: feed.title })}
title={feed.last_error ? `Last error: ${feed.last_error}` : undefined}
>
{feed.favicon
? <img className="feed-favicon" src={feed.favicon} alt="" />
: <span className="feed-favicon" style={{ display: 'inline-block' }} />}
<span className="label">{feed.title}</span>
<span className="count-badge">{feed.unread_count > 0 ? feed.unread_count : ''}</span>
</button>
);
}
+705
View File
@@ -0,0 +1,705 @@
:root {
--bg: #14181B;
--surface: #1B2024;
--surface-hover: #232A2F;
--surface-raised: #262D32;
--border: #2A3136;
--text: #E8EAE6;
--text-dim: #8B939A;
--text-faint: #5B6268;
--accent: #4FA3A0;
--accent-strong: #6FC2BE;
--accent-wash: rgba(79, 163, 160, 0.14);
--amber: #E0A458;
--amber-wash: rgba(224, 164, 88, 0.14);
--danger: #D97676;
--font-display: 'Fraunces', Georgia, serif;
--font-body: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
--font-mono: 'IBM Plex Mono', 'SF Mono', monospace;
--radius-s: 6px;
--radius-m: 10px;
--radius-l: 16px;
color-scheme: dark;
}
[data-theme="light"] {
--bg: #F4F5F0;
--surface: #FFFFFF;
--surface-hover: #EEEFE9;
--surface-raised: #FFFFFF;
--border: #E0E2DA;
--text: #1A1D1F;
--text-dim: #6B7178;
--text-faint: #9BA1A6;
--accent: #2C7A73;
--accent-strong: #1F5F59;
--accent-wash: rgba(44, 122, 115, 0.09);
--amber: #B97A2E;
--amber-wash: rgba(185, 122, 46, 0.1);
--danger: #B94A4A;
color-scheme: light;
}
* { box-sizing: border-box; }
html, body, #root {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
overflow: hidden;
}
a { color: var(--accent-strong); }
button {
font-family: inherit;
cursor: pointer;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 10px; }
::-webkit-scrollbar-thumb:hover { background: var(--text-faint); }
:focus-visible {
outline: 2px solid var(--accent-strong);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}
/* ---------- App shell ---------- */
.app {
display: grid;
grid-template-columns: 260px 380px 1fr;
height: 100vh;
height: 100dvh;
}
@media (max-width: 1100px) {
.app { grid-template-columns: 240px 340px 1fr; }
}
@media (max-width: 860px) {
.app {
display: block;
height: 100dvh;
position: relative;
overflow: hidden;
}
}
/* ---------- Sidebar ---------- */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100%;
}
.sidebar-brand {
display: flex;
align-items: baseline;
gap: 8px;
padding: 20px 18px 14px;
}
.sidebar-brand .mark {
color: var(--accent);
}
.sidebar-brand h1 {
font-family: var(--font-display);
font-size: 21px;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0;
}
.sidebar-brand .tick {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-faint);
letter-spacing: 0.08em;
text-transform: uppercase;
}
.sidebar-scroll {
flex: 1;
overflow-y: auto;
padding: 4px 10px 12px;
}
.nav-section {
margin-bottom: 6px;
}
.nav-item {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 8px 10px;
border: none;
background: transparent;
border-radius: var(--radius-s);
color: var(--text-dim);
font-size: 14px;
text-align: left;
position: relative;
}
.nav-item svg { flex-shrink: 0; opacity: 0.85; }
.nav-item:hover { background: var(--surface-hover); color: var(--text); }
.nav-item.active {
background: var(--accent-wash);
color: var(--text);
}
.nav-item.active::before {
content: '';
position: absolute;
left: -10px;
top: 6px;
bottom: 6px;
width: 3px;
border-radius: 3px;
background: var(--accent);
}
.nav-item .label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.count-badge {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-faint);
min-width: 20px;
text-align: right;
}
.nav-item.active .count-badge, .nav-item:hover .count-badge { color: var(--accent-strong); }
.feed-favicon {
width: 15px;
height: 15px;
border-radius: 3px;
flex-shrink: 0;
background: var(--surface-hover);
}
.sidebar-group-header {
display: flex;
align-items: center;
gap: 6px;
padding: 14px 10px 6px;
font-family: var(--font-mono);
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.09em;
color: var(--text-faint);
}
.sidebar-group-header .line {
flex: 1;
height: 1px;
background: var(--border);
}
.folder-row {
display: flex;
align-items: center;
gap: 6px;
}
.folder-row .nav-item { flex: 1; }
.folder-children {
padding-left: 16px;
border-left: 1px solid var(--border);
margin-left: 20px;
}
.sidebar-footer {
padding: 10px 12px;
border-top: 1px solid var(--border);
display: flex;
gap: 6px;
}
.icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-s);
border: 1px solid transparent;
background: transparent;
color: var(--text-dim);
}
.icon-btn:hover {
background: var(--surface-hover);
color: var(--text);
}
.icon-btn.primary {
background: var(--accent);
color: #0d1414;
}
.icon-btn.primary:hover { background: var(--accent-strong); }
.add-feed-btn {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 10px;
border-radius: var(--radius-s);
border: 1px dashed var(--border);
background: transparent;
color: var(--text-dim);
font-size: 13px;
}
.add-feed-btn:hover { border-color: var(--accent); color: var(--text); }
/* ---------- Article list ---------- */
.article-list-pane {
background: var(--bg);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
}
.list-topbar {
display: flex;
align-items: center;
gap: 10px;
padding: 14px 16px 10px;
border-bottom: 1px solid var(--border);
}
.list-topbar h2 {
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
margin: 0;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-box {
display: flex;
align-items: center;
gap: 8px;
margin: 10px 16px;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-s);
background: var(--surface);
color: var(--text-faint);
}
.search-box svg { flex-shrink: 0; }
.search-box input {
border: none;
background: transparent;
outline: none;
color: var(--text);
font-size: 13px;
width: 100%;
}
.article-scroll {
flex: 1;
overflow-y: auto;
}
.article-row {
display: flex;
gap: 12px;
padding: 13px 16px;
border-bottom: 1px solid var(--border);
cursor: pointer;
position: relative;
border-left: 3px solid transparent;
}
.article-row:hover { background: var(--surface); }
.article-row.selected {
background: var(--accent-wash);
border-left-color: var(--accent);
}
.article-row.unread .row-title { font-weight: 600; }
.article-row .signal {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--accent);
flex-shrink: 0;
margin-top: 6px;
}
.article-row.read .signal { background: transparent; }
.article-row-content { flex: 1; min-width: 0; }
.row-meta {
display: flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--text-faint);
text-transform: uppercase;
letter-spacing: 0.04em;
margin-bottom: 4px;
}
.row-title {
font-size: 14.5px;
line-height: 1.35;
margin: 0 0 4px;
color: var(--text);
}
.row-summary {
font-size: 12.5px;
color: var(--text-dim);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.row-thumb {
width: 64px;
height: 64px;
border-radius: var(--radius-s);
object-fit: cover;
flex-shrink: 0;
background: var(--surface-hover);
}
.row-star { color: var(--amber); flex-shrink: 0; margin-top: 2px; }
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
padding: 40px;
text-align: center;
color: var(--text-faint);
gap: 10px;
}
.empty-state svg { opacity: 0.4; width: 40px; height: 40px; }
.empty-state h3 { font-family: var(--font-display); color: var(--text-dim); margin: 0; font-weight: 600; }
.empty-state p { margin: 0; font-size: 13px; max-width: 280px; }
/* ---------- Reading pane ---------- */
.reader-pane {
height: 100%;
overflow-y: auto;
background: var(--bg);
}
.reader-topbar {
position: sticky;
top: 0;
display: flex;
align-items: center;
gap: 8px;
padding: 12px 20px;
background: var(--bg);
border-bottom: 1px solid var(--border);
z-index: 5;
}
.reader-topbar .spacer { flex: 1; }
.reader-article {
max-width: 700px;
margin: 0 auto;
padding: 36px 32px 80px;
}
.reader-article .eyebrow {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--accent-strong);
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 14px;
}
.reader-article h1 {
font-family: var(--font-display);
font-size: 32px;
line-height: 1.22;
font-weight: 600;
letter-spacing: -0.01em;
margin: 0 0 14px;
}
.reader-meta {
display: flex;
align-items: center;
gap: 14px;
color: var(--text-dim);
font-size: 13px;
margin-bottom: 28px;
padding-bottom: 24px;
border-bottom: 1px solid var(--border);
}
.reader-meta a {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--accent-strong);
text-decoration: none;
}
.reader-meta a:hover { text-decoration: underline; }
.reader-body {
font-size: 17px;
line-height: 1.75;
color: var(--text);
}
.reader-body img, .reader-body iframe {
max-width: 100%;
border-radius: var(--radius-m);
height: auto;
margin: 8px 0;
}
.reader-body p { margin: 0 0 1.2em; }
.reader-body a { text-decoration: underline; text-underline-offset: 2px; }
.reader-body h1, .reader-body h2, .reader-body h3 {
font-family: var(--font-display);
margin: 1.4em 0 0.6em;
}
.reader-body blockquote {
border-left: 3px solid var(--accent);
margin: 1.4em 0;
padding: 2px 0 2px 18px;
color: var(--text-dim);
font-style: italic;
}
.reader-body pre {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-s);
padding: 14px;
overflow-x: auto;
font-family: var(--font-mono);
font-size: 13px;
}
.reader-body code {
font-family: var(--font-mono);
font-size: 0.9em;
background: var(--surface);
padding: 2px 5px;
border-radius: 4px;
}
.reader-body ul, .reader-body ol { padding-left: 1.4em; margin: 0 0 1.2em; }
.reader-body figure { margin: 1.4em 0; }
.reader-body figcaption { font-size: 13px; color: var(--text-faint); margin-top: 6px; }
/* ---------- Modal ---------- */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 12vh;
z-index: 50;
}
.modal {
background: var(--surface-raised);
border: 1px solid var(--border);
border-radius: var(--radius-l);
width: 440px;
max-width: 92vw;
padding: 22px;
box-shadow: 0 24px 60px rgba(0,0,0,0.4);
}
.modal h3 {
font-family: var(--font-display);
margin: 0 0 4px;
font-size: 20px;
}
.modal .modal-sub { color: var(--text-dim); font-size: 13px; margin: 0 0 18px; }
.field { margin-bottom: 14px; }
.field label {
display: block;
font-size: 12px;
color: var(--text-dim);
margin-bottom: 6px;
font-family: var(--font-mono);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.field input, .field select {
width: 100%;
padding: 9px 11px;
border-radius: var(--radius-s);
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
font-size: 14px;
outline: none;
}
.field input:focus, .field select:focus { border-color: var(--accent); }
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
.btn {
padding: 8px 16px;
border-radius: var(--radius-s);
border: 1px solid var(--border);
background: transparent;
color: var(--text);
font-size: 13.5px;
font-weight: 500;
}
.btn:hover { background: var(--surface-hover); }
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #0d1414;
}
.btn.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
.btn.danger { color: var(--danger); border-color: var(--danger); }
.btn.danger:hover { background: rgba(217, 118, 118, 0.1); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.form-error {
background: var(--amber-wash);
color: var(--amber);
border: 1px solid var(--amber);
border-radius: var(--radius-s);
padding: 8px 10px;
font-size: 12.5px;
margin-bottom: 12px;
}
/* ---------- Mobile ---------- */
.mobile-topbar {
display: none;
}
@media (max-width: 860px) {
.sidebar, .article-list-pane, .reader-pane-wrap {
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
width: 100%;
transition: transform 0.28s ease;
}
.sidebar { z-index: 30; transform: translateX(-100%); border-right: none; }
.sidebar.open { transform: translateX(0); box-shadow: 8px 0 24px rgba(0,0,0,0.3); }
.article-list-pane { z-index: 10; border-right: none; }
.reader-pane-wrap {
z-index: 20;
background: var(--bg);
transform: translateX(100%);
}
.reader-pane-wrap.open { transform: translateX(0); }
.mobile-scrim {
position: absolute; inset: 0; background: rgba(0,0,0,0.4); z-index: 25;
}
.mobile-topbar {
display: flex;
}
.reader-article { padding: 24px 18px 60px; }
.reader-article h1 { font-size: 25px; }
.reader-body { font-size: 16px; }
}
.brand-tag {
font-family: var(--font-mono);
font-size: 10px;
color: var(--text-faint);
}
.theme-toggle {
display: flex;
align-items: center;
gap: 4px;
}
.loading-line {
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), transparent);
background-size: 200% 100%;
animation: shimmer 1.4s infinite;
}
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
.toast {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
background: var(--surface-raised);
border: 1px solid var(--border);
padding: 10px 16px;
border-radius: var(--radius-m);
font-size: 13px;
z-index: 100;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App.jsx';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>
);
+15
View File
@@ -0,0 +1,15 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:8080',
},
},
build: {
outDir: '../backend/public',
emptyOutDir: true,
},
});