Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ All send `access-control-allow-origin: *` and need no key.
| `/api/discoveries/{id}` | Progress of one keyword run |
| `/api/crawlstats` | Crawler health, including the two discovery queues |
| `/api/topics/{keyword}` | The feeds on a topic, its category breakdown, `?group=` to narrow |
| `/api/topics/{a},{b},{c}` | Several topics (up to 10) as one: the union, each feed once, tagged-under-any first then strength summed across them; adds `topics` and `unknown`. Works on every topic surface: `/topics/a,b`, `/topics/a,b/blogs`, `/topics/a,b.rss`, the player, and the MCP `get_topic`/`topic_posts` |
| `/topics/{keyword}/{group}.rss` | One category of a topic, as a feed — also `.atom`, `.json`, `.m3u`, `.pls` |
| `/mcp` | MCP endpoint — and the documentation page, in a browser |
| `/api/authors` | The people behind the feeds; `?feed={url}` finds the people behind one feed |
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rssamplifier/web",
"version": "0.2.4",
"version": "0.3.0",
"private": true,
"type": "module",
"scripts": {
Expand Down
5 changes: 4 additions & 1 deletion apps/web/src/app/api/queue/route.js
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,10 @@ export async function POST(req) {
const group = body.group ? topicGroup(body.group) : null;
if (body.group && !group) return wantsHtml ? redirect(back) : json({ error: 'not-found' }, 404);

const rows = await q.mediaForTopic(client, String(body.topic ?? ''), {
// A combined view (business,saas) posts its slugs joined by commas, the
// same form its address uses; a single topic is a one-element list, which
// is the single-topic query unchanged.
const rows = await q.mediaForTopic(client, String(body.topic ?? '').split(','), {
limit: PLAYLIST_LIMIT,
kinds: group?.kinds ?? null,
});
Expand Down
55 changes: 49 additions & 6 deletions apps/web/src/app/api/topics/[keyword]/route.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { q } from '@rssamplifier/db';

import { db, siteUrl } from '../../../../lib/db.js';
import { groupsWithFeeds, slugFromUrl, topicGroup } from '../../../../lib/topicGroups.js';
import { groupsWithFeeds, topicGroup } from '../../../../lib/topicGroups.js';
import { resolveTopics, topicQueryArg, topicSegment } from '../../../../lib/topicSet.js';

export const dynamic = 'force-dynamic';

Expand All @@ -19,22 +20,48 @@ export const dynamic = 'force-dynamic';
* a second request, and a caller that guessed wrong can see what it should have
* said.
*
* Several topics at once — `/api/topics/business,saas,crypto` — answer in the
* same shape for their union (see lib/topicSet.js): every feed filed under any
* of them, each once, `total` counting distinct feeds. Three fields are added
* for a list: `topics` (each known topic with its own count and address),
* `unknown` (what was asked for and does not exist) and `strength` per feed is
* the sum across the topics. A list with nothing known is a 404; one longer
* than ten topics a 400.
*
* @param {Request} req
* @param {{ params: Promise<{ keyword: string }> }} ctx
*/
export async function GET(req, { params }) {
const { keyword } = await params;
const slug = slugFromUrl(keyword);

const url = new URL(req.url);
const limit = Math.min(Math.max(Number(url.searchParams.get('limit') ?? 100) || 100, 1), 500);
const offset = Math.max(Number(url.searchParams.get('offset') ?? 0) || 0, 0);
const group = topicGroup(url.searchParams.get('group'));

const client = db();
const topic = await q.topicBySlug(client, slug);
const found = await resolveTopics(client, keyword);

if (found.error === 'too-many') {
return new Response(
JSON.stringify(
{ error: 'too-many-topics', max: found.max, hint: `Combine at most ${found.max} topics.` },
null,
2,
),
{
status: 400,
headers: {
'content-type': 'application/json; charset=utf-8',
'access-control-allow-origin': '*',
},
},
);
}

const topic = found.topic;
if (!topic) {
const slug = found.unknown.join(',');
return new Response(JSON.stringify({ error: 'not-found', slug }, null, 2), {
status: 404,
headers: {
Expand All @@ -45,11 +72,11 @@ export async function GET(req, { params }) {
}

const [rows, counts] = await Promise.all([
q.feedsForTopic(client, slug, { limit, offset, kinds: group?.kinds ?? null }),
q.topicKindCounts(client, slug),
q.feedsForTopic(client, topicQueryArg(topic), { limit, offset, kinds: group?.kinds ?? null }),
q.topicKindCounts(client, topicQueryArg(topic)),
]);

const base = `${siteUrl()}/topics/${encodeURIComponent(topic.slug)}`;
const base = `${siteUrl()}/topics/${topicSegment(topic.slug)}`;

return new Response(
JSON.stringify(
Expand All @@ -63,6 +90,20 @@ export async function GET(req, { params }) {
? group.kinds.reduce((sum, kind) => sum + (counts[kind] ?? 0), 0)
: topic.feedCount,
topicTotal: topic.feedCount,
// Only on a combined view, so a single topic's answer is byte for byte
// what it was.
...(topic.multi
? {
topics: (topic.topics ?? []).map((t) => ({
slug: t.slug,
keyword: t.keyword,
feedCount: t.feedCount,
page: `${siteUrl()}/topics/${encodeURIComponent(t.slug)}`,
api: `${siteUrl()}/api/topics/${encodeURIComponent(t.slug)}`,
})),
unknown: topic.unknown ?? [],
}
: {}),
group: group?.segment ?? null,
groups: groupsWithFeeds(counts).map(({ group: entry, count }) => ({
group: entry.segment,
Expand Down Expand Up @@ -92,6 +133,8 @@ export async function GET(req, { params }) {
// a phrase counted across its writing.
source: f.source,
strength: Number(f.count ?? 0),
// Which of the listed topics this feed is filed under.
...(topic.multi ? { topics: String(f.topics ?? '').split(',').filter(Boolean) } : {}),
page: `${siteUrl()}/${f.slug}`,
})),
},
Expand Down
85 changes: 62 additions & 23 deletions apps/web/src/app/topics/[keyword]/TopicListing.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import Share from '../../Share.jsx';
import { Avatar } from '../../Thumb.jsx';
import { feedImage } from '../../../lib/thumbs.js';
import { jsonLdScript } from '../../../lib/jsonld.js';
import { topicQueryArg, topicSegment } from '../../../lib/topicSet.js';

/** Feeds per page. Matches the category pages. */
export const PAGE_SIZE = 60;
Expand Down Expand Up @@ -71,24 +72,30 @@ function formatTitle(ext, what) {
* moment one of them grew a feature, and the sub-groups exist precisely because
* there are now eight of them per topic.
*
* A combined view (/topics/business,saas,crypto) is the same page over the
* union of its topics — see lib/topicSet.js. What it adds is a link to each
* topic it combines and a note naming any that do not exist; what it leaves
* out is everything keyed on one topic's slug (following it, its webring).
*
* @param {{
* topic: { slug: string, keyword: string, feedCount: number },
* topic: import('../../../lib/topicSet.js').ResolvedTopic,
* counts: Record<string, number>,
* group?: import('../../../lib/topicGroups.js').TOPIC_GROUPS[number]|null,
* page?: number,
* }} props
*/
export default async function TopicListing({ topic, counts, group = null, page = 1 }) {
const client = db();
const slug = encodeURIComponent(topic.slug);
const slug = topicSegment(topic.slug);
const multi = Boolean(topic.multi);

const base = group ? `/topics/${slug}/${group.segment}` : `/topics/${slug}`;
const total = group
? group.kinds.reduce((sum, kind) => sum + (counts[kind] ?? 0), 0)
: topic.feedCount;

const [rows, user] = await Promise.all([
q.feedsForTopic(client, topic.slug, {
q.feedsForTopic(client, topicQueryArg(topic), {
limit: PAGE_SIZE,
offset: (page - 1) * PAGE_SIZE,
kinds: group?.kinds ?? null,
Expand All @@ -99,7 +106,7 @@ export default async function TopicListing({ topic, counts, group = null, page =
// Only asked once we know there is someone to ask about. A signed-out visitor
// still gets the button — the endpoint sends them to sign in and back here —
// so this decides what it says rather than whether it appears.
const follow = user
const follow = user && !multi
? await alerts.topicFollowState(client, String(user.id), topic.slug, group?.segment ?? '')
: { following: false, alerts: false };

Expand Down Expand Up @@ -154,15 +161,40 @@ export default async function TopicListing({ topic, counts, group = null, page =
<p className="eyebrow">
{group ? (
<>
<a href="/topics">Topic</a> · <a href={`/topics/${slug}`}>{topic.keyword}</a>
<a href="/topics">{multi ? 'Topics' : 'Topic'}</a> ·{' '}
<a href={`/topics/${slug}`}>{topic.keyword}</a>
</>
) : (
<a href="/topics">Topic</a>
<a href="/topics">{multi ? 'Topics' : 'Topic'}</a>
)}
</p>
<h1>{heading}</h1>
<p className="lede">{lede}</p>

{/* What this combined view is made of, each topic on its own page, and
what was asked for that does not exist — said rather than silently
dropped, so a typo in one of five topics is visible. */}
{multi && (
<p className="topic-set">
Every feed filed under any of:{' '}
{(topic.topics ?? []).map((t, i) => (
<span key={t.slug}>
{i > 0 && ' · '}
<a href={`/topics/${encodeURIComponent(t.slug)}${group ? `/${group.segment}` : ''}`}>
{t.keyword}
</a>{' '}
<span className="count">({t.feedCount})</span>
</span>
))}
{(topic.unknown ?? []).length > 0 && (
<>
{' '}
— no topic called {(topic.unknown ?? []).map((u) => `"${u}"`).join(', ')}.
</>
)}
</p>
)}

{/* The topic's other categories. Shown on the sub-group pages too, so
moving between them never costs a trip back through the topic — which
is the whole reason for cutting the topic up. Only the groups that
Expand Down Expand Up @@ -230,17 +262,19 @@ export default async function TopicListing({ topic, counts, group = null, page =
form underneath, so it works with JavaScript off; with JavaScript it
flips in place rather than reloading a page of sixty feeds to change
two words on one button. */}
<FollowControls
endpoint="/api/follows/topics"
kind="topic"
slug={topic.slug}
segment={group?.segment ?? ''}
following={follow.following}
alerts={follow.alerts}
signedIn={Boolean(user)}
next={base}
label="Follow this topic"
/>
{!multi && (
<FollowControls
endpoint="/api/follows/topics"
kind="topic"
slug={topic.slug}
segment={group?.segment ?? ''}
following={follow.following}
alerts={follow.alerts}
signedIn={Boolean(user)}
next={base}
label="Follow this topic"
/>
)}

<Share
url={pageUrl}
Expand All @@ -251,12 +285,16 @@ export default async function TopicListing({ topic, counts, group = null, page =

{/* The same feeds as a webring: hops, an OPML, and a viewer that frames
them one at a time. Both pages exist; this is the door between them. */}
<a className="button" href={`/ring/${encodeURIComponent(topic.slug)}`}>
Webring
</a>
<a className="button" href={`/view/${encodeURIComponent(topic.slug)}`}>
View webring
</a>
{!multi && (
<>
<a className="button" href={`/ring/${encodeURIComponent(topic.slug)}`}>
Webring
</a>
<a className="button" href={`/view/${encodeURIComponent(topic.slug)}`}>
View webring
</a>
</>
)}
</div>

<Ad format={AD_TEXT} />
Expand All @@ -283,6 +321,7 @@ export default async function TopicListing({ topic, counts, group = null, page =
<span>
{f.item_count} {f.category === 'podcast' ? 'episodes' : 'posts'}
</span>
{multi && f.topics && <span>{String(f.topics).split(',').join(', ')}</span>}
</div>
</a>
);
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/app/topics/[keyword]/TopicPlayer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { db } from '../../../lib/db.js';
import { currentUser } from '../../../lib/auth.js';
import { PLAYLIST_LIMIT } from '../../../lib/topicFeed.js';
import { playlistEntry, playerPath, queueRuntime, rawPlaylistPath } from '../../../lib/player.js';
import { topicQueryArg, topicSegment } from '../../../lib/topicSet.js';
import PlaylistPlayer from '../../PlaylistPlayer.jsx';
import QueueAll from '../../QueueAll.jsx';

Expand All @@ -16,16 +17,16 @@ import QueueAll from '../../QueueAll.jsx';
* they differ only in the filter, and two copies would drift.
*
* @param {{
* topic: { slug: string, keyword: string, feedCount: number },
* topic: import('../../../lib/topicSet.js').ResolvedTopic,
* group?: { segment: string, kinds: string[], heading: string, item: string }|null,
* }} props
*/
export default async function TopicPlayer({ topic, group = null }) {
const client = db();
const slug = encodeURIComponent(topic.slug);
const slug = topicSegment(topic.slug);

const [rows, user] = await Promise.all([
q.mediaForTopic(client, topic.slug, {
q.mediaForTopic(client, topicQueryArg(topic), {
limit: PLAYLIST_LIMIT,
kinds: group?.kinds ?? null,
}),
Expand Down
17 changes: 7 additions & 10 deletions apps/web/src/app/topics/[keyword]/[group]/page.jsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { notFound } from 'next/navigation';
import { q } from '@rssamplifier/db';

import { db, siteUrl } from '../../../../lib/db.js';
import { feedAlternates } from '../../../../lib/subscribe.js';
import { slugFromUrl, topicGroup } from '../../../../lib/topicGroups.js';
import { topicGroup } from '../../../../lib/topicGroups.js';
import { resolveTopicsWithCounts, topicSegment } from '../../../../lib/topicSet.js';
import { pageNumber } from '../../../CategoryIndex.jsx';
import TopicListing from '../TopicListing.jsx';

Expand All @@ -19,7 +19,7 @@ export const dynamic = 'force-dynamic';
*
* @param {{ params: Promise<{ keyword: string, group: string }> }} props
* @returns {Promise<{
* topic: { slug: string, keyword: string, feedCount: number },
* topic: import('../../../../lib/topicSet.js').ResolvedTopic,
* counts: Record<string, number>,
* group: NonNullable<ReturnType<typeof topicGroup>>,
* total: number,
Expand All @@ -31,12 +31,7 @@ async function resolve({ params }) {
if (!group) return null;

const client = db();
const slug = slugFromUrl(keyword);

const [topic, counts] = await Promise.all([
q.topicBySlug(client, slug),
q.topicKindCounts(client, slug),
]);
const { topic, counts } = await resolveTopicsWithCounts(client, keyword);
if (!topic) return null;

const total = group.kinds.reduce((sum, kind) => sum + (counts[kind] ?? 0), 0);
Expand All @@ -58,12 +53,14 @@ export async function generateMetadata(props) {
if (!found) return { title: 'Not found' };

const { topic, group, total } = found;
const page = `${siteUrl()}/topics/${encodeURIComponent(topic.slug)}/${group.segment}`;
const page = `${siteUrl()}/topics/${topicSegment(topic.slug)}/${group.segment}`;
const heading = `${topic.keyword}: ${group.heading.toLowerCase()}`;

return {
title: heading,
description: `${total} ${group.noun} in the RSS Amplifier directory cover ${topic.keyword}.`,
// Out of the index for a combined view; see the whole-topic page.
...(topic.multi ? { robots: { index: false, follow: true } } : {}),
alternates: {
canonical: page,
types: feedAlternates(page, heading),
Expand Down
Loading
Loading