Blogging platform with advanced tools for arts and sciences.
6

Configure Feed

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

Extract publication card into reusable component

lemma (Jun 7, 2026, 4:44 PM -0500) 73409277 7192c2f9

+83 -72
+49
app/components/PublicationCard.tsx
··· 1 + import { useState } from 'react' 2 + import { RssIcon } from '@heroicons/react/24/outline' 3 + 4 + interface Props { 5 + pub: { atUri: string; name: string; url: string; description?: string } 6 + action?: React.ReactNode 7 + } 8 + 9 + export default function PublicationCard({ pub, action }: Props) { 10 + const [copied, setCopied] = useState(false) 11 + 12 + async function handleCopyFeed() { 13 + const feedUrl = `${pub.url.replace(/\/$/, '')}/feed.xml` 14 + try { 15 + await navigator.clipboard.writeText(feedUrl) 16 + setCopied(true) 17 + setTimeout(() => setCopied(false), 2500) 18 + } catch { /* ignore */ } 19 + } 20 + 21 + return ( 22 + <li 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"> 23 + <div className="min-w-0"> 24 + <p className="text-sm font-medium text-stone-900 dark:text-white truncate">{pub.name}</p> 25 + <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{new URL(pub.url).host}</p> 26 + {pub.description && ( 27 + <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{pub.description}</p> 28 + )} 29 + {copied && ( 30 + <p className="text-xs text-green-600 dark:text-green-400 mt-1"> 31 + Feed URL copied — paste into your RSS reader 32 + </p> 33 + )} 34 + </div> 35 + <div className="shrink-0 flex items-center gap-2"> 36 + {new URL(pub.url).origin === window.location.origin && ( 37 + <button 38 + onClick={handleCopyFeed} 39 + title="Copy Atom feed URL" 40 + className="p-1.5 rounded-md text-stone-400 dark:text-stone-500 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors" 41 + > 42 + <RssIcon className="h-4 w-4" /> 43 + </button> 44 + )} 45 + {action} 46 + </div> 47 + </li> 48 + ) 49 + }
+21 -54
app/routes/profile.tsx
··· 12 12 import CenteredMessage from '~/components/CenteredMessage' 13 13 import PageLayout from '~/components/PageLayout' 14 14 import Spinner from '~/components/Spinner' 15 - import { RssIcon } from '@heroicons/react/24/outline' 15 + import PublicationCard from '~/components/PublicationCard' 16 16 import type { Route } from './+types/profile' 17 17 import { SITE_NAME } from '~/lib/site-config' 18 18 ··· 31 31 // publicationAtUri → subscriptionRkey; undefined = not yet loaded 32 32 const [subscriptionsByPub, setSubscriptionsByPub] = useState<Record<string, string> | undefined>(undefined) 33 33 const [loadingPubs, setLoadingPubs] = useState<Record<string, boolean>>({}) 34 - const [copiedPubUri, setCopiedPubUri] = useState<string | null>(null) 34 + 35 35 36 36 useEffect(() => { 37 37 if (!id) return ··· 129 129 } 130 130 } 131 131 132 - async function handleCopyFeed(pub: Publication) { 133 - const feedUrl = `${pub.url.replace(/\/$/, '')}/feed.xml` 134 - try { 135 - await navigator.clipboard.writeText(feedUrl) 136 - setCopiedPubUri(pub.atUri) 137 - setTimeout(() => setCopiedPubUri((cur) => (cur === pub.atUri ? null : cur)), 2500) 138 - } catch { /* ignore */ } 139 - } 140 - 141 132 if (error) { 142 133 return <CenteredMessage message={error} error action={{ label: 'Go home', to: '/' }} /> 143 134 } ··· 167 158 const isSubscribed = !!subscriptionsByPub?.[pub.atUri] 168 159 const isLoading = !!loadingPubs[pub.atUri] 169 160 return ( 170 - <li 161 + <PublicationCard 171 162 key={pub.atUri} 172 - 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" 173 - > 174 - <div className="min-w-0"> 175 - <p className="text-sm font-medium text-stone-900 dark:text-white truncate">{pub.name}</p> 176 - <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{new URL(pub.url).host}</p> 177 - {pub.description && ( 178 - <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{pub.description}</p> 179 - )} 180 - {copiedPubUri === pub.atUri && ( 181 - <p className="text-xs text-green-600 dark:text-green-400 mt-1"> 182 - Feed URL copied — paste into your RSS reader 183 - </p> 184 - )} 185 - </div> 186 - <div className="shrink-0 flex items-center gap-2"> 187 - {new URL(pub.url).origin === window.location.origin && ( 163 + pub={pub} 164 + action={showSubscribeButtons && ( 165 + isSubscribed ? ( 166 + <button 167 + onClick={() => handleUnsubscribe(pub)} 168 + disabled={isLoading} 169 + className="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" 170 + > 171 + {isLoading ? 'Unsubscribing…' : 'Subscribed'} 172 + </button> 173 + ) : ( 188 174 <button 189 - onClick={() => handleCopyFeed(pub)} 190 - title="Copy Atom feed URL" 191 - className="p-1.5 rounded-md text-stone-400 dark:text-stone-500 hover:text-orange-500 dark:hover:text-orange-400 hover:bg-stone-100 dark:hover:bg-stone-800 transition-colors" 175 + onClick={() => handleSubscribe(pub)} 176 + disabled={isLoading} 177 + className="text-sm px-3 py-1 rounded-full bg-stone-900 dark:bg-stone-100 text-stone-50 dark:text-stone-900 hover:bg-stone-700 dark:hover:bg-stone-200 transition-colors disabled:opacity-50" 192 178 > 193 - <RssIcon className="h-4 w-4" /> 179 + {isLoading ? 'Subscribing…' : 'Subscribe'} 194 180 </button> 195 - )} 196 - {showSubscribeButtons && ( 197 - isSubscribed ? ( 198 - <button 199 - onClick={() => handleUnsubscribe(pub)} 200 - disabled={isLoading} 201 - className="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" 202 - > 203 - {isLoading ? 'Unsubscribing…' : 'Subscribed'} 204 - </button> 205 - ) : ( 206 - <button 207 - onClick={() => handleSubscribe(pub)} 208 - disabled={isLoading} 209 - className="text-sm px-3 py-1 rounded-full bg-stone-900 dark:bg-stone-100 text-stone-50 dark:text-stone-900 hover:bg-stone-700 dark:hover:bg-stone-200 transition-colors disabled:opacity-50" 210 - > 211 - {isLoading ? 'Subscribing…' : 'Subscribe'} 212 - </button> 213 - ) 214 - )} 215 - </div> 216 - </li> 181 + ) 182 + )} 183 + /> 217 184 ) 218 185 })} 219 186 </ul>
+13 -18
app/routes/subscriptions.tsx
··· 6 6 import EmptyState from '~/components/EmptyState' 7 7 import PageLayout from '~/components/PageLayout' 8 8 import Spinner from '~/components/Spinner' 9 + import PublicationCard from '~/components/PublicationCard' 9 10 import { SITE_NAME } from '~/lib/site-config' 10 11 11 12 export function meta() { ··· 176 177 {resolvedPubs.map((pub) => { 177 178 const isLoading = !!loadingPubs[pub.atUri] 178 179 return ( 179 - <li 180 + <PublicationCard 180 181 key={pub.atUri} 181 - 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" 182 - > 183 - <div className="min-w-0"> 184 - <p className="text-sm font-medium text-stone-900 dark:text-white truncate">{pub.name}</p> 185 - <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{new URL(pub.url).host}</p> 186 - {pub.description && ( 187 - <p className="text-sm text-stone-500 dark:text-stone-400 truncate mt-0.5">{pub.description}</p> 188 - )} 189 - </div> 190 - <button 191 - onClick={() => handleUnsubscribe(pub)} 192 - disabled={isLoading} 193 - 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" 194 - > 195 - {isLoading ? 'Unsubscribing…' : 'Subscribed'} 196 - </button> 197 - </li> 182 + pub={pub} 183 + action={ 184 + <button 185 + onClick={() => handleUnsubscribe(pub)} 186 + disabled={isLoading} 187 + className="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" 188 + > 189 + {isLoading ? 'Unsubscribing…' : 'Subscribed'} 190 + </button> 191 + } 192 + /> 198 193 ) 199 194 })} 200 195 </ul>