diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 0000000..e10cc31
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+**/node_modules
+backend/data
+backend/data-test
+backend/public
+frontend/dist
+.git
+.gitignore
+*.md
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3fe9dc8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+data/
+data-test/
+backend/public/
+frontend/dist/
+*.log
+.DS_Store
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 0000000..27adbbf
--- /dev/null
+++ b/Dockerfile
@@ -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"]
diff --git a/README.md b/README.md
index df59d10..d56b93f 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..eef1146
--- /dev/null
+++ b/docker-compose.yml
@@ -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
diff --git a/frontend/index.html b/frontend/index.html
new file mode 100644
index 0000000..a842c94
--- /dev/null
+++ b/frontend/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+ {(sidebarOpen) &&
setSidebarOpen(false)} />}
+
+ { setView(v); setSidebarOpen(false); }}
+ onAddFeed={() => setShowAddFeed(true)}
+ onRefreshAll={handleRefreshAll}
+ onOpenManage={() => setShowManage(true)}
+ onToggleTheme={() => setTheme((t) => (t === 'dark' ? 'light' : 'dark'))}
+ onClose={() => setSidebarOpen(false)}
+ />
+
+
+
setSidebarOpen(true)}
+ search={search}
+ onSearch={setSearch}
+ onMarkAllRead={handleMarkAllRead}
+ loading={loadingArticles}
+ />
+
+
+
setReaderOpen(false)}
+ onToggleStar={handleToggleStar}
+ />
+
+
+ {showAddFeed && (
+ setShowAddFeed(false)} />
+ )}
+ {showManage && (
+ setShowManage(false)}
+ onDeleteFeed={handleDeleteFeed}
+ onRefreshFeed={handleRefreshFeed}
+ onImported={(result) => { showToast(`Imported ${result.results.filter(r => r.status === 'added').length} feeds`); loadSidebarData(); }}
+ />
+ )}
+ {toast && {toast}
}
+
+ );
+}
diff --git a/frontend/src/Icons.jsx b/frontend/src/Icons.jsx
new file mode 100644
index 0000000..efbe3c3
--- /dev/null
+++ b/frontend/src/Icons.jsx
@@ -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) => (
+
+);
+export const Back = (p) => (
+
+);
+export const Star = (p) => (
+
+);
+export const StarFilled = (p) => (
+
+);
+export const Refresh = (p) => (
+
+);
+export const Plus = (p) => (
+
+);
+export const Folder = (p) => (
+
+);
+export const Rss = (p) => (
+
+);
+export const Search = (p) => (
+
+);
+export const ExternalLink = (p) => (
+
+);
+export const Check = (p) => (
+
+);
+export const CheckCircle = (p) => (
+
+);
+export const X = (p) => (
+
+);
+export const Settings = (p) => (
+
+);
+export const Trash = (p) => (
+
+);
+export const Inbox = (p) => (
+
+);
+export const Upload = (p) => (
+
+);
+export const Download = (p) => (
+
+);
+export const ChevronDown = (p) => (
+
+);
+export const Sun = (p) => (
+
+);
+export const Moon = (p) => (
+
+);
diff --git a/frontend/src/api.js b/frontend/src/api.js
new file mode 100644
index 0000000..e83ac88
--- /dev/null
+++ b/frontend/src/api.js
@@ -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'),
+};
diff --git a/frontend/src/components/AddFeedModal.jsx b/frontend/src/components/AddFeedModal.jsx
new file mode 100644
index 0000000..f87f978
--- /dev/null
+++ b/frontend/src/components/AddFeedModal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
Add a feed
+
Paste the URL of an RSS or Atom feed, or a site — we'll try to find it.
+ {error &&
{error}
}
+
+
+
+ );
+}
diff --git a/frontend/src/components/ArticleList.jsx b/frontend/src/components/ArticleList.jsx
new file mode 100644
index 0000000..e8bf16a
--- /dev/null
+++ b/frontend/src/components/ArticleList.jsx
@@ -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 (
+
+
+
+
+
+
{title}
+
+
+
+
+ onSearch(e.target.value)}
+ />
+
+ {loading &&
}
+
+ {!loading && articles.length === 0 && (
+
+
+
Nothing here
+
You're all caught up, or this view is waiting on its first fetch.
+
+ )}
+ {articles.map((a) => (
+
onSelect(a)}
+ >
+
+
+
+ {a.feed_title}
+ ·
+ {timeAgo(a.published_at)}
+
+
{a.title}
+
{a.summary}
+
+ {a.is_starred ?
: null}
+ {a.image_url &&

}
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/components/ArticleView.jsx b/frontend/src/components/ArticleView.jsx
new file mode 100644
index 0000000..f652251
--- /dev/null
+++ b/frontend/src/components/ArticleView.jsx
@@ -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 (
+
+
+
+
Select an article
+
Pick something from the list to start reading.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+
+ {article.link && (
+
+
+
+ )}
+
+
+
+ {article.feed_favicon &&

}
+
{article.feed_title}
+
+ {article.title}
+
+ {article.author &&
{article.author}}
+
{formatDate(article.published_at)}
+ {article.link && (
+
+ Original
+
+ )}
+
+ {article.content
+ ?
+ : }
+
+
+ );
+}
diff --git a/frontend/src/components/ManageFeedsModal.jsx b/frontend/src/components/ManageFeedsModal.jsx
new file mode 100644
index 0000000..67031d8
--- /dev/null
+++ b/frontend/src/components/ManageFeedsModal.jsx
@@ -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 (
+
+
e.stopPropagation()}>
+
+
Manage feeds
+
+
+
{feeds.length} feed{feeds.length === 1 ? '' : 's'} total
+
+
+
+
+ {feeds.map((f) => (
+
+
+
+ {f.title}
+
+
+ {f.last_error ? Error: {f.last_error} : f.url}
+
+
+
+
+
+ ))}
+ {feeds.length === 0 && (
+
+ No feeds yet.
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx
new file mode 100644
index 0000000..677f3f2
--- /dev/null
+++ b/frontend/src/components/Sidebar.jsx
@@ -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 (
+
+
+
+
Dispatch
+
+
+
+
+
+
+
+
+
+
+
Folders
+ {folders.map((folder) => (
+
+
+
+
+
+ {(feedsByFolder[folder.id] || []).map((feed) => (
+
+ ))}
+
+
+ ))}
+
+
Feeds
+ {unfoldered.map((feed) => (
+
+ ))}
+ {feeds.length === 0 && (
+
+ No feeds yet. Add your first one below.
+
+ )}
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+function FeedRow({ feed, view, onSelectView }) {
+ return (
+
+ );
+}
diff --git a/frontend/src/index.css b/frontend/src/index.css
new file mode 100644
index 0000000..d131e76
--- /dev/null
+++ b/frontend/src/index.css
@@ -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);
+}
diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx
new file mode 100644
index 0000000..7497ae8
--- /dev/null
+++ b/frontend/src/main.jsx
@@ -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(
+
+
+
+);
diff --git a/frontend/vite.config.js b/frontend/vite.config.js
new file mode 100644
index 0000000..f406070
--- /dev/null
+++ b/frontend/vite.config.js
@@ -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,
+ },
+});