Files
newsfeed/web/src/routes/Settings.tsx
rob thijssen 5ce52fff4d
Some checks failed
deploy / build (push) Failing after 7s
deploy / deploy-api (push) Has been skipped
deploy / deploy-web (push) Has been skipped
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
2026-07-08 11:55:49 +03:00

173 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}