- Keep interactions fast and keyboard-friendly.
- Keep state predictable by encoding major UI state in URL params.
- Prioritize readability and simple information architecture.
| Area | Choice |
|---|---|
| Framework | React 19 + TypeScript |
| Build | Vite |
| Router | TanStack Router |
| Data fetching/cache | TanStack Query |
| State | Zustand (UI-only state) |
| UI system | shadcn/ui + Tailwind CSS |
| Route pattern | Purpose |
|---|---|
/ |
Canonical redirect to /unread |
/:filter |
Top-level reading view |
/feeds/:feedId/:filter |
Feed-scoped reading view |
/groups/:groupId/:filter |
Group-scoped reading view |
/feeds |
Feed/group management |
/login |
Password/OIDC login |
Reading state is split between path params and search params:
| Location | Key | Type | Meaning |
|---|---|---|---|
| Path param | filter |
all | unread | starred |
Active article filter |
| Path param | feedId |
number | Selected feed scope |
| Path param | groupId |
number | Selected group scope |
| Search param | article |
number | Opened article in drawer |
Examples:
/unread/feeds/6/unread/groups/3/starred?article=289
This keeps list context stable while opening/closing article detail.
- Fixed left sidebar (
300px) - Main content on the right
- Article detail opens in right-side drawer
- Sidebar is a left sheet/drawer
- Main content remains single-column
- Modals/drawers share the same UI flow as desktop
- App branding
- Search entry (
Cmd/Ctrl + K) - Feed tree (All, groups, feeds)
- Footer actions: Manage Feeds, Settings
- Header with page title and "Mark all as read"
- Filter tabs: All / Unread / Starred
- Infinite article list (load more)
- Article cards with quick actions (read/unread, star)
- Shows full article content (sanitized HTML)
- Supports previous/next navigation
- Includes source link and feed metadata
- Grouped feed list with search + status filter
- Group actions: rename, delete (except default group)
- Feed actions: edit
- Bulk/system actions: refresh all, OPML import/export, add feed/group
- API layer lives in
frontend/src/lib/api/ - Query logic lives in
frontend/src/queries/ - TanStack Query handles:
- infinite pagination for items
- optimistic read/unread updates
- cache invalidation after mutations
- Zustand stores transient UI state only (dialogs, mobile sidebar, edit targets)
- Endpoint:
GET /api/search - Searches feeds and items in one request
- Results open feed context or article drawer
- "Starred" view is powered by bookmarks
- Bookmarks are content snapshots, so starred items survive source deletion
Implemented shortcuts:
Cmd/Ctrl + K: toggle search dialogCmd/Ctrl + ,: open settings dialogEsc: close search/settings/article drawer/: open search dialog?: open keyboard shortcuts helpj/n/ArrowDown: next articlek/p/ArrowUp: previous articlem: toggle read/unread for current articles/f: toggle star for current articleo/v: open current article in browserg u: go to unreadg a: go to allg s: go to starredg f: go to feed management
Shortcut help entry points:
- Sidebar search button hint shows
Cmd+K / ? - Search dialog Quick Actions includes a "Keyboard Shortcuts" item
- Settings > Appearance includes a "Keyboard Shortcuts" section
- Password login is available when password auth is enabled
- If password is empty and OIDC is not configured, the UI is directly accessible without
/login - When OIDC is enabled, login page shows "Sign in with OIDC"
- OIDC callback failure is surfaced as
/login?error=oidc_failed
frontend/src/routes/$filter.lazy.tsx: top-level reading pagefrontend/src/routes/feeds_.$feedId.$filter.lazy.tsx: feed-scoped reading pagefrontend/src/routes/groups.$groupId.$filter.lazy.tsx: group-scoped reading pagefrontend/src/routes/feeds.lazy.tsx: feed management pagefrontend/src/routes/login.lazy.tsx: login pagefrontend/src/components/article/article-page.tsx: shared reading page wrapperfrontend/src/components/article/article-list.tsx: list + tabs + bulk readfrontend/src/components/article/article-drawer.tsx: article detailfrontend/src/components/feed/feed-list.tsx: sidebar feed tree
TanStack file-based routes can infer parent-child nesting from file names. We intentionally keep management page /feeds and reading page /feeds/:feedId/:filter as separate route trees.
frontend/src/routes/feeds.lazy.tsxmaps to management page/feedsfrontend/src/routes/feeds_.$feedId.$filter.lazy.tsxmaps to reading page/feeds/:feedId/:filter
The feeds_ prefix is a routing implementation detail to avoid accidental nesting under the management page route while preserving the final URL path as /feeds/....
- Type check:
cd frontend && npx tsc -b --noEmit - Lint:
cd frontend && pnpm lint - Production build:
cd frontend && pnpm build - Smoke test flows:
- login/logout
- search open + result navigation
- feed/group selection and URL sync
- read/unread + starred updates
/feedspage operations (add/edit/delete/import/export)