diff --git a/apps/web/src/app/FollowControls.jsx b/apps/web/src/app/FollowControls.jsx
index 4769aec..e4ff3bf 100644
--- a/apps/web/src/app/FollowControls.jsx
+++ b/apps/web/src/app/FollowControls.jsx
@@ -20,7 +20,7 @@ import FollowButton from './FollowButton.jsx';
*
* @param {{
* endpoint: string,
- * kind: 'feed'|'topic',
+ * kind: 'feed'|'topic'|'author',
* slug: string,
* segment?: string,
* following: boolean,
@@ -81,7 +81,7 @@ export default function FollowControls({
* this only says whether this one follow feeds them.
*
* @param {{
- * kind: 'feed'|'topic',
+ * kind: 'feed'|'topic'|'author',
* slug: string,
* segment?: string,
* alerts: boolean,
diff --git a/apps/web/src/app/account/alerts/page.jsx b/apps/web/src/app/account/alerts/page.jsx
index 11a903c..6ab85aa 100644
--- a/apps/web/src/app/account/alerts/page.jsx
+++ b/apps/web/src/app/account/alerts/page.jsx
@@ -13,7 +13,8 @@ export const dynamic = 'force-dynamic';
export const metadata = {
title: 'Alerts',
- description: 'Where you are told about new posts from the blogs and topics you follow.',
+ description:
+ 'Where you are told about new posts from the blogs, topics and people you follow.',
};
/** What each error code from /api/alerts/channels means to a reader. */
@@ -52,7 +53,8 @@ export default async function AlertsPage({ searchParams }) {
]);
const hasEmail = channels.some((c) => c.kind === 'email');
- const watching = following.feeds.length + following.topics.length;
+ const watching =
+ following.feeds.length + following.topics.length + following.authors.length;
return (
<>
@@ -150,7 +152,8 @@ export default async function AlertsPage({ searchParams }) {
{watching === 0 ? (
Nothing yet. Open a blog you follow โ or any{' '}
- topic โ and press ๐ beside the Follow button.
+ topic or person โ and press ๐ beside the
+ Follow button.
) : (
<>
@@ -167,6 +170,16 @@ export default async function AlertsPage({ searchParams }) {
)}
+ {following.authors.length > 0 && (
+
+ )}
+
{following.feeds.length > 0 && (
{following.feeds.map((f) => (
diff --git a/apps/web/src/app/api/alerts/route.js b/apps/web/src/app/api/alerts/route.js
index 41a21ef..bd312f3 100644
--- a/apps/web/src/app/api/alerts/route.js
+++ b/apps/web/src/app/api/alerts/route.js
@@ -1,4 +1,4 @@
-import { alerts, q } from '@rssamplifier/db';
+import { alerts, authors, q } from '@rssamplifier/db';
import { db } from '../../../lib/db.js';
import { currentUser } from '../../../lib/auth.js';
@@ -52,7 +52,9 @@ export async function POST(req) {
return json({ error: 'bad-request' }, 400);
}
- if (kind !== 'feed' && kind !== 'topic') return json({ error: 'bad-kind' }, 400);
+ if (kind !== 'feed' && kind !== 'topic' && kind !== 'author') {
+ return json({ error: 'bad-kind' }, 400);
+ }
// Only ever back to somewhere on this site. `next` arrives in a form field, so
// an absolute URL in it would make this endpoint an open redirect.
@@ -69,7 +71,9 @@ export async function POST(req) {
const changed =
kind === 'feed'
? await setForFeed(client, userId, slug, on)
- : await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on);
+ : kind === 'author'
+ ? await setForAuthor(client, userId, slug, on)
+ : await alerts.setTopicAlerts(client, userId, slugFromUrl(slug), segment, on);
// Not following it โ or, for a blog, no such blog. Either way there is nothing
// to flag, and saying so is more useful than reporting a success that did not
@@ -98,6 +102,24 @@ async function setForFeed(client, userId, slug, on) {
return alerts.setFeedAlerts(client, userId, String(feed.id), on);
}
+/**
+ * Resolve a person's slug to their id, then flag the follow.
+ *
+ * The same indirection as the blog above and for the same reason: the table is
+ * keyed on an id the reader never sees, and the slug is what a page can send.
+ *
+ * @param {import('@libsql/client').Client} client
+ * @param {string} userId
+ * @param {string} slug
+ * @param {boolean} on
+ * @returns {Promise
}
+ */
+async function setForAuthor(client, userId, slug, on) {
+ const person = await authors.authorBySlug(client, String(slug).trim().toLowerCase());
+ if (!person) return false;
+ return alerts.setAuthorAlerts(client, userId, String(person.id), on);
+}
+
/**
* Where a no-JavaScript submit lands when the form did not say.
*
@@ -107,6 +129,7 @@ async function setForFeed(client, userId, slug, on) {
* @returns {string}
*/
function fallbackPath(kind, slug, segment) {
+ if (kind === 'author') return `/authors/${encodeURIComponent(String(slug).toLowerCase())}`;
if (kind !== 'topic') return `/${slug}`;
const base = `/topics/${encodeURIComponent(slugFromUrl(slug))}`;
return segment ? `${base}/${encodeURIComponent(segment)}` : base;
diff --git a/apps/web/src/app/api/following/feed/[format]/route.js b/apps/web/src/app/api/following/feed/[format]/route.js
index b06cc0a..07f330e 100644
--- a/apps/web/src/app/api/following/feed/[format]/route.js
+++ b/apps/web/src/app/api/following/feed/[format]/route.js
@@ -58,7 +58,7 @@ export async function GET(req, { params }) {
);
}
- const { feeds, topics, items } = await following(client, String(user.id), {
+ const { feeds, topics, authors, items } = await following(client, String(user.id), {
limit: RIVER_LIMIT,
});
@@ -85,10 +85,11 @@ export async function GET(req, { params }) {
format,
{
title: 'Following โ RSS Amplifier',
- description: `Recent posts from the ${count(topics.length, 'topic')} and ${count(
- feeds.length,
- 'blog',
- )} this RSS Amplifier account follows.`,
+ description: `Recent posts from the ${count(topics.length, 'topic')}, ${count(
+ authors.length,
+ 'person',
+ 'people',
+ )} and ${count(feeds.length, 'blog')} this RSS Amplifier account follows.`,
link: `${origin}/following`,
selfUrl: followingFeedUrl(origin, token, format),
},
@@ -116,8 +117,8 @@ export async function GET(req, { params }) {
* @param {string} noun
* @returns {string}
*/
-function count(n, noun) {
- return `${n} ${noun}${n === 1 ? '' : 's'}`;
+function count(n, noun, plural = `${noun}s`) {
+ return `${n} ${n === 1 ? noun : plural}`;
}
/**
diff --git a/apps/web/src/app/api/follows/authors/route.js b/apps/web/src/app/api/follows/authors/route.js
new file mode 100644
index 0000000..3990456
--- /dev/null
+++ b/apps/web/src/app/api/follows/authors/route.js
@@ -0,0 +1,105 @@
+import { accounts, authors } from '@rssamplifier/db';
+
+import { db } from '../../../../lib/db.js';
+import { currentUser } from '../../../../lib/auth.js';
+
+export const dynamic = 'force-dynamic';
+
+/**
+ * Follow or unfollow a person.
+ *
+ * The third sibling of /api/follows and /api/follows/topics, kept apart from
+ * both for the reason the topics route already gives: they take different
+ * identifiers, validate differently, and land somewhere different afterwards.
+ *
+ * What is different here is the indirection. The table is keyed on the author's
+ * id, but the request carries their slug, because a slug is the public identity
+ * and accepting an internal id would mean publishing one. So this resolves the
+ * slug first, and that lookup is also where a follow on somebody who does not
+ * exist is refused.
+ *
+ * Form-first like every other write on the site: a plain POST answered with a
+ * 303 back to the page it came from, so following works with JavaScript off. A
+ * JSON caller gets JSON.
+ *
+ * @param {Request} req
+ */
+export async function POST(req) {
+ const user = await currentUser();
+ const wantsHtml = (req.headers.get('accept') ?? '').includes('text/html');
+
+ let rawSlug = '';
+ let action = 'toggle';
+
+ try {
+ if ((req.headers.get('content-type') ?? '').includes('application/json')) {
+ const body = await req.json();
+ rawSlug = String(body?.slug ?? '');
+ action = String(body?.action ?? 'toggle');
+ } else {
+ const form = await req.formData();
+ rawSlug = String(form.get('slug') ?? '');
+ action = String(form.get('action') ?? 'toggle');
+ }
+ } catch {
+ return json({ error: 'bad-request' }, 400);
+ }
+
+ // Lowercased the way `authorBySlug` expects and the way the page's own URL is
+ // written, so a follow made from a link somebody typed in capitals is the
+ // same row as one made from the page.
+ const slug = rawSlug.trim().toLowerCase();
+ if (!slug) return wantsHtml ? redirect('/authors') : json({ error: 'bad-request' }, 400);
+
+ const page = `/authors/${encodeURIComponent(slug)}`;
+
+ if (!user) {
+ // Sent to sign in and then back to the person they were reading, rather
+ // than handed a bare error.
+ if (wantsHtml) return redirect(`/login?next=${encodeURIComponent(page)}`);
+ return json({ error: 'sign-in-required' }, 401);
+ }
+
+ const client = db();
+ const person = await authors.authorBySlug(client, slug);
+
+ // Unlike the topic route, this refuses in both directions rather than only on
+ // follow. A topic slug outlives the topic table by design, so an unfollow has
+ // to work for a slug that no longer resolves; an author id only exists while
+ // the author row does, and the cascade has already removed the follow by the
+ // time the row is gone. There is nothing left to delete and nothing to key it
+ // by, so a 404 is the honest answer.
+ if (!person) return wantsHtml ? redirect('/authors') : json({ error: 'not-found' }, 404);
+
+ const userId = String(user.id);
+ const authorId = String(person.id);
+
+ const following = await accounts.isFollowingAuthor(client, userId, authorId);
+ const shouldFollow = action === 'follow' || (action === 'toggle' && !following);
+
+ if (shouldFollow) await accounts.followAuthor(client, userId, authorId);
+ else await accounts.unfollowAuthor(client, userId, authorId);
+
+ if (wantsHtml) return redirect(page);
+ return json({ ok: true, slug, following: shouldFollow });
+}
+
+/**
+ * @param {string} location
+ * @returns {Response}
+ */
+function redirect(location) {
+ return new Response(null, { status: 303, headers: { location, 'cache-control': 'no-store' } });
+}
+
+/**
+ * @param {unknown} body
+ * @param {number} [status]
+ * @returns {Response}
+ */
+function json(body, status = 200) {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
+ });
+}
diff --git a/apps/web/src/app/authors/[slug]/page.jsx b/apps/web/src/app/authors/[slug]/page.jsx
index 030d88c..39c9c97 100644
--- a/apps/web/src/app/authors/[slug]/page.jsx
+++ b/apps/web/src/app/authors/[slug]/page.jsx
@@ -1,10 +1,12 @@
import { notFound } from 'next/navigation';
-import { authors } from '@rssamplifier/db';
+import { alerts, authors } from '@rssamplifier/db';
import { db, siteUrl } from '../../../lib/db.js';
+import { currentUser } from '../../../lib/auth.js';
import { feedAlternates } from '../../../lib/subscribe.js';
import AdBanner from '../../AdBanner.jsx';
import AuthorLinks from '../../AuthorLinks.jsx';
+import FollowControls from '../../FollowControls.jsx';
import SubscribeLinks from '../../SubscribeLinks.jsx';
import { CATEGORIES } from '../../CategoryIndex.jsx';
import ListFilter from '../../ListFilter.jsx';
@@ -59,6 +61,15 @@ export default async function AuthorPage({ params }) {
const feeds = person.feeds ?? [];
const links = person.links ?? [];
+ // Whether this reader already follows them, and whether that follow is
+ // alerting. One round trip for both, the way the feed and topic pages do it:
+ // the button and the bell are rendered together and asking twice would be two
+ // queries for one row.
+ const user = await currentUser();
+ const follow = user
+ ? await alerts.authorFollowState(db(), String(user.id), String(person.id))
+ : { following: false, alerts: false };
+
// What they have published lately, read off their own feeds' ids rather than
// searched for -- see `postsByAuthor`. A profile that lists the blogs but not
// the writing is a card catalogue entry; the point of a page about a person
@@ -142,6 +153,29 @@ export default async function AuthorPage({ params }) {
+ {/* Follow the person, and then decide whether to be told. Above the
+ subscribe links deliberately: those hand the reader a document to take
+ somewhere else, and this keeps them here, which is the thing the page
+ could describe and not offer.
+
+ Only where there is something to follow. A profile with no credited
+ feeds would produce a follow that can never deliver anything, which is
+ the same reason the subscribe links below are conditional. */}
+ {feeds.length > 0 && (
+
+
+
+ )}
+
{/* Everything they publish, wherever they publish it, as one feed. Only
offered when there is something behind it: a subscribe link on a
profile with no credited feeds is a link to an empty document. */}
diff --git a/apps/web/src/app/following/page.jsx b/apps/web/src/app/following/page.jsx
index 1c08f28..2b6229d 100644
--- a/apps/web/src/app/following/page.jsx
+++ b/apps/web/src/app/following/page.jsx
@@ -10,6 +10,7 @@ import { currentUser } from '../../lib/auth.js';
import { postThumb } from '../../lib/thumbs.js';
import {
RIVER_LIMIT,
+ RIVER_AUTHORS,
RIVER_TOPICS,
following as loadFollowing,
followingFeedUrl,
@@ -45,13 +46,13 @@ export default async function FollowingPage({ searchParams }) {
const client = db();
const userId = String(user.id);
- const [{ feeds, topics, items, topicsUsed }, token] = await Promise.all([
+ const [{ feeds, topics, authors, items, topicsUsed, authorsUsed }, token] = await Promise.all([
loadFollowing(client, userId, { limit: RIVER_LIMIT }),
accounts.feedToken(client, userId),
]);
const origin = siteUrl();
- const nothing = feeds.length === 0 && topics.length === 0;
+ const nothing = feeds.length === 0 && topics.length === 0 && authors.length === 0;
return (
<>
@@ -68,14 +69,16 @@ export default async function FollowingPage({ searchParams }) {
{nothing ? (
Nothing followed yet. Press Follow on any blog to be
- told when they post, or on any topic to be told when anybody posts
+ told when they post, on any topic to be told when anybody posts
about it โ ai and ai: podcasts{' '}
- are two separate follows, because they are two separate pages.
+ are two separate follows, because they are two separate pages โ or on a{' '}
+ person, which collects everything they publish wherever they
+ publish it.
) : (
- {describe(topics.length, 'topic')} and {describe(feeds.length, 'blog')}, merged newest
- first.
+ {describe(topics.length, 'topic')}, {describe(authors.length, 'person', 'people')} and{' '}
+ {describe(feeds.length, 'blog')}, merged newest first.
)}
@@ -84,10 +87,10 @@ export default async function FollowingPage({ searchParams }) {
Topics
{topics.length >= FILTER_FROM && (
-
+
)}
-