feat: scaffold newsfeed — user-controlled news feed
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:
42
web/src/App.tsx
Normal file
42
web/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
web/src/api/bindings/ApiTokenInfo.ts
Normal file
11
web/src/api/bindings/ApiTokenInfo.ts
Normal 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, };
|
||||
18
web/src/api/bindings/CandidateSubmission.ts
Normal file
18
web/src/api/bindings/CandidateSubmission.ts
Normal 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, };
|
||||
16
web/src/api/bindings/ContentItem.ts
Normal file
16
web/src/api/bindings/ContentItem.ts
Normal 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, };
|
||||
6
web/src/api/bindings/CreateApiToken.ts
Normal file
6
web/src/api/bindings/CreateApiToken.ts
Normal 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, };
|
||||
15
web/src/api/bindings/CreatedApiToken.ts
Normal file
15
web/src/api/bindings/CreatedApiToken.ts
Normal 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, };
|
||||
11
web/src/api/bindings/FeedPage.ts
Normal file
11
web/src/api/bindings/FeedPage.ts
Normal 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, };
|
||||
18
web/src/api/bindings/FeedQuery.ts
Normal file
18
web/src/api/bindings/FeedQuery.ts
Normal 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, };
|
||||
15
web/src/api/bindings/Interest.ts
Normal file
15
web/src/api/bindings/Interest.ts
Normal 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, };
|
||||
6
web/src/api/bindings/ItemState.ts
Normal file
6
web/src/api/bindings/ItemState.ts
Normal 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";
|
||||
10
web/src/api/bindings/LoginRequest.ts
Normal file
10
web/src/api/bindings/LoginRequest.ts
Normal 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, };
|
||||
6
web/src/api/bindings/Me.ts
Normal file
6
web/src/api/bindings/Me.ts
Normal 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, };
|
||||
6
web/src/api/bindings/Media.ts
Normal file
6
web/src/api/bindings/Media.ts
Normal 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, };
|
||||
11
web/src/api/bindings/NewSource.ts
Normal file
11
web/src/api/bindings/NewSource.ts
Normal 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, };
|
||||
6
web/src/api/bindings/RegisterRequest.ts
Normal file
6
web/src/api/bindings/RegisterRequest.ts
Normal 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, };
|
||||
7
web/src/api/bindings/Signal.ts
Normal file
7
web/src/api/bindings/Signal.ts
Normal 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, };
|
||||
6
web/src/api/bindings/SignalAction.ts
Normal file
6
web/src/api/bindings/SignalAction.ts
Normal 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";
|
||||
19
web/src/api/bindings/Source.ts
Normal file
19
web/src/api/bindings/Source.ts
Normal 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, };
|
||||
6
web/src/api/bindings/SourceKind.ts
Normal file
6
web/src/api/bindings/SourceKind.ts
Normal 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";
|
||||
6
web/src/api/bindings/UpsertInterest.ts
Normal file
6
web/src/api/bindings/UpsertInterest.ts
Normal 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, };
|
||||
7
web/src/api/bindings/User.ts
Normal file
7
web/src/api/bindings/User.ts
Normal 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
84
web/src/api/client.ts
Normal 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}`),
|
||||
};
|
||||
65
web/src/components/FeedItem.tsx
Normal file
65
web/src/components/FeedItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
web/src/components/Nav.tsx
Normal file
38
web/src/components/Nav.tsx
Normal 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
438
web/src/index.css
Normal 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
50
web/src/lib/auth.tsx
Normal 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
21
web/src/main.tsx
Normal 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
58
web/src/routes/Feed.tsx
Normal 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">Couldn’t 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
95
web/src/routes/Login.tsx
Normal 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
172
web/src/routes/Settings.tsx
Normal 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 won’t 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
114
web/src/routes/Sources.tsx
Normal 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
9
web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
Reference in New Issue
Block a user