👁️
5
fork

Configure Feed

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

seperate primer page

+213
+4
src/components/richtext/RichtextSection.tsx
··· 34 34 placeholder?: string; 35 35 emptyText?: string; 36 36 availableTags?: string[]; 37 + /** Rendered next to expand/collapse button when content is truncated */ 38 + fullViewLink?: React.ReactNode; 37 39 } 38 40 39 41 const COLLAPSED_LINES = 8; ··· 47 49 placeholder = "Write something...", 48 50 emptyText = "Add a description...", 49 51 availableTags, 52 + fullViewLink, 50 53 }: RichtextSectionProps) { 51 54 const [isEditing, setIsEditing] = useState(false); 52 55 const [isExpanded, setIsExpanded] = useState(false); ··· 171 174 )} 172 175 </button> 173 176 )} 177 + {needsTruncation && fullViewLink} 174 178 {!readOnly && ( 175 179 <button 176 180 type="button"
+22
src/routeTree.gen.ts
··· 27 27 import { Route as ProfileDidDeckRkeyRouteImport } from './routes/profile/$did/deck/$rkey' 28 28 import { Route as ProfileDidListRkeyIndexRouteImport } from './routes/profile/$did/list/$rkey/index' 29 29 import { Route as ProfileDidDeckRkeyIndexRouteImport } from './routes/profile/$did/deck/$rkey/index' 30 + import { Route as ProfileDidDeckRkeyPrimerRouteImport } from './routes/profile/$did/deck/$rkey/primer' 30 31 import { Route as ProfileDidDeckRkeyPlayRouteImport } from './routes/profile/$did/deck/$rkey/play' 31 32 import { Route as ProfileDidDeckRkeyExportRouteImport } from './routes/profile/$did/deck/$rkey/export' 32 33 import { Route as ProfileDidDeckRkeyBulkEditRouteImport } from './routes/profile/$did/deck/$rkey/bulk-edit' ··· 121 122 path: '/', 122 123 getParentRoute: () => ProfileDidDeckRkeyRoute, 123 124 } as any) 125 + const ProfileDidDeckRkeyPrimerRoute = 126 + ProfileDidDeckRkeyPrimerRouteImport.update({ 127 + id: '/primer', 128 + path: '/primer', 129 + getParentRoute: () => ProfileDidDeckRkeyRoute, 130 + } as any) 124 131 const ProfileDidDeckRkeyPlayRoute = ProfileDidDeckRkeyPlayRouteImport.update({ 125 132 id: '/play', 126 133 path: '/play', ··· 159 166 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 160 167 '/profile/$did/deck/$rkey/export': typeof ProfileDidDeckRkeyExportRoute 161 168 '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 169 + '/profile/$did/deck/$rkey/primer': typeof ProfileDidDeckRkeyPrimerRoute 162 170 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 163 171 '/profile/$did/list/$rkey/': typeof ProfileDidListRkeyIndexRoute 164 172 } ··· 180 188 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 181 189 '/profile/$did/deck/$rkey/export': typeof ProfileDidDeckRkeyExportRoute 182 190 '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 191 + '/profile/$did/deck/$rkey/primer': typeof ProfileDidDeckRkeyPrimerRoute 183 192 '/profile/$did/deck/$rkey': typeof ProfileDidDeckRkeyIndexRoute 184 193 '/profile/$did/list/$rkey': typeof ProfileDidListRkeyIndexRoute 185 194 } ··· 204 213 '/profile/$did/deck/$rkey/bulk-edit': typeof ProfileDidDeckRkeyBulkEditRoute 205 214 '/profile/$did/deck/$rkey/export': typeof ProfileDidDeckRkeyExportRoute 206 215 '/profile/$did/deck/$rkey/play': typeof ProfileDidDeckRkeyPlayRoute 216 + '/profile/$did/deck/$rkey/primer': typeof ProfileDidDeckRkeyPrimerRoute 207 217 '/profile/$did/deck/$rkey/': typeof ProfileDidDeckRkeyIndexRoute 208 218 '/profile/$did/list/$rkey/': typeof ProfileDidListRkeyIndexRoute 209 219 } ··· 229 239 | '/profile/$did/deck/$rkey/bulk-edit' 230 240 | '/profile/$did/deck/$rkey/export' 231 241 | '/profile/$did/deck/$rkey/play' 242 + | '/profile/$did/deck/$rkey/primer' 232 243 | '/profile/$did/deck/$rkey/' 233 244 | '/profile/$did/list/$rkey/' 234 245 fileRoutesByTo: FileRoutesByTo ··· 250 261 | '/profile/$did/deck/$rkey/bulk-edit' 251 262 | '/profile/$did/deck/$rkey/export' 252 263 | '/profile/$did/deck/$rkey/play' 264 + | '/profile/$did/deck/$rkey/primer' 253 265 | '/profile/$did/deck/$rkey' 254 266 | '/profile/$did/list/$rkey' 255 267 id: ··· 273 285 | '/profile/$did/deck/$rkey/bulk-edit' 274 286 | '/profile/$did/deck/$rkey/export' 275 287 | '/profile/$did/deck/$rkey/play' 288 + | '/profile/$did/deck/$rkey/primer' 276 289 | '/profile/$did/deck/$rkey/' 277 290 | '/profile/$did/list/$rkey/' 278 291 fileRoutesById: FileRoutesById ··· 422 435 preLoaderRoute: typeof ProfileDidDeckRkeyIndexRouteImport 423 436 parentRoute: typeof ProfileDidDeckRkeyRoute 424 437 } 438 + '/profile/$did/deck/$rkey/primer': { 439 + id: '/profile/$did/deck/$rkey/primer' 440 + path: '/primer' 441 + fullPath: '/profile/$did/deck/$rkey/primer' 442 + preLoaderRoute: typeof ProfileDidDeckRkeyPrimerRouteImport 443 + parentRoute: typeof ProfileDidDeckRkeyRoute 444 + } 425 445 '/profile/$did/deck/$rkey/play': { 426 446 id: '/profile/$did/deck/$rkey/play' 427 447 path: '/play' ··· 464 484 ProfileDidDeckRkeyBulkEditRoute: typeof ProfileDidDeckRkeyBulkEditRoute 465 485 ProfileDidDeckRkeyExportRoute: typeof ProfileDidDeckRkeyExportRoute 466 486 ProfileDidDeckRkeyPlayRoute: typeof ProfileDidDeckRkeyPlayRoute 487 + ProfileDidDeckRkeyPrimerRoute: typeof ProfileDidDeckRkeyPrimerRoute 467 488 ProfileDidDeckRkeyIndexRoute: typeof ProfileDidDeckRkeyIndexRoute 468 489 } 469 490 ··· 471 492 ProfileDidDeckRkeyBulkEditRoute: ProfileDidDeckRkeyBulkEditRoute, 472 493 ProfileDidDeckRkeyExportRoute: ProfileDidDeckRkeyExportRoute, 473 494 ProfileDidDeckRkeyPlayRoute: ProfileDidDeckRkeyPlayRoute, 495 + ProfileDidDeckRkeyPrimerRoute: ProfileDidDeckRkeyPrimerRoute, 474 496 ProfileDidDeckRkeyIndexRoute: ProfileDidDeckRkeyIndexRoute, 475 497 } 476 498
+12
src/routes/profile/$did/deck/$rkey/index.tsx
··· 2 2 import { type DragEndEvent, useDndMonitor } from "@dnd-kit/core"; 3 3 import { useSuspenseQuery } from "@tanstack/react-query"; 4 4 import { createFileRoute, Link } from "@tanstack/react-router"; 5 + import { Link as LinkIcon } from "lucide-react"; 5 6 import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 6 7 import { ErrorBoundary } from "react-error-boundary"; 7 8 import { toast } from "sonner"; ··· 688 689 readOnly={!isOwner} 689 690 placeholder="Write about your deck's strategy, key combos, card choices..." 690 691 availableTags={allTags} 692 + fullViewLink={ 693 + <Link 694 + to="/profile/$did/deck/$rkey/primer" 695 + params={{ did, rkey }} 696 + target="_blank" 697 + className="inline-flex items-center gap-1 px-2 py-1 text-sm font-medium rounded-md bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 text-gray-700 dark:text-zinc-300" 698 + > 699 + <LinkIcon className="w-4 h-4" /> 700 + Open 701 + </Link> 702 + } 691 703 /> 692 704 </TagClickContext.Provider> 693 705 </ErrorBoundary>
+175
src/routes/profile/$did/deck/$rkey/primer.tsx
··· 1 + import type { Did } from "@atcute/lexicons"; 2 + import { useSuspenseQuery } from "@tanstack/react-query"; 3 + import { createFileRoute, Link } from "@tanstack/react-router"; 4 + import { ArrowLeft } from "lucide-react"; 5 + import { useState } from "react"; 6 + import { CommentsPanel } from "@/components/comments/CommentsPanel"; 7 + import { Drawer } from "@/components/Drawer"; 8 + import { HandleLink } from "@/components/HandleLink"; 9 + import { RichtextRenderer } from "@/components/richtext/RichtextRenderer"; 10 + import { SocialStats } from "@/components/social/SocialStats"; 11 + import { asRkey } from "@/lib/atproto-client"; 12 + import { DECK_LIST_NSID } from "@/lib/constellation-client"; 13 + import { prefetchSocialStats } from "@/lib/constellation-queries"; 14 + import { getDeckQueryOptions } from "@/lib/deck-queries"; 15 + import { getEmbeddedPrimer } from "@/lib/deck-types"; 16 + import { didDocumentQueryOptions, extractHandle } from "@/lib/did-to-handle"; 17 + import { formatDisplayName } from "@/lib/format-utils"; 18 + import { documentToPlainText } from "@/lib/richtext-convert"; 19 + import type { DeckItemUri } from "@/lib/social-item-types"; 20 + 21 + export const Route = createFileRoute("/profile/$did/deck/$rkey/primer")({ 22 + component: PrimerPage, 23 + loader: async ({ context, params }) => { 24 + const [{ deck, cid }, didDoc] = await Promise.all([ 25 + context.queryClient.ensureQueryData( 26 + getDeckQueryOptions(params.did as Did, asRkey(params.rkey)), 27 + ), 28 + context.queryClient.ensureQueryData( 29 + didDocumentQueryOptions(params.did as Did), 30 + ), 31 + ]); 32 + 33 + const deckUri = 34 + `at://${params.did}/${DECK_LIST_NSID}/${params.rkey}` as DeckItemUri; 35 + 36 + await prefetchSocialStats(context.queryClient, { 37 + type: "deck", 38 + uri: deckUri, 39 + cid, 40 + }); 41 + 42 + const handle = extractHandle(didDoc); 43 + 44 + return { deck, cid, handle }; 45 + }, 46 + head: ({ loaderData }) => { 47 + if (!loaderData) { 48 + return { meta: [{ title: "Primer Not Found | DeckBelcher" }] }; 49 + } 50 + 51 + const { deck, handle } = loaderData; 52 + const format = formatDisplayName(deck.format); 53 + const byline = handle ? ` by @${handle}` : ""; 54 + const title = format 55 + ? `${deck.name} Primer (${format})${byline} | DeckBelcher` 56 + : `${deck.name} Primer${byline} | DeckBelcher`; 57 + 58 + const ogTitle = format 59 + ? `${deck.name} Primer (${format})${byline}` 60 + : `${deck.name} Primer${byline}`; 61 + 62 + const embeddedPrimer = getEmbeddedPrimer(deck.primer); 63 + const primerText = embeddedPrimer 64 + ? documentToPlainText(embeddedPrimer) 65 + : undefined; 66 + const description = primerText 67 + ? `${primerText.slice(0, 150)}${primerText.length > 150 ? "..." : ""}` 68 + : `Deck primer for ${deck.name}`; 69 + 70 + return { 71 + meta: [ 72 + { title }, 73 + { name: "description", content: description }, 74 + { property: "og:title", content: ogTitle }, 75 + { property: "og:description", content: description }, 76 + { property: "og:image", content: "/logo512-maskable.png" }, 77 + { property: "og:image:width", content: "512" }, 78 + { property: "og:image:height", content: "512" }, 79 + { property: "og:type", content: "article" }, 80 + { name: "twitter:card", content: "summary" }, 81 + { name: "twitter:title", content: ogTitle }, 82 + { name: "twitter:description", content: description }, 83 + { name: "twitter:image", content: "/logo512-maskable.png" }, 84 + ], 85 + }; 86 + }, 87 + }); 88 + 89 + function PrimerPage() { 90 + const { did, rkey } = Route.useParams(); 91 + const { data: deckRecord } = useSuspenseQuery( 92 + getDeckQueryOptions(did as Did, asRkey(rkey)), 93 + ); 94 + const deck = deckRecord.deck; 95 + const primer = getEmbeddedPrimer(deck.primer); 96 + 97 + const [isCommentsDrawerOpen, setIsCommentsDrawerOpen] = useState(false); 98 + 99 + const deckUri = `at://${did}/${DECK_LIST_NSID}/${rkey}` as DeckItemUri; 100 + 101 + const format = formatDisplayName(deck.format); 102 + 103 + return ( 104 + <div className="min-h-screen bg-white dark:bg-zinc-900"> 105 + <div className="max-w-3xl mx-auto px-6 py-8"> 106 + {/* Back link */} 107 + <Link 108 + to="/profile/$did/deck/$rkey" 109 + params={{ did, rkey }} 110 + className="inline-flex items-center gap-1.5 text-sm text-gray-500 dark:text-zinc-400 hover:text-cyan-600 dark:hover:text-cyan-400 mb-6" 111 + > 112 + <ArrowLeft className="w-4 h-4" /> 113 + Back to deck 114 + </Link> 115 + 116 + {/* Header */} 117 + <header className="mb-4"> 118 + <h1 className="text-4xl font-bold text-gray-900 dark:text-white font-display"> 119 + {deck.name} 120 + </h1> 121 + <p className="mt-2 text-sm text-gray-500 dark:text-zinc-400"> 122 + <HandleLink did={did as Did} prefix="by" /> 123 + {format && <span> · {format}</span>} 124 + </p> 125 + </header> 126 + 127 + {/* Social stats */} 128 + <div className="mb-8 pb-6 border-b border-gray-200 dark:border-zinc-700"> 129 + <SocialStats 130 + item={{ 131 + type: "deck", 132 + uri: deckUri, 133 + cid: deckRecord.cid, 134 + }} 135 + itemName={deck.name} 136 + onCommentClick={() => setIsCommentsDrawerOpen(true)} 137 + /> 138 + </div> 139 + 140 + {/* Primer content */} 141 + {primer ? ( 142 + <article className="prose prose-gray dark:prose-invert max-w-none"> 143 + <RichtextRenderer 144 + doc={primer} 145 + className="text-gray-700 dark:text-zinc-300" 146 + /> 147 + </article> 148 + ) : ( 149 + <p className="text-gray-500 dark:text-zinc-400 italic"> 150 + No primer has been written for this deck yet. 151 + </p> 152 + )} 153 + </div> 154 + 155 + {/* Comments drawer */} 156 + <Drawer 157 + isOpen={isCommentsDrawerOpen} 158 + onClose={() => setIsCommentsDrawerOpen(false)} 159 + size="lg" 160 + aria-label={`Comments on ${deck.name}`} 161 + > 162 + <CommentsPanel 163 + subject={{ 164 + $type: "com.deckbelcher.social.comment#recordSubject", 165 + ref: { uri: deckUri, cid: deckRecord.cid }, 166 + }} 167 + item={{ type: "deck", uri: deckUri, cid: deckRecord.cid }} 168 + title={`Comments on ${deck.name}`} 169 + onClose={() => setIsCommentsDrawerOpen(false)} 170 + maxHeight="max-h-screen" 171 + /> 172 + </Drawer> 173 + </div> 174 + ); 175 + }