feat: scaffold newsfeed — user-controlled news feed
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped

A self-hosted feed where the user owns the ranking: per-source and signed
per-interest weights, decayed by recency, via a transparent deterministic
scorer. Content is sourced algorithmically (worker RSS/Atom polling) and
agentically (per-user API tokens POSTing candidates to the ingest endpoint).
Single-user today, multi-user by construction (every row keyed on user_id).

Rust cargo workspace + Vite/React/SWC/TS SPA:
- newsfeed-entities: DTOs (ts-rs bindings -> web/src/api/bindings)
- newsfeed-core: ranking, auth primitives, ingest, data-access ports
- newsfeed-data: SQLite adapters (sqlx, runtime queries)
- newsfeed-api: axum REST/JSON daemon
- newsfeed-worker: RSS polling + rescoring loop
- web: responsive, mobile-first SPA (React Query, generated types)

Deploy (Gitea Actions, build static musl + SPA, rsync as gitea_ci):
api+worker -> slartibartfast, SPA -> oolon (nginx serves + proxies /v1).

Deliberate deviations from house conventions (documented in CLAUDE.md/readme):
- SQLite instead of Postgres; api+worker co-locate sharing one DB file.
- Runtime sqlx queries instead of query! macros (SQLite dynamic typing;
  keeps CI database-free).

Verified end-to-end: auth, token ingest, interest-weighted ranking, signals,
pagination (curl + browser); cargo fmt/clippy -D/test and pnpm build/lint pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
This commit is contained in:
2026-07-08 11:36:57 +03:00
commit 5ce52fff4d
104 changed files with 10963 additions and 0 deletions

42
web/src/App.tsx Normal file
View File

@@ -0,0 +1,42 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import { AuthProvider, useAuth } from './lib/auth';
import { Nav } from './components/Nav';
import { Login } from './routes/Login';
import { Feed } from './routes/Feed';
import { Sources } from './routes/Sources';
import { Settings } from './routes/Settings';
function Shell() {
const { user, loading } = useAuth();
if (loading) {
return <div className="center muted">Loading</div>;
}
if (!user) {
return <Login />;
}
return (
<div className="app">
<Nav />
<main className="content">
<Routes>
<Route path="/" element={<Feed />} />
<Route path="/sources" element={<Sources />} />
<Route path="/settings" element={<Settings />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</main>
</div>
);
}
export function App() {
return (
<AuthProvider>
<Shell />
</AuthProvider>
);
}

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Public metadata about an API token. The secret itself is shown exactly once, at
* creation, and is never retrievable afterwards.
*/
export type ApiTokenInfo = { id: string, user_id: string, name: string,
/**
* Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
*/
prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, };

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { Media } from "./Media";
/**
* A candidate pushed to `POST /v1/ingest/candidates` by an agentic or algorithmic
* source. The authenticated API token determines the owning user; `source` names the
* producer for attribution and per-source weighting.
*/
export type CandidateSubmission = {
/**
* Stable de-dup key from the producer. Re-submitting the same key is a no-op.
*/
external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array<string>, media: Array<Media>, published_at: string | null,
/**
* Optional name of the source that produced this candidate. If it matches an
* existing agentic source for the user it is linked; otherwise ingested unlinked.
*/
source: string | null, };

View File

@@ -0,0 +1,16 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ItemState } from "./ItemState";
import type { Media } from "./Media";
/**
* A content item owned by a user.
*/
export type ContentItem = { id: string, user_id: string, source_id: string | null,
/**
* De-duplication key from the origin (feed guid, tweet id, URL, …).
*/
external_id: string, url: string | null, title: string, summary: string | null, author: string | null, tags: Array<string>, media: Array<Media>, published_at: string | null, ingested_at: string, state: ItemState,
/**
* Cached ranking score; recomputed by the ranker as weights/signals change.
*/
score: number, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Request to mint a new API token.
*/
export type CreateApiToken = { name: string, };

View File

@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Response returned once when a token is created. `secret` is the full bearer token
* (`nf_<prefix>_<random>`) and is never persisted in the clear.
*/
export type CreatedApiToken = {
/**
* The full secret. Present only in this response.
*/
secret: string, id: string, user_id: string, name: string,
/**
* Short non-secret prefix (e.g. `nf_a1b2c3`) to help the user identify the token.
*/
prefix: string, created_at: string, last_used_at: string | null, revoked_at: string | null, };

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ContentItem } from "./ContentItem";
/**
* A page of ranked feed items.
*/
export type FeedPage = { items: Array<ContentItem>,
/**
* Cursor to pass back for the next page, or `None` at the end.
*/
next_cursor: string | null, };

View File

@@ -0,0 +1,18 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Query parameters for `GET /v1/feed`.
*/
export type FeedQuery = {
/**
* Max items to return. Defaults applied server-side.
*/
limit: number | null,
/**
* Opaque cursor for pagination (score+id of the last item seen).
*/
cursor: string | null,
/**
* When true, include saved items; otherwise only live feed items.
*/
include_saved: boolean, };

View File

@@ -0,0 +1,15 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A weighted topic of interest belonging to a single user.
*/
export type Interest = { id: string, user_id: string,
/**
* Free-text label matched (case-insensitively) against item title/summary/tags.
*/
label: string,
/**
* How strongly this interest pulls matching items up the feed, `-1.0..=1.0`.
* Negative weights actively bury matching content.
*/
weight: number, created_at: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Lifecycle state of a content item.
*/
export type ItemState = "candidate" | "feed" | "saved" | "dismissed";

View File

@@ -0,0 +1,10 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Login request.
*/
export type LoginRequest = {
/**
* Username or email.
*/
identifier: string, password: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The current authenticated principal, returned by `GET /v1/auth/me`.
*/
export type Me = { id: string, username: string, email: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A media attachment (image/video/audio) associated with an item.
*/
export type Media = { kind: string, url: string, width: number | null, height: number | null, };

View File

@@ -0,0 +1,11 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SourceKind } from "./SourceKind";
/**
* Request to create a source.
*/
export type NewSource = { kind: SourceKind, name: string, url: string | null,
/**
* Defaults to `1.0` when omitted.
*/
weight: number | null, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Registration request from the sign-up form.
*/
export type RegisterRequest = { username: string, email: string, password: string, };

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SignalAction } from "./SignalAction";
/**
* A recorded interaction, posted to `POST /v1/feed/signals`.
*/
export type Signal = { item_id: string, action: SignalAction, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* The kinds of interaction the user can have with an item.
*/
export type SignalAction = "view" | "click" | "save" | "dismiss";

View File

@@ -0,0 +1,19 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { SourceKind } from "./SourceKind";
/**
* A user's configured content source.
*/
export type Source = { id: string, user_id: string, kind: SourceKind, name: string,
/**
* Poll URL for [`SourceKind::Rss`]; unused/optional for agentic sources.
*/
url: string | null,
/**
* Baseline weight applied to every item from this source, `0.0..=1.0`.
*/
weight: number, enabled: boolean,
/**
* When the worker last polled this source (rss only).
*/
last_polled_at: string | null, created_at: string, };

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* How a source produces candidates.
*/
export type SourceKind = "rss" | "agentic";

View File

@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* Create or update an interest.
*/
export type UpsertInterest = { label: string, weight: number, };

View File

@@ -0,0 +1,7 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
/**
* A registered user. The password hash never leaves `newsfeed-data`/`newsfeed-core`;
* it is deliberately absent from this DTO so it can't be serialised to a client.
*/
export type User = { id: string, username: string, email: string, created_at: string, };

84
web/src/api/client.ts Normal file
View File

@@ -0,0 +1,84 @@
// Thin typed client over the newsfeed API. Types are the generated bindings from the
// Rust `newsfeed-entities` crate (see src/api/bindings/, regenerate with `pnpm gen:types`).
// Session auth rides on an HttpOnly cookie, so every request sends credentials.
import type { Me } from './bindings/Me';
import type { RegisterRequest } from './bindings/RegisterRequest';
import type { LoginRequest } from './bindings/LoginRequest';
import type { ApiTokenInfo } from './bindings/ApiTokenInfo';
import type { CreateApiToken } from './bindings/CreateApiToken';
import type { CreatedApiToken } from './bindings/CreatedApiToken';
import type { Source } from './bindings/Source';
import type { NewSource } from './bindings/NewSource';
import type { Interest } from './bindings/Interest';
import type { UpsertInterest } from './bindings/UpsertInterest';
import type { FeedPage } from './bindings/FeedPage';
import type { Signal } from './bindings/Signal';
const BASE = import.meta.env.VITE_API_BASE_URL ?? '';
/** Error carrying the HTTP status so callers (e.g. 401 → login) can branch on it. */
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
}
}
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
method,
credentials: 'include',
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (!res.ok) {
let message = res.statusText;
try {
const data = await res.json();
if (data && typeof data.error === 'string') message = data.error;
} catch {
/* non-JSON error body */
}
throw new ApiError(res.status, message);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
// auth
me: () => request<Me>('GET', '/v1/auth/me'),
register: (req: RegisterRequest) => request<Me>('POST', '/v1/auth/register', req),
login: (req: LoginRequest) => request<Me>('POST', '/v1/auth/login', req),
logout: () => request<void>('POST', '/v1/auth/logout'),
// feed
feed: (opts: { cursor?: string; includeSaved?: boolean } = {}) => {
const q = new URLSearchParams();
if (opts.cursor) q.set('cursor', opts.cursor);
if (opts.includeSaved) q.set('include_saved', 'true');
const qs = q.toString();
return request<FeedPage>('GET', `/v1/feed${qs ? `?${qs}` : ''}`);
},
signal: (sig: Signal) => request<void>('POST', '/v1/feed/signals', sig),
// sources
listSources: () => request<Source[]>('GET', '/v1/sources'),
createSource: (s: NewSource) => request<Source>('POST', '/v1/sources', s),
deleteSource: (id: string) => request<void>('DELETE', `/v1/sources/${id}`),
// interests
listInterests: () => request<Interest[]>('GET', '/v1/interests'),
upsertInterest: (i: UpsertInterest) => request<Interest>('PUT', '/v1/interests', i),
deleteInterest: (id: string) => request<void>('DELETE', `/v1/interests/${id}`),
// api tokens
listTokens: () => request<ApiTokenInfo[]>('GET', '/v1/tokens'),
createToken: (t: CreateApiToken) => request<CreatedApiToken>('POST', '/v1/tokens', t),
revokeToken: (id: string) => request<void>('DELETE', `/v1/tokens/${id}`),
};

View File

@@ -0,0 +1,65 @@
import { api } from '../api/client';
import type { ContentItem } from '../api/bindings/ContentItem';
import type { SignalAction } from '../api/bindings/SignalAction';
interface Props {
item: ContentItem;
onSignal: (action: SignalAction) => void;
}
export function FeedItem({ item, onSignal }: Props) {
const image = item.media.find((m) => m.kind.startsWith('image'))?.url;
const published = item.published_at ? new Date(item.published_at).toLocaleDateString() : null;
const openAndClick = () => {
onSignal('click');
// Fire-and-forget signal; navigation proceeds regardless.
void api.signal({ item_id: item.id, action: 'click' }).catch(() => {});
};
return (
<article className="feed-item card">
{image && (
<a href={item.url ?? '#'} target="_blank" rel="noreferrer" onClick={openAndClick} className="feed-thumb">
<img src={image} alt="" loading="lazy" />
</a>
)}
<div className="feed-body">
<div className="feed-meta">
<span className="score" title="Your ranking score">
{item.score.toFixed(2)}
</span>
{item.author && <span className="muted">{item.author}</span>}
{published && <span className="muted">{published}</span>}
</div>
<h2 className="feed-title">
{item.url ? (
<a href={item.url} target="_blank" rel="noreferrer" onClick={openAndClick}>
{item.title}
</a>
) : (
item.title
)}
</h2>
{item.summary && <p className="feed-summary">{item.summary}</p>}
{item.tags.length > 0 && (
<div className="tags">
{item.tags.map((t) => (
<span key={t} className="tag">
{t}
</span>
))}
</div>
)}
<div className="feed-actions">
<button onClick={() => onSignal('save')} disabled={item.state === 'saved'}>
{item.state === 'saved' ? '★ Saved' : '☆ Save'}
</button>
<button onClick={() => onSignal('dismiss')} className="ghost">
Dismiss
</button>
</div>
</div>
</article>
);
}

View File

@@ -0,0 +1,38 @@
// Responsive navigation: a bottom tab bar on mobile, a left rail on desktop (driven
// entirely by CSS media queries against .nav).
import { NavLink } from 'react-router-dom';
import { useAuth } from '../lib/auth';
const links = [
{ to: '/', label: 'Feed', icon: '📰', end: true },
{ to: '/sources', label: 'Sources', icon: '🌐', end: false },
{ to: '/settings', label: 'Settings', icon: '⚙️', end: false },
];
export function Nav() {
const { user, logout } = useAuth();
return (
<nav className="nav">
<div className="nav-brand">newsfeed</div>
<ul className="nav-links">
{links.map((l) => (
<li key={l.to}>
<NavLink to={l.to} end={l.end} className={({ isActive }) => (isActive ? 'active' : '')}>
<span className="nav-icon" aria-hidden>
{l.icon}
</span>
<span className="nav-label">{l.label}</span>
</NavLink>
</li>
))}
</ul>
<button className="nav-logout" onClick={logout} title={`Log out ${user?.username ?? ''}`}>
<span className="nav-icon" aria-hidden>
</span>
<span className="nav-label">Log out</span>
</button>
</nav>
);
}

438
web/src/index.css Normal file
View File

@@ -0,0 +1,438 @@
:root {
--bg: #0b0d10;
--surface: #14181d;
--surface-2: #1b2027;
--border: #262d36;
--text: #e6e9ee;
--muted: #8a93a0;
--accent: #4c8dff;
--accent-contrast: #ffffff;
--pos: #43c47a;
--neg: #ff6b6b;
--radius: 14px;
--nav-h: 60px;
color-scheme: dark;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f5f6f8;
--surface: #ffffff;
--surface-2: #f0f2f5;
--border: #e2e6eb;
--text: #10151b;
--muted: #62707f;
--accent: #2f6fe0;
color-scheme: light;
}
}
* {
box-sizing: border-box;
}
html,
body,
#root {
height: 100%;
margin: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
font-size: 16px;
line-height: 1.45;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
button {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: var(--surface-2);
color: var(--text);
border-radius: 10px;
padding: 0.5rem 0.9rem;
transition: filter 0.15s ease;
}
button:hover:not(:disabled) {
filter: brightness(1.15);
}
button:disabled {
opacity: 0.5;
cursor: default;
}
button.primary {
background: var(--accent);
color: var(--accent-contrast);
border-color: transparent;
font-weight: 600;
}
button.ghost {
background: transparent;
}
button.link {
border: none;
background: none;
color: var(--accent);
padding: 0.3rem;
}
input,
select {
font: inherit;
color: var(--text);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.55rem 0.7rem;
width: 100%;
}
input[type='range'] {
padding: 0;
accent-color: var(--accent);
}
input[type='checkbox'] {
width: auto;
}
label {
display: flex;
flex-direction: column;
gap: 0.35rem;
font-size: 0.85rem;
color: var(--muted);
}
/* ---------- layout ---------- */
.app {
min-height: 100%;
display: flex;
flex-direction: column;
}
.content {
flex: 1;
width: 100%;
max-width: 720px;
margin: 0 auto;
padding: 1rem 1rem calc(var(--nav-h) + env(safe-area-inset-bottom) + 1rem);
}
.center {
min-height: 100dvh;
display: grid;
place-items: center;
padding: 1rem;
}
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.muted {
color: var(--muted);
}
.small {
font-size: 0.82rem;
}
.mono {
font-family: ui-monospace, 'SF Mono', Menlo, monospace;
font-size: 0.85rem;
}
.error {
color: var(--neg);
font-size: 0.85rem;
margin: 0;
}
.pos {
color: var(--pos);
}
.neg {
color: var(--neg);
}
.empty {
text-align: center;
padding: 2rem 1rem;
}
.page-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.page-head h1 {
font-size: 1.4rem;
margin: 0;
}
.stack {
display: flex;
flex-direction: column;
gap: 1rem;
}
.row {
display: flex;
gap: 0.6rem;
align-items: flex-end;
flex-wrap: wrap;
}
.grow {
flex: 1;
min-width: 8rem;
}
.toggle {
flex-direction: row;
align-items: center;
gap: 0.4rem;
}
/* ---------- nav ---------- */
.nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: calc(var(--nav-h) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
z-index: 10;
}
.nav-brand {
display: none;
}
.nav-links {
display: flex;
flex: 1;
list-style: none;
margin: 0;
padding: 0;
}
.nav-links li {
flex: 1;
}
.nav a,
.nav-logout {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 0.4rem;
width: 100%;
border: none;
background: none;
color: var(--muted);
font-size: 0.7rem;
}
.nav a.active {
color: var(--accent);
}
.nav-icon {
font-size: 1.2rem;
}
@media (min-width: 860px) {
.app {
flex-direction: row;
}
.nav {
position: sticky;
top: 0;
height: 100dvh;
width: 220px;
flex-direction: column;
align-items: stretch;
border-top: none;
border-right: 1px solid var(--border);
padding: 1.25rem 0.75rem;
gap: 0.25rem;
}
.nav-brand {
display: block;
font-weight: 700;
font-size: 1.25rem;
padding: 0 0.75rem 1rem;
}
.nav-links {
flex-direction: column;
flex: 0;
gap: 0.25rem;
}
.nav a,
.nav-logout {
flex-direction: row;
justify-content: flex-start;
gap: 0.75rem;
padding: 0.7rem 0.75rem;
border-radius: 10px;
font-size: 0.95rem;
}
.nav a.active {
background: var(--surface-2);
}
.nav-logout {
margin-top: auto;
}
.content {
padding-bottom: 2rem;
}
}
/* ---------- auth ---------- */
.auth-card {
width: min(380px, 92vw);
}
.brand {
margin: 0;
font-size: 1.8rem;
letter-spacing: -0.02em;
}
/* ---------- feed ---------- */
.feed-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feed-item {
padding: 0;
overflow: hidden;
gap: 0;
}
.feed-thumb img {
width: 100%;
max-height: 240px;
object-fit: cover;
display: block;
}
.feed-body {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.feed-meta {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.78rem;
}
.score {
font-family: ui-monospace, monospace;
font-weight: 700;
color: var(--accent);
background: color-mix(in srgb, var(--accent) 15%, transparent);
padding: 0.1rem 0.4rem;
border-radius: 6px;
}
.feed-title {
font-size: 1.1rem;
margin: 0;
line-height: 1.3;
}
.feed-summary {
margin: 0;
color: var(--muted);
font-size: 0.92rem;
}
.feed-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.25rem;
}
.more {
display: block;
margin: 1.25rem auto 0;
}
/* ---------- lists / pills / tags ---------- */
.list {
list-style: none;
margin: 1rem 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.list-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.list-row-end {
display: flex;
align-items: center;
gap: 0.6rem;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.tag {
font-size: 0.72rem;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 999px;
padding: 0.1rem 0.55rem;
color: var(--muted);
}
.pill {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.04em;
border-radius: 6px;
padding: 0.1rem 0.45rem;
margin-right: 0.5rem;
}
.pill-rss {
background: color-mix(in srgb, var(--accent) 20%, transparent);
color: var(--accent);
}
.pill-agentic {
background: color-mix(in srgb, var(--pos) 20%, transparent);
color: var(--pos);
}
.pill-revoked {
background: color-mix(in srgb, var(--neg) 20%, transparent);
color: var(--neg);
margin-left: 0.5rem;
}
/* ---------- token secret ---------- */
.secret {
background: var(--surface-2);
border: 1px dashed var(--accent);
border-radius: 10px;
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.token {
font-family: ui-monospace, monospace;
word-break: break-all;
background: var(--bg);
padding: 0.5rem;
border-radius: 8px;
}

50
web/src/lib/auth.tsx Normal file
View File

@@ -0,0 +1,50 @@
// Auth state derived from the server. `me` is the single source of truth: a successful
// query means an authenticated session, a 401 means logged out.
import { createContext, useContext, type ReactNode } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { api, ApiError } from '../api/client';
import type { Me } from '../api/bindings/Me';
interface AuthState {
user: Me | null;
loading: boolean;
refresh: () => void;
logout: () => void;
}
const AuthContext = createContext<AuthState | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const qc = useQueryClient();
const meQuery = useQuery({
queryKey: ['me'],
queryFn: api.me,
// A 401 is a normal "logged out" answer, not a retryable error.
retry: (_count, err) => !(err instanceof ApiError && err.status === 401),
});
const logoutMutation = useMutation({
mutationFn: api.logout,
onSuccess: () => qc.setQueryData(['me'], null),
});
const unauthorized = meQuery.error instanceof ApiError && meQuery.error.status === 401;
const value: AuthState = {
user: unauthorized ? null : (meQuery.data ?? null),
loading: meQuery.isLoading,
refresh: () => qc.invalidateQueries({ queryKey: ['me'] }),
logout: () => logoutMutation.mutate(),
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth(): AuthState {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error('useAuth must be used within AuthProvider');
return ctx;
}

21
web/src/main.tsx Normal file
View File

@@ -0,0 +1,21 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { BrowserRouter } from 'react-router-dom';
import { App } from './App';
import './index.css';
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</StrictMode>,
);

58
web/src/routes/Feed.tsx Normal file
View File

@@ -0,0 +1,58 @@
import { useState } from 'react';
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { FeedItem } from '../components/FeedItem';
import type { SignalAction } from '../api/bindings/SignalAction';
export function Feed() {
const qc = useQueryClient();
const [includeSaved, setIncludeSaved] = useState(false);
const query = useInfiniteQuery({
queryKey: ['feed', { includeSaved }],
queryFn: ({ pageParam }) => api.feed({ cursor: pageParam, includeSaved }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (last) => last.next_cursor ?? undefined,
});
const signal = useMutation({
mutationFn: ({ id, action }: { id: string; action: SignalAction }) =>
api.signal({ item_id: id, action }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['feed'] }),
});
const items = query.data?.pages.flatMap((p) => p.items) ?? [];
return (
<section>
<header className="page-head">
<h1>Feed</h1>
<label className="toggle">
<input type="checkbox" checked={includeSaved} onChange={(e) => setIncludeSaved(e.target.checked)} />
Show saved
</label>
</header>
{query.isLoading && <p className="muted">Loading your feed</p>}
{query.isError && <p className="error">Couldnt load the feed.</p>}
{!query.isLoading && items.length === 0 && (
<p className="muted empty">
Nothing here yet. Add a source, or push candidates to the ingest endpoint with an API token.
</p>
)}
<div className="feed-list">
{items.map((item) => (
<FeedItem key={item.id} item={item} onSignal={(action) => signal.mutate({ id: item.id, action })} />
))}
</div>
{query.hasNextPage && (
<button className="more" onClick={() => query.fetchNextPage()} disabled={query.isFetchingNextPage}>
{query.isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
)}
</section>
);
}

95
web/src/routes/Login.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { useState, type FormEvent } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '../api/client';
import { useAuth } from '../lib/auth';
export function Login() {
const { refresh } = useAuth();
const [mode, setMode] = useState<'login' | 'register'>('login');
const [identifier, setIdentifier] = useState('');
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const mutation = useMutation({
mutationFn: async () => {
if (mode === 'register') {
await api.register({ username, email, password });
}
await api.login({
identifier: mode === 'register' ? username : identifier,
password,
});
},
onSuccess: refresh,
});
const onSubmit = (e: FormEvent) => {
e.preventDefault();
mutation.mutate();
};
const error =
mutation.error instanceof ApiError ? mutation.error.message : mutation.error ? 'Something went wrong' : null;
return (
<div className="center">
<form className="card auth-card" onSubmit={onSubmit}>
<h1 className="brand">newsfeed</h1>
<p className="muted">Your feed. Your weights. Your rules.</p>
{mode === 'register' && (
<>
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" required />
</label>
<label>
Email
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
required
/>
</label>
</>
)}
{mode === 'login' && (
<label>
Username or email
<input value={identifier} onChange={(e) => setIdentifier(e.target.value)} autoComplete="username" required />
</label>
)}
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete={mode === 'register' ? 'new-password' : 'current-password'}
required
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" className="primary" disabled={mutation.isPending}>
{mutation.isPending ? '…' : mode === 'register' ? 'Create account' : 'Log in'}
</button>
<button
type="button"
className="link"
onClick={() => setMode(mode === 'login' ? 'register' : 'login')}
>
{mode === 'login' ? 'Need an account? Register' : 'Have an account? Log in'}
</button>
</form>
</div>
);
}

172
web/src/routes/Settings.tsx Normal file
View File

@@ -0,0 +1,172 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import type { CreatedApiToken } from '../api/bindings/CreatedApiToken';
function Interests() {
const qc = useQueryClient();
const interests = useQuery({ queryKey: ['interests'], queryFn: api.listInterests });
const [label, setLabel] = useState('');
const [weight, setWeight] = useState(0.5);
const upsert = useMutation({
mutationFn: (body: { label: string; weight: number }) => api.upsertInterest(body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }),
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteInterest(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['interests'] }),
});
const onAdd = (e: FormEvent) => {
e.preventDefault();
if (!label.trim()) return;
upsert.mutate({ label: label.trim(), weight });
setLabel('');
setWeight(0.5);
};
return (
<div className="card">
<h2>Interests</h2>
<p className="muted small">
Positive weights pull matching stories up; negative weights bury them. These weights and only these decide
your ranking.
</p>
<form className="row" onSubmit={onAdd}>
<input
className="grow"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="topic, e.g. rust"
/>
<input
type="range"
min={-1}
max={1}
step={0.05}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
/>
<span className="mono">{weight.toFixed(2)}</span>
<button type="submit" className="primary">
Add
</button>
</form>
<ul className="list">
{interests.data?.map((i) => (
<li key={i.id} className="list-row">
<strong>{i.label}</strong>
<div className="list-row-end">
<input
type="range"
min={-1}
max={1}
step={0.05}
defaultValue={i.weight}
onMouseUp={(e) => upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) })}
onTouchEnd={(e) =>
upsert.mutate({ label: i.label, weight: Number((e.target as HTMLInputElement).value) })
}
/>
<span className={`mono ${i.weight < 0 ? 'neg' : 'pos'}`}>{i.weight.toFixed(2)}</span>
<button className="ghost" onClick={() => remove.mutate(i.id)}>
</button>
</div>
</li>
))}
{interests.data?.length === 0 && <p className="muted empty">No interests yet.</p>}
</ul>
</div>
);
}
function Tokens() {
const qc = useQueryClient();
const tokens = useQuery({ queryKey: ['tokens'], queryFn: api.listTokens });
const [name, setName] = useState('');
const [fresh, setFresh] = useState<CreatedApiToken | null>(null);
const create = useMutation({
mutationFn: () => api.createToken({ name: name.trim() }),
onSuccess: (t) => {
setFresh(t);
setName('');
qc.invalidateQueries({ queryKey: ['tokens'] });
},
});
const revoke = useMutation({
mutationFn: (id: string) => api.revokeToken(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tokens'] }),
});
return (
<div className="card">
<h2>API tokens</h2>
<p className="muted small">
Agentic and algorithmic producers POST candidates to <code>/v1/ingest/candidates</code> with a bearer token.
Each token is scoped to your feed.
</p>
<form
className="row"
onSubmit={(e) => {
e.preventDefault();
if (name.trim()) create.mutate();
}}
>
<input className="grow" value={name} onChange={(e) => setName(e.target.value)} placeholder="token name" />
<button type="submit" className="primary">
Mint token
</button>
</form>
{fresh && (
<div className="secret">
<p className="small">
Copy this now it wont be shown again:
</p>
<code className="token">{fresh.secret}</code>
<button className="ghost" onClick={() => setFresh(null)}>
Done
</button>
</div>
)}
<ul className="list">
{tokens.data?.map((t) => (
<li key={t.id} className="list-row">
<div>
<strong>{t.name}</strong> <span className="mono muted">{t.prefix}</span>
{t.revoked_at && <span className="pill pill-revoked">revoked</span>}
</div>
{!t.revoked_at && (
<button className="ghost" onClick={() => revoke.mutate(t.id)}>
Revoke
</button>
)}
</li>
))}
{tokens.data?.length === 0 && <p className="muted empty">No tokens yet.</p>}
</ul>
</div>
);
}
export function Settings() {
return (
<section>
<header className="page-head">
<h1>Settings</h1>
</header>
<div className="stack">
<Interests />
<Tokens />
</div>
</section>
);
}

114
web/src/routes/Sources.tsx Normal file
View File

@@ -0,0 +1,114 @@
import { useState, type FormEvent } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { api, ApiError } from '../api/client';
import type { SourceKind } from '../api/bindings/SourceKind';
export function Sources() {
const qc = useQueryClient();
const sources = useQuery({ queryKey: ['sources'], queryFn: api.listSources });
const [kind, setKind] = useState<SourceKind>('rss');
const [name, setName] = useState('');
const [url, setUrl] = useState('');
const [weight, setWeight] = useState(1);
const create = useMutation({
mutationFn: () => api.createSource({ kind, name, url: kind === 'rss' ? url : null, weight }),
onSuccess: () => {
setName('');
setUrl('');
setWeight(1);
qc.invalidateQueries({ queryKey: ['sources'] });
},
});
const remove = useMutation({
mutationFn: (id: string) => api.deleteSource(id),
onSuccess: () => qc.invalidateQueries({ queryKey: ['sources'] }),
});
const onSubmit = (e: FormEvent) => {
e.preventDefault();
create.mutate();
};
const error = create.error instanceof ApiError ? create.error.message : null;
return (
<section>
<header className="page-head">
<h1>Sources</h1>
</header>
<form className="card" onSubmit={onSubmit}>
<div className="row">
<label>
Kind
<select value={kind} onChange={(e) => setKind(e.target.value as SourceKind)}>
<option value="rss">RSS / Atom feed</option>
<option value="agentic">Agentic (pushed)</option>
</select>
</label>
<label className="grow">
Name
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Hacker News" required />
</label>
</div>
{kind === 'rss' && (
<label>
Feed URL
<input
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/feed.xml"
required
/>
</label>
)}
<label>
Baseline weight: <strong>{weight.toFixed(2)}</strong>
<input
type="range"
min={0}
max={1}
step={0.05}
value={weight}
onChange={(e) => setWeight(Number(e.target.value))}
/>
</label>
{error && <p className="error">{error}</p>}
<button type="submit" className="primary" disabled={create.isPending}>
Add source
</button>
</form>
<ul className="list">
{sources.data?.map((s) => (
<li key={s.id} className="card list-row">
<div>
<span className={`pill pill-${s.kind}`}>{s.kind}</span>
<strong>{s.name}</strong>
{s.url && (
<a className="muted small" href={s.url} target="_blank" rel="noreferrer">
{s.url}
</a>
)}
</div>
<div className="list-row-end">
<span className="muted small">w {s.weight.toFixed(2)}</span>
<button className="ghost" onClick={() => remove.mutate(s.id)}>
Delete
</button>
</div>
</li>
))}
{sources.data?.length === 0 && <p className="muted empty">No sources yet.</p>}
</ul>
</section>
);
}

9
web/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_BASE_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}