Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

Select the types of activity you want to include in your feed.

Add Subscriptions page

lemma (Jun 6, 2026, 9:02 AM -0500) 9ca91208 2ccf263f

+159 -1
+18 -1
app/components/Nav.tsx
··· 1 1 import { useState, useEffect } from 'react' 2 2 import { Link, NavLink } from 'react-router' 3 3 import { PencilSquareIcon, UserCircleIcon } from '@heroicons/react/24/solid' 4 - import { SunIcon, MoonIcon, ComputerDesktopIcon, HomeIcon, ClockIcon, UsersIcon, Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' 4 + import { SunIcon, MoonIcon, ComputerDesktopIcon, HomeIcon, ClockIcon, UsersIcon, RssIcon, Cog6ToothIcon, QuestionMarkCircleIcon } from '@heroicons/react/24/outline' 5 5 import { useAuth } from '~/lib/auth-context' 6 6 import { getStoredTheme, persistTheme, applyTheme, type Theme } from '~/lib/theme' 7 7 import Avatar from '~/components/Avatar' ··· 81 81 > 82 82 <UsersIcon className="size-5 shrink-0" /> 83 83 <span className="hidden md:inline text-sm">Following</span> 84 + </NavLink> 85 + )} 86 + 87 + {!loading && session && ( 88 + <NavLink 89 + to="/subscriptions" 90 + aria-label="Subscriptions" 91 + className={({ isActive }) => 92 + `flex items-center gap-1.5 transition-colors ${ 93 + isActive 94 + ? 'text-stone-900 dark:text-stone-100' 95 + : 'text-stone-500 dark:text-stone-400 hover:text-stone-800 dark:hover:text-stone-200' 96 + }` 97 + } 98 + > 99 + <RssIcon className="size-5 shrink-0" /> 100 + <span className="hidden md:inline text-sm">Subscriptions</span> 84 101 </NavLink> 85 102 )} 86 103
+1
app/routes.ts
··· 5 5 route("publish", "routes/publish.tsx"), 6 6 route("recent", "routes/recent.tsx"), 7 7 route("following", "routes/following.tsx"), 8 + route("subscriptions", "routes/subscriptions.tsx"), 8 9 route("settings", "routes/settings.tsx"), 9 10 route("faq", "routes/faq.tsx"), 10 11 route("help", "routes/help.tsx"),
+140
app/routes/subscriptions.tsx
··· 1 + import { useEffect, useState } from 'react' 2 + import { useAuth } from '~/lib/auth-context' 3 + import { 4 + listSubscriptions, listPublicPublications, unsubscribe, 5 + type Publication, 6 + } from '~/lib/api-client' 7 + import CenteredMessage from '~/components/CenteredMessage' 8 + import EmptyState from '~/components/EmptyState' 9 + import PageLayout from '~/components/PageLayout' 10 + import Spinner from '~/components/Spinner' 11 + import { SITE_NAME } from '~/lib/site-config' 12 + 13 + export function meta() { 14 + return [{ title: `Subscriptions — ${SITE_NAME}` }] 15 + } 16 + 17 + type SubscribedPub = Publication & { rkey: string } 18 + 19 + export default function Subscriptions() { 20 + const { session, loading: authLoading } = useAuth() 21 + const [pubs, setPubs] = useState<SubscribedPub[] | null>(null) 22 + const [loading, setLoading] = useState(false) 23 + const [error, setError] = useState<string | null>(null) 24 + const [loadingPubs, setLoadingPubs] = useState<Record<string, boolean>>({}) 25 + 26 + useEffect(() => { 27 + if (authLoading || !session) return 28 + let cancelled = false 29 + setLoading(true) 30 + 31 + async function load() { 32 + const subs = await listSubscriptions(session!) 33 + if (cancelled) return 34 + 35 + const byAuthor = new Map<string, { rkey: string; publicationAtUri: string }[]>() 36 + for (const s of subs) { 37 + const list = byAuthor.get(s.authorDid) ?? [] 38 + list.push({ rkey: s.rkey, publicationAtUri: s.publicationAtUri }) 39 + byAuthor.set(s.authorDid, list) 40 + } 41 + 42 + const results = await Promise.allSettled( 43 + [...byAuthor.entries()].map(async ([did, entries]) => { 44 + const pubList = await listPublicPublications(did) 45 + return entries.flatMap(({ rkey, publicationAtUri }) => { 46 + const pub = pubList.find((p) => p.atUri === publicationAtUri) 47 + return pub ? [{ ...pub, rkey }] : [] 48 + }) 49 + }), 50 + ) 51 + 52 + if (cancelled) return 53 + setPubs(results.flatMap((r) => (r.status === 'fulfilled' ? r.value : []))) 54 + } 55 + 56 + load() 57 + .catch((err) => { if (!cancelled) setError(err instanceof Error ? err.message : 'Failed to load') }) 58 + .finally(() => { if (!cancelled) setLoading(false) }) 59 + 60 + return () => { cancelled = true } 61 + }, [session, authLoading]) 62 + 63 + async function handleUnsubscribe(pub: SubscribedPub) { 64 + if (!session) return 65 + setLoadingPubs((prev) => ({ ...prev, [pub.atUri]: true })) 66 + try { 67 + await unsubscribe(session, pub.rkey) 68 + setPubs((prev) => prev?.filter((p) => p.atUri !== pub.atUri) ?? prev) 69 + } catch { 70 + // non-fatal 71 + } finally { 72 + setLoadingPubs((prev) => ({ ...prev, [pub.atUri]: false })) 73 + } 74 + } 75 + 76 + if (authLoading) { 77 + return ( 78 + <div className="flex-1 flex items-center justify-center"> 79 + <Spinner /> 80 + </div> 81 + ) 82 + } 83 + 84 + if (!session) { 85 + return <CenteredMessage message="Sign in to view your subscriptions." action={{ label: 'Sign in', to: '/' }} /> 86 + } 87 + 88 + return ( 89 + <PageLayout spacing="8"> 90 + <div> 91 + <h1 className="font-display text-2xl font-semibold text-stone-900 dark:text-white">Subscriptions</h1> 92 + <p className="text-sm text-stone-500 dark:text-stone-400 mt-1">Publications you follow</p> 93 + </div> 94 + 95 + {error && <p className="text-sm text-red-500">{error}</p>} 96 + 97 + {loading && ( 98 + <div className="flex justify-center py-12"> 99 + <Spinner /> 100 + </div> 101 + )} 102 + 103 + {!loading && pubs !== null && pubs.length === 0 && ( 104 + <EmptyState 105 + message="No subscriptions yet." 106 + detail="Visit an author's profile and subscribe to their publication." 107 + /> 108 + )} 109 + 110 + {pubs !== null && pubs.length > 0 && ( 111 + <ul className="space-y-2"> 112 + {pubs.map((pub) => { 113 + const isLoading = !!loadingPubs[pub.atUri] 114 + return ( 115 + <li 116 + key={pub.atUri} 117 + className="flex items-center justify-between gap-4 rounded-lg border border-stone-200 dark:border-stone-700/80 px-4 py-3 bg-white dark:bg-stone-900/40" 118 + > 119 + <div className="min-w-0"> 120 + <p className="text-sm font-medium text-stone-900 dark:text-white truncate">{pub.name}</p> 121 + <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{new URL(pub.url).host}</p> 122 + {pub.description && ( 123 + <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{pub.description}</p> 124 + )} 125 + </div> 126 + <button 127 + onClick={() => handleUnsubscribe(pub)} 128 + disabled={isLoading} 129 + className="shrink-0 text-sm px-3 py-1 rounded-full border border-stone-300 dark:border-stone-600 text-stone-600 dark:text-stone-300 hover:border-stone-400 dark:hover:border-stone-500 hover:bg-stone-50 dark:hover:bg-stone-800/50 transition-colors disabled:opacity-50" 130 + > 131 + {isLoading ? 'Unsubscribing…' : 'Subscribed'} 132 + </button> 133 + </li> 134 + ) 135 + })} 136 + </ul> 137 + )} 138 + </PageLayout> 139 + ) 140 + }