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 textContent/description to post cards across all feeds and profile pages

- Consolidate PostList to use PostCard, eliminating duplicate card rendering
and adding draft badge/styling support to PostCard via isDraft/editTo props
- Add textContent and description to Post type and to getUserPublicPosts and
getUserBlogPosts API responses so profile and home pages show snippets
- Add markdownToText and description extraction to the backfill Lambda so
re-indexing populates textContent on existing post records
- Add textContent to celebrity timeline items (was missing from getTimeline)

lemma (Jun 5, 2026, 5:24 PM -0500) 2ccf263f 9d6f4ee0

+83 -91
+3
CLAUDE.md
··· 47 47 | `infra/indexer/` (code only) | `AWS_PROFILE=localstack make -C infra run-local-indexer` | `AWS_PROFILE=production make -C infra update-aws-indexer` | 48 48 | `infra/indexer/` (CloudFormation changes) | `AWS_PROFILE=localstack make -C infra deploy-local-indexer` | `AWS_PROFILE=production make -C infra deploy-aws-indexer` | 49 49 | Restart indexer (no code change) | — | `AWS_PROFILE=production make -C infra restart-aws-indexer` | 50 + | `infra/backfill/` (Lambda code only) | `AWS_PROFILE=localstack make -C infra update-local-backfill` | `AWS_PROFILE=production make -C infra update-aws-backfill` | 51 + | `infra/backfill/` (CloudFormation changes) | `AWS_PROFILE=localstack make -C infra deploy-local-backfill` | `AWS_PROFILE=production make -C infra deploy-aws-backfill` | 52 + | Run backfill | `AWS_PROFILE=localstack make -C infra run-local-backfill DIDS_FILE=~/Drive/dids.txt` | `AWS_PROFILE=production make -C infra run-aws-backfill DIDS_FILE=~/Drive/dids.txt` | 50 53 51 54 `DOMAIN_NAME=lemma.pub` is required for `deploy-site` and `create-aws-api` (sets `API_DID=did:web:lemma.pub` on the Lambda — omitting it resets `API_DID` to `did:web:localhost` and breaks all authenticated endpoints). Lambda-zip-only and indexer targets do not use it. 52 55
+22 -8
app/components/PostCard.tsx
··· 18 18 path?: string 19 19 textContent?: string 20 20 description?: string 21 + isDraft?: boolean 22 + editTo?: string 21 23 } 22 24 23 25 export default function PostCard({ 24 - authorDid, rkey, pubRkey, title, publishedAt, resolvedHandle, publicationName, coverImageCid, resolvedUrl, path, textContent, description, 26 + authorDid, rkey, pubRkey, title, publishedAt, resolvedHandle, publicationName, 27 + coverImageCid, resolvedUrl, path, textContent, description, isDraft, editTo, 25 28 }: Props) { 26 29 const fullUrl = resolvedUrl && path ? resolvedUrl + path : null 27 - const href = fullUrl && isExternalUrl(fullUrl) ? fullUrl : null 28 - const innerTo = fullUrl 30 + const href = !isDraft && fullUrl && isExternalUrl(fullUrl) ? fullUrl : null 31 + const innerTo = editTo ?? (fullUrl 29 32 ? new URL(fullUrl).pathname 30 33 : pubRkey 31 34 ? `/${encodeURIComponent(authorDid)}/pub/${encodeURIComponent(pubRkey)}/${encodeURIComponent(rkey)}` 32 - : '/' 35 + : '/') 33 36 34 37 const cardInner = ( 35 38 <div className="flex items-stretch min-h-[5rem]"> ··· 44 47 </div> 45 48 )} 46 49 <div className="flex-1 px-4 py-3.5 min-w-0"> 47 - <p className="font-display text-base font-medium text-stone-900 dark:text-white line-clamp-2 leading-snug"> 48 - {title} 49 - </p> 50 + <div className="flex items-start gap-2"> 51 + <p className="font-display text-base font-medium text-stone-900 dark:text-white line-clamp-2 leading-snug flex-1"> 52 + {title} 53 + </p> 54 + {isDraft && ( 55 + <span className="shrink-0 text-sm font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 mt-px"> 56 + Draft 57 + </span> 58 + )} 59 + </div> 50 60 {(textContent || description) && ( 51 61 <p className="text-sm text-stone-500 dark:text-stone-400 line-clamp-2 mt-1 leading-snug"> 52 62 {textContent || description} ··· 65 75 ) 66 76 67 77 return ( 68 - <li className="group rounded-lg border border-stone-200 dark:border-stone-700/80 bg-white dark:bg-stone-900/40 overflow-hidden hover:border-stone-300 dark:hover:border-stone-600 hover:shadow-md hover:-translate-y-px transition-all duration-150"> 78 + <li className={`group rounded-lg border overflow-hidden transition-all duration-150 ${ 79 + isDraft 80 + ? 'border-amber-200/70 dark:border-amber-900/40 bg-amber-50/40 dark:bg-amber-950/10' 81 + : 'border-stone-200 dark:border-stone-700/80 bg-white dark:bg-stone-900/40 hover:border-stone-300 dark:hover:border-stone-600 hover:shadow-md hover:-translate-y-px' 82 + }`}> 69 83 {href ? ( 70 84 <a href={href} target="_blank" rel="noopener noreferrer" className="block"> 71 85 {cardInner}
+30 -82
app/components/PostList.tsx
··· 1 1 import { useEffect, useRef, useState } from 'react' 2 - import { Link } from 'react-router' 3 2 import type { Post } from '~/lib/api-client' 4 - import CoverImage from '~/components/CoverImage' 3 + import PostCard from '~/components/PostCard' 5 4 import EmptyState from '~/components/EmptyState' 6 5 7 6 const PAGE_SIZE = 10 8 - 9 - function isExternalUrl(url: string): boolean { 10 - try { return new URL(url).origin !== window.location.origin } catch { return true } 11 - } 12 7 13 8 export default function PostList({ 14 9 posts, ··· 49 44 50 45 return ( 51 46 <> 52 - <ul className="space-y-2.5"> 53 - {visible.map((post) => { 54 - const rkey = post.atUri.split('/').pop()! 55 - const did = post.atUri.split('/')[2] 56 - const isPublished = post.visibility === 'published' 57 - const resolvedUrl = 58 - isPublished && post.resolvedUrl && post.path 59 - ? post.resolvedUrl + post.path 47 + <ul className="space-y-2.5"> 48 + {visible.map((post) => { 49 + const rkey = post.atUri.split('/').pop()! 50 + const did = post.atUri.split('/')[2] 51 + const isDraft = post.visibility !== 'published' 52 + const draftPubRkey = post.resolvedUrl 53 + ? new URL(post.resolvedUrl).pathname.split('/').pop()! 60 54 : null 61 - const externalUrl = resolvedUrl && isExternalUrl(resolvedUrl) ? resolvedUrl : null 62 - const draftPubRkey = post.resolvedUrl ? new URL(post.resolvedUrl).pathname.split('/').pop()! : null 63 - const innerTo = resolvedUrl 64 - ? new URL(resolvedUrl).pathname 65 - : draftPubRkey 55 + const editTo = isDraft && draftPubRkey 66 56 ? `/${encodeURIComponent(handle)}/pub/${draftPubRkey}/${encodeURIComponent(rkey)}/edit` 67 - : '/' 68 - 69 - const cardInner = ( 70 - <div className="flex items-stretch min-h-[5rem]"> 71 - {post.coverImageRef && ( 72 - <div className="w-20 md:w-24 shrink-0 overflow-hidden bg-stone-100 dark:bg-stone-800"> 73 - <CoverImage 74 - did={did} 75 - cid={post.coverImageRef} 76 - thumbnail 77 - className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" 78 - /> 79 - </div> 80 - )} 81 - <div className="flex-1 px-4 py-3.5 min-w-0"> 82 - <div className="flex items-start gap-2"> 83 - <span className="font-display text-base font-medium text-stone-900 dark:text-white line-clamp-2 flex-1 leading-snug"> 84 - {post.title} 85 - </span> 86 - {!isPublished && ( 87 - <span className="shrink-0 text-sm font-medium text-amber-600 dark:text-amber-400 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded px-1.5 py-0.5 mt-px"> 88 - Draft 89 - </span> 90 - )} 91 - </div> 92 - <div className="flex items-center justify-between gap-2 mt-1.5"> 93 - <p className="text-sm text-stone-400 dark:text-stone-500 truncate"> 94 - {post.resolvedPubName ?? ''} 95 - </p> 96 - <p className="text-sm text-stone-400 dark:text-stone-500 shrink-0 tabular-nums"> 97 - {new Date(post.publishedAt).toLocaleDateString(undefined, { 98 - year: 'numeric', 99 - month: 'short', 100 - day: 'numeric', 101 - })} 102 - </p> 103 - </div> 104 - </div> 105 - </div> 106 - ) 57 + : undefined 107 58 108 - return ( 109 - <li 110 - key={post.sk} 111 - className={`group rounded-lg border overflow-hidden transition-all duration-150 ${ 112 - isPublished 113 - ? 'border-stone-200 dark:border-stone-700/80 bg-white dark:bg-stone-900/40 hover:border-stone-300 dark:hover:border-stone-600 hover:shadow-md hover:-translate-y-px' 114 - : 'border-amber-200/70 dark:border-amber-900/40 bg-amber-50/40 dark:bg-amber-950/10' 115 - }`} 116 - > 117 - {externalUrl ? ( 118 - <a href={externalUrl} target="_blank" rel="noopener noreferrer" className="block"> 119 - {cardInner} 120 - </a> 121 - ) : ( 122 - <Link to={innerTo} className="block"> 123 - {cardInner} 124 - </Link> 125 - )} 126 - </li> 127 - ) 128 - })} 129 - </ul> 130 - <div ref={sentinelRef} /> 59 + return ( 60 + <PostCard 61 + key={post.sk} 62 + authorDid={did} 63 + rkey={rkey} 64 + title={post.title} 65 + publishedAt={post.publishedAt} 66 + publicationName={post.resolvedPubName} 67 + coverImageCid={post.coverImageRef} 68 + resolvedUrl={post.resolvedUrl} 69 + path={post.path} 70 + textContent={post.textContent} 71 + description={post.description} 72 + isDraft={isDraft} 73 + editTo={editTo} 74 + /> 75 + ) 76 + })} 77 + </ul> 78 + <div ref={sentinelRef} /> 131 79 </> 132 80 ) 133 81 }
+2
app/lib/api-client.ts
··· 57 57 coverImageRef?: string 58 58 bskyPostRef?: { uri: string; cid: string } 59 59 license?: string 60 + textContent?: string 61 + description?: string 60 62 } 61 63 62 64 export type PostDetail = Post & {
+4
infra/api/src/index.ts
··· 342 342 publishedAt: item.publishedAt as string, 343 343 visibility: 'published' as const, 344 344 coverImageRef: item.coverImageRef as string | undefined, 345 + textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined, 346 + description: item.description as string | undefined, 345 347 })); 346 348 return json(200, posts); 347 349 } ··· 370 372 publishedAt: item.publishedAt as string, 371 373 visibility: (item.visibility as string) === 'draft' ? 'draft' : 'published', 372 374 coverImageRef: item.coverImageRef as string | undefined, 375 + textContent: item.textContent ? (item.textContent as string).slice(0, 300) : undefined, 376 + description: item.description as string | undefined, 373 377 })); 374 378 return json(200, posts); 375 379 }
+22 -1
infra/backfill/src/index.ts
··· 18 18 19 19 const TABLE = process.env.DYNAMODB_TABLE ?? 'blogs' 20 20 const CONCURRENCY = parseInt(process.env.CONCURRENCY ?? '5', 10) 21 + 22 + function markdownToText(md: string): string { 23 + return md 24 + .replace(/```[\s\S]*?```/g, '') 25 + .replace(/`[^`]*`/g, '') 26 + .replace(/!\[.*?\]\(.*?\)/g, '') 27 + .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') 28 + .replace(/^#{1,6}\s+/gm, '') 29 + .replace(/(\*\*|__)(.*?)\1/g, '$2') 30 + .replace(/(\*|_)(.*?)\1/g, '$2') 31 + .replace(/^[-*+]\s+/gm, '') 32 + .replace(/^\d+\.\s+/gm, '') 33 + .replace(/^>\s+/gm, '') 34 + .replace(/\n{2,}/g, ' ') 35 + .replace(/\s+/g, ' ') 36 + .trim() 37 + } 21 38 const ACCEPT_LOOSE_DOCUMENTS = process.env.ACCEPT_LOOSE_DOCUMENTS === 'true' 22 39 const TIMELINE_TTL_SECONDS = 30 * 24 * 60 * 60 23 40 ··· 136 153 site: string 137 154 path?: string 138 155 title: string 156 + description?: string 139 157 publishedAt: string 140 158 updatedAt?: string 141 159 content?: { $type?: string; content?: string } ··· 166 184 publishedAt: record.publishedAt, 167 185 } 168 186 if (record.updatedAt) item.updatedAt = record.updatedAt 187 + if (record.description?.trim()) item.description = record.description.trim() 169 188 if (record.content?.$type === 'pub.lemma.blog.entry' && record.content.content !== undefined) { 170 189 item.content = record.content.content 190 + const text = markdownToText(record.content.content) 191 + if (text) item.textContent = text 171 192 } 172 193 if (record.coverImage?.ref.$link) item.coverImageRef = record.coverImage.ref.$link 173 194 if (record.bskyPostRef) item.bskyPostRef = record.bskyPostRef ··· 318 339 319 340 type PubRecord = { url: string; name: string; description?: string; preferences?: { showInDiscover?: boolean } } 320 341 type DocRecord = { 321 - site: string; path?: string; title: string; publishedAt: string; updatedAt?: string 342 + site: string; path?: string; title: string; description?: string; publishedAt: string; updatedAt?: string 322 343 content?: { $type?: string; content?: string } 323 344 coverImage?: { ref: { $link: string } } 324 345 bskyPostRef?: { uri: string; cid: string }