Skip to content

Commit fb91c9e

Browse files
NiallJoeMaherclaude
andcommitted
feat(relaunch): followable Publications (handoff #5 §2)
Publications = feed sources, now followable. New publication_follow table (userId↔sourceId, migration 0028) + publication tRPC router (getBySlug with follower/article counts + isFollowing, follow/unfollow). The source profile at /{slug} (_sourceProfileClient) is redesigned to the publication layout: square logo tile, "// publication" eyebrow, name + @handle, Follow/Share, tagline, Followers + Articles stats, "// latest articles" via UnifiedContentCard — no banner. Byline "in {Publication}" links (card + reader MetaHeader) point to /{slug}. (The /feed/[sourceSlug] route is 308-redirected to /{slug} by next.config, so the canonical page lives there.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ed0ce33 commit fb91c9e

10 files changed

Lines changed: 6636 additions & 206 deletions

File tree

app/(app)/[username]/_sourceProfileClient.tsx

Lines changed: 198 additions & 179 deletions
Large diffs are not rendered by default.

app/(app)/feed/[sourceSlug]/_client.tsx

Lines changed: 25 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,14 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
6060

6161
if (sourceStatus === "pending") {
6262
return (
63-
<div className="mx-auto max-w-2xl px-4 text-fg">
63+
<div className="mx-auto max-w-2xl px-4 text-black dark:text-white">
6464
<div className="pt-6 sm:flex">
6565
<div className="mr-4 flex-shrink-0 self-center">
66-
<div className="mb-2 h-20 w-20 animate-pulse rounded-full bg-inset sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32" />
66+
<div className="mb-2 h-20 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-700 sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32" />
6767
</div>
6868
<div className="flex flex-col justify-center">
69-
<div className="mb-2 h-6 w-48 animate-pulse rounded bg-inset" />
70-
<div className="h-4 w-32 animate-pulse rounded bg-inset" />
69+
<div className="mb-2 h-6 w-48 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
70+
<div className="h-4 w-32 animate-pulse rounded bg-neutral-200 dark:bg-neutral-700" />
7171
</div>
7272
</div>
7373
</div>
@@ -76,17 +76,17 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
7676

7777
if (sourceStatus === "error" || !source) {
7878
return (
79-
<div className="mx-auto max-w-2xl px-4 py-8 text-fg">
80-
<div className="rounded-lg border border-danger/30 bg-danger/12 p-6 text-center">
81-
<h1 className="text-lg font-semibold text-danger">
79+
<div className="mx-auto max-w-2xl px-4 py-8 text-black dark:text-white">
80+
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-800 dark:bg-red-950">
81+
<h1 className="text-lg font-semibold text-red-700 dark:text-red-300">
8282
Source Not Found
8383
</h1>
84-
<p className="mt-2 text-sm text-danger">
84+
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
8585
This source may have been removed or the link is invalid.
8686
</p>
8787
<Link
8888
href="/feed"
89-
className="mt-4 inline-block text-sm text-accent-soft hover:text-accent"
89+
className="mt-4 inline-block text-sm text-blue-500 hover:underline"
9090
>
9191
Back to Feed
9292
</Link>
@@ -100,7 +100,7 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
100100

101101
return (
102102
<>
103-
<div className="mx-auto max-w-2xl px-4 text-fg">
103+
<div className="text-900 mx-auto max-w-2xl px-4 text-black dark:text-white">
104104
{/* Profile header - matching user profile pattern exactly */}
105105
<div className="pt-6 sm:flex">
106106
<div className="mr-4 flex-shrink-0 self-center">
@@ -117,16 +117,16 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
117117
src={faviconUrl}
118118
/>
119119
) : (
120-
<div className="mb-2 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-accent to-accent text-3xl font-bold text-on-accent sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32 lg:text-4xl">
120+
<div className="mb-2 flex h-20 w-20 items-center justify-center rounded-full bg-gradient-to-br from-orange-400 to-orange-600 text-3xl font-bold text-white sm:mb-0 sm:h-24 sm:w-24 lg:h-32 lg:w-32 lg:text-4xl">
121121
{source.name?.charAt(0).toUpperCase() || "?"}
122122
</div>
123123
)}
124124
</div>
125125
<div className="flex flex-col justify-center">
126-
<h1 className="mb-0 font-display text-lg font-extrabold tracking-tight text-fg md:text-xl">
127-
{source.name}
128-
</h1>
129-
<h2 className="text-sm font-bold text-muted">@{sourceSlug}</h2>
126+
<h1 className="mb-0 text-lg font-bold md:text-xl">{source.name}</h1>
127+
<h2 className="text-sm font-bold text-neutral-500 dark:text-neutral-400">
128+
@{sourceSlug}
129+
</h2>
130130
<p className="mt-1">{source.description || ""}</p>
131131
{source.websiteUrl && (
132132
<Link
@@ -135,8 +135,8 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
135135
target="_blank"
136136
rel="noopener noreferrer"
137137
>
138-
<LinkIcon className="mr-2 h-5 text-muted" />
139-
<p className="mt-1 text-accent-soft hover:text-accent">
138+
<LinkIcon className="mr-2 h-5 text-neutral-500 dark:text-neutral-400" />
139+
<p className="mt-1 text-blue-500">
140140
{getDomainFromUrl(source.websiteUrl)}
141141
</p>
142142
</Link>
@@ -149,17 +149,18 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
149149
<Heading level={1}>{`Articles (${source.articleCount})`}</Heading>
150150
</div>
151151

152+
{/* Articles list using UnifiedContentCard */}
152153
<div>
153154
{articlesStatus === "pending" ? (
154155
<div className="space-y-4">
155156
{[...Array(5)].map((_, i) => (
156157
<div
157158
key={i}
158-
className="animate-pulse rounded-lg border border-hairline p-3"
159+
className="animate-pulse rounded-lg border border-neutral-200 p-3 dark:border-neutral-700"
159160
>
160-
<div className="mb-2 h-4 w-1/4 rounded bg-inset" />
161-
<div className="mb-2 h-5 w-3/4 rounded bg-inset" />
162-
<div className="h-4 w-1/2 rounded bg-inset" />
161+
<div className="mb-2 h-4 w-1/4 rounded bg-neutral-200 dark:bg-neutral-700" />
162+
<div className="mb-2 h-5 w-3/4 rounded bg-neutral-200 dark:bg-neutral-700" />
163+
<div className="h-4 w-1/2 rounded bg-neutral-200 dark:bg-neutral-700" />
163164
</div>
164165
))}
165166
</div>
@@ -198,14 +199,15 @@ const SourceProfilePage = ({ sourceSlug }: Props) => {
198199
);
199200
})}
200201

202+
{/* Load more trigger */}
201203
<div ref={loadMoreRef} className="py-4 text-center">
202204
{isFetchingNextPage && (
203-
<div className="text-sm text-muted">
205+
<div className="text-sm text-neutral-500 dark:text-neutral-400">
204206
Loading more articles...
205207
</div>
206208
)}
207209
{!hasNextPage && articles.length > 0 && (
208-
<div className="text-sm text-muted">
210+
<div className="text-sm text-neutral-500 dark:text-neutral-400">
209211
No more articles
210212
</div>
211213
)}

components/ContentDetail/MetaHeader.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,12 +109,13 @@ const ContentMetaHeader = ({
109109

110110
// Render source info (for feed articles)
111111
if (source) {
112-
const sourceLink = source.slug ? `/feed/${source.slug}` : "#";
112+
const sourceLink = source.slug ? `/${source.slug}` : "#";
113113
return (
114114
<div className="mb-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted">
115115
<Link
116116
href={sourceLink}
117-
className="flex items-center gap-2 hover:text-fg"
117+
className="flex items-center gap-2"
118+
onClick={(e) => e.stopPropagation()}
118119
>
119120
{source.logo ? (
120121
<img
@@ -129,7 +130,9 @@ const ContentMetaHeader = ({
129130
{source.name?.charAt(0).toUpperCase() || "?"}
130131
</div>
131132
)}
132-
<span className="font-medium">{source.name || "Unknown Source"}</span>
133+
<span className="whitespace-nowrap font-mono text-accent-soft hover:text-accent">
134+
in {source.name || "Unknown Source"}
135+
</span>
133136
</Link>
134137
{source.author &&
135138
source.author.trim() &&

components/UnifiedContentCard/UnifiedContentCard.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -249,7 +249,24 @@ const UnifiedContentCard = ({
249249
<span className="whitespace-nowrap font-mono text-xs text-faint">
250250
{handle ? `@${handle}` : ""}
251251
{relativeTime ? `${handle ? " · " : ""}${relativeTime}` : ""}
252-
{type === "LINK" && source?.name ? ` · via ${source.name}` : ""}
252+
{type === "LINK" && source?.name ? (
253+
<>
254+
{" · in "}
255+
{source.slug ? (
256+
<Link
257+
href={`/${source.slug}`}
258+
onClick={(e) => e.stopPropagation()}
259+
className="text-accent-soft hover:text-accent"
260+
>
261+
{source.name}
262+
</Link>
263+
) : (
264+
<span className="text-accent-soft">{source.name}</span>
265+
)}
266+
</>
267+
) : (
268+
""
269+
)}
253270
{readTimeMins ? ` · ${readTimeMins} min` : ""}
254271
</span>
255272
</div>

drizzle/0028_pale_king_cobra.sql

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
CREATE TABLE "publication_follow" (
2+
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
3+
"user_id" text NOT NULL,
4+
"source_id" integer NOT NULL,
5+
"created_at" timestamp(3) with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL
6+
);
7+
--> statement-breakpoint
8+
ALTER TABLE "publication_follow" ADD CONSTRAINT "publication_follow_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
9+
ALTER TABLE "publication_follow" ADD CONSTRAINT "publication_follow_source_id_feed_sources_id_fk" FOREIGN KEY ("source_id") REFERENCES "public"."feed_sources"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
10+
CREATE UNIQUE INDEX "publication_follow_pair_idx" ON "publication_follow" USING btree ("user_id","source_id");--> statement-breakpoint
11+
CREATE INDEX "publication_follow_source_idx" ON "publication_follow" USING btree ("source_id");

0 commit comments

Comments
 (0)