an app to share curated trails sidetrail.app
atproto nextjs react rsc
50
fork

Configure Feed

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

move embed loading to server

+238 -196
+21 -67
app/at/(trail)/[handle]/trail/[rkey]/StopEmbed.tsx
··· 1 1 "use client"; 2 2 3 - import { use, createContext, lazy } from "react"; 3 + import { use, createContext } from "react"; 4 4 import type { ExternalEmbed } from "@/data/queries"; 5 - import { LinkPreview } from "./LinkPreview"; 6 - import { loadTrailCard } from "@/app/loadTrailCard"; 7 - import type { BlueskyPost, LinkMetadata } from "./utils/embed-resolver"; 8 5 import "./embeds/TrailEmbed.css"; 9 6 import "@/app/TrailCard.css"; 10 7 11 - const BlueskyPostEmbed = lazy(() => 12 - import("./embeds/BlueskyPostEmbed").then((m) => ({ default: m.BlueskyPostEmbed })), 13 - ); 14 - 15 - async function fetchBlueskyPost(uri: string): Promise<BlueskyPost | null> { 16 - const { getBlueskyPost } = await import("./utils/embed-resolver"); 17 - return getBlueskyPost(uri); 18 - } 19 - 20 - async function fetchLinkMetadata(uri: string): Promise<LinkMetadata | null> { 21 - const { getLinkMetadata } = await import("./utils/embed-resolver"); 22 - return getLinkMetadata(uri); 23 - } 24 - 25 - type EmbedPromise = Promise<BlueskyPost | LinkMetadata | React.ReactElement | null>; 8 + type EmbedPromise = Promise<React.ReactElement | null>; 26 9 export type EmbedCache = Map<string, EmbedPromise>; 27 10 export const EmbedCacheContext = createContext<[EmbedCache | null, (c: EmbedCache) => void]>([ 28 11 null, ··· 38 21 return uri.includes("/app.sidetrail.trail/"); 39 22 } 40 23 41 - function isBlueskyPostUri(uri: string): boolean { 42 - return ( 43 - uri.includes("/app.bsky.feed.post/") || (uri.includes("bsky.app") && uri.includes("/post/")) 44 - ); 45 - } 46 - 47 - function getOrCreatePromise(uri: string, cache: EmbedCache): EmbedPromise { 24 + function getPromise(uri: string, cache: EmbedCache): EmbedPromise { 48 25 const existing = cache.get(uri); 49 - if (existing) return existing; 50 - 51 - let promise: EmbedPromise; 52 - if (isTrailUri(uri)) { 53 - promise = Promise.resolve().then(() => loadTrailCard(uri)); 54 - } else if (isBlueskyPostUri(uri)) { 55 - promise = fetchBlueskyPost(uri); 56 - } else { 57 - promise = fetchLinkMetadata(uri); 26 + if (!existing) { 27 + throw new Error(`Embed not in cache: ${uri}. Must be pre-populated before render.`); 58 28 } 59 - 60 - cache.set(uri, promise); 61 - return promise; 29 + return existing; 62 30 } 63 31 64 32 export function StopEmbed({ external, onDelete }: Props) { ··· 67 35 68 36 const uri = external.uri; 69 37 const isTrail = isTrailUri(uri); 70 - const isBluesky = isBlueskyPostUri(uri); 71 - const promise = getOrCreatePromise(uri, cache); 72 - const data = use(promise); 38 + const promise = getPromise(uri, cache); 39 + const embed = use(promise); 73 40 74 - if (!data) { 41 + if (!embed) { 75 42 return ( 76 43 <div className="BlueskyPostEmbed-container"> 77 44 <div className="BlueskyPostEmbed error"> ··· 86 53 ); 87 54 } 88 55 89 - if (isTrail) { 90 - if (onDelete) { 91 - return ( 92 - <div className="TrailEmbed-container"> 93 - {data as React.ReactElement} 94 - <button onClick={onDelete} className="TrailEmbed-deleteButton" title="remove link"> 95 - × 96 - </button> 97 - </div> 98 - ); 99 - } 100 - return data as React.ReactElement; 101 - } 102 - 103 - if (isBluesky) { 104 - return <BlueskyPostEmbed post={data as BlueskyPost} onDelete={onDelete} />; 56 + if (onDelete) { 57 + const containerClass = isTrail ? "TrailEmbed-container" : "BlueskyPostEmbed-container"; 58 + const buttonClass = isTrail ? "TrailEmbed-deleteButton" : "BlueskyPostEmbed-deleteButton"; 59 + return ( 60 + <div className={containerClass}> 61 + {embed} 62 + <button onClick={onDelete} className={buttonClass} title="remove link"> 63 + × 64 + </button> 65 + </div> 66 + ); 105 67 } 106 68 107 - const metadata = data as LinkMetadata; 108 - const mergedExternal = { 109 - ...external, 110 - title: metadata.title || external.title, 111 - description: metadata.description || external.description, 112 - thumb: metadata.thumb || external.thumb, 113 - }; 114 - 115 - return <LinkPreview external={mergedExternal} fallbackToUrl onDelete={onDelete} />; 69 + return embed; 116 70 }
+32 -34
app/at/(trail)/[handle]/trail/[rkey]/TrailStopCard.tsx
··· 5 5 import type { TrailStop } from "@/data/queries"; 6 6 import { EmbedCacheContext, StopEmbed } from "./StopEmbed"; 7 7 import { extractLink } from "./utils/linkExtraction"; 8 - import { getLinkMetadata, blueskyUrlToAtUri } from "./utils/embed-resolver"; 8 + import { loadEmbed } from "@/app/loadEmbed"; 9 + import { blueskyUrlToAtUri } from "./utils/bluesky"; 9 10 import "./TrailStopCard.css"; 10 11 11 12 function EmbedLoading() { ··· 30 31 const isEditing = !!editContext; 31 32 const updateStop = editContext?.updateStop; 32 33 const error = editContext?.inlineErrors[stop.tid]; 33 - const [, setEmbedCache] = use(EmbedCacheContext); 34 + const [embedCache, setEmbedCache] = use(EmbedCacheContext); 34 35 35 36 const textareaRef = useCallback((el: HTMLTextAreaElement | null) => { 36 37 if (el) { ··· 53 54 54 55 const pastedText = e.clipboardData.getData("text"); 55 56 const extractedLink = extractLink(pastedText); 56 - if (!extractedLink || !updateStop) return; 57 + if (!extractedLink || !updateStop || !embedCache) return; 57 58 58 - if (extractedLink.type === "app.sidetrail.trail") { 59 - e.preventDefault(); 60 - updateStop(stop.tid, { external: { uri: extractedLink.uri } }); 61 - return; 62 - } 59 + let linkUrl = extractedLink.uri; 63 60 64 - if (extractedLink.type === "app.bsky.feed.post") { 65 - e.preventDefault(); 66 - let linkUrl = extractedLink.uri; 67 - if (linkUrl.startsWith("http")) { 68 - const atUri = blueskyUrlToAtUri(linkUrl); 69 - if (atUri) linkUrl = atUri; 70 - } 71 - updateStop(stop.tid, { 72 - external: { uri: linkUrl, title: linkUrl, description: "" }, 73 - }); 74 - return; 61 + // Normalize bluesky URLs to at:// URIs 62 + if (extractedLink.type === "app.bsky.feed.post" && linkUrl.startsWith("http")) { 63 + const atUri = blueskyUrlToAtUri(linkUrl); 64 + if (atUri) linkUrl = atUri; 75 65 } 76 66 77 - const linkUrl = extractedLink.uri; 78 - 67 + // Prevent default paste for special embed types 79 68 const embedDomains = [ 80 69 "youtube.com", 81 70 "youtu.be", ··· 87 76 "giphy.com", 88 77 "tenor.com", 89 78 ]; 90 - try { 91 - const host = new URL(linkUrl).hostname.replace(/^www\./, ""); 92 - if (embedDomains.some((d) => host === d || host.endsWith("." + d))) { 93 - e.preventDefault(); 94 - } 95 - } catch {} 79 + const isSpecialEmbed = 80 + extractedLink.type === "app.sidetrail.trail" || 81 + extractedLink.type === "app.bsky.feed.post" || 82 + (() => { 83 + try { 84 + const host = new URL(linkUrl).hostname.replace(/^www\./, ""); 85 + return embedDomains.some((d) => host === d || host.endsWith("." + d)); 86 + } catch { 87 + return false; 88 + } 89 + })(); 90 + 91 + if (isSpecialEmbed) { 92 + e.preventDefault(); 93 + } 96 94 97 - updateStop(stop.tid, { 98 - external: { uri: linkUrl, title: linkUrl, description: "" }, 99 - }); 95 + // Start loading embed and add to cache 96 + const embedPromise = loadEmbed(linkUrl); 97 + const newCache = new Map(embedCache); 98 + newCache.set(linkUrl, embedPromise); 99 + setEmbedCache(newCache); 100 100 101 - const metadata = await getLinkMetadata(linkUrl); 102 - if (metadata && updateStop) { 103 - updateStop(stop.tid, { external: metadata }); 104 - } 101 + // Update stop with the external link 102 + updateStop(stop.tid, { external: { uri: linkUrl } }); 105 103 }; 106 104 107 105 const handleRemoveLink = () => {
+9 -2
app/at/(trail)/[handle]/trail/[rkey]/TrailView.tsx
··· 11 11 cleanHandle: string; 12 12 rkey: string; 13 13 currentUserDid: string | null; 14 + initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>; 14 15 }; 15 16 16 17 type ViewMode = "overview" | "walk"; 17 18 18 - export function TrailView({ trail, cleanHandle, rkey, currentUserDid }: Props) { 19 + export function TrailView({ trail, cleanHandle, rkey, currentUserDid, initialEmbeds }: Props) { 19 20 const [viewMode, setViewMode] = useState<ViewMode>("overview"); 20 21 21 22 const onLeave = useEffectEvent(() => { ··· 62 63 } 63 64 64 65 return ( 65 - <TrailWalk trail={trail} cleanHandle={cleanHandle} rkey={rkey} onModeChange={setViewMode} /> 66 + <TrailWalk 67 + trail={trail} 68 + cleanHandle={cleanHandle} 69 + rkey={rkey} 70 + onModeChange={setViewMode} 71 + initialEmbeds={initialEmbeds} 72 + /> 66 73 ); 67 74 }
+28 -3
app/at/(trail)/[handle]/trail/[rkey]/TrailWalk.tsx
··· 15 15 16 16 import { EmbedCacheContext } from "./StopEmbed"; 17 17 18 - function RevealedStop({ revealed, children }: { revealed: boolean; children: ReactNode }) { 19 - const cacheAndSetCache = useState<EmbedCache>(() => new Map()); 18 + function RevealedStop({ 19 + revealed, 20 + children, 21 + initialEmbed, 22 + }: { 23 + revealed: boolean; 24 + children: ReactNode; 25 + initialEmbed?: [string, Promise<React.ReactElement | null>]; 26 + }) { 27 + const cacheAndSetCache = useState<EmbedCache>(() => new Map(initialEmbed ? [initialEmbed] : [])); 20 28 return ( 21 29 <EmbedCacheContext value={cacheAndSetCache}> 22 30 <Activity mode={revealed ? "visible" : "hidden"}>{children}</Activity> ··· 33 41 onPublish?: () => void; 34 42 publishError?: string[] | null; 35 43 isPublishing?: boolean; 44 + initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>; 36 45 }; 37 46 38 47 export function TrailWalk({ ··· 42 51 onPublish, 43 52 publishError, 44 53 isPublishing, 54 + initialEmbeds, 45 55 }: Props) { 46 56 const { header, stops, yourWalk } = trail; 57 + const initialEmbedsMap = new Map(initialEmbeds); 47 58 const router = useRouter(); 48 59 const requireAuth = useAuthAction(); 49 60 ··· 254 265 255 266 const isReorderActive = isEditMode && isCurrent && !isHoveringStopContent; 256 267 268 + const embedUri = stop.external?.uri; 269 + const initialEmbed = embedUri 270 + ? initialEmbedsMap.get(embedUri) 271 + ? ([embedUri, initialEmbedsMap.get(embedUri)!] as [ 272 + string, 273 + Promise<React.ReactElement | null>, 274 + ]) 275 + : undefined 276 + : undefined; 277 + 257 278 return ( 258 - <RevealedStop key={stop.tid} revealed={isCompleted || isEditMode || !isUnreached}> 279 + <RevealedStop 280 + key={stop.tid} 281 + revealed={isCompleted || isEditMode || !isUnreached} 282 + initialEmbed={initialEmbed} 283 + > 259 284 <div 260 285 onPointerEnter={() => { 261 286 setIsHoveringStopContent(true);
+1 -1
app/at/(trail)/[handle]/trail/[rkey]/embeds/BlueskyPostEmbed.tsx
··· 7 7 type EmbedPlayerParams, 8 8 } from "../utils/embed-player"; 9 9 import { BlueskyRichText } from "./BlueskyRichText"; 10 - import type { BlueskyPost } from "../utils/embed-resolver"; 10 + import type { BlueskyPost } from "../utils/bluesky"; 11 11 import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs"; 12 12 import * as feedPost from "@/lib/lexicons/app/bsky/feed/post"; 13 13 import * as embedImages from "@/lib/lexicons/app/bsky/embed/images";
+7 -5
app/at/(trail)/[handle]/trail/[rkey]/page.tsx
··· 1 1 import { Metadata } from "next"; 2 2 import { loadTrailDetail, loadCurrentUser } from "@/data/queries"; 3 3 import { TrailView } from "./TrailView"; 4 - 5 - // Work around "Could not find the module "..." in the React Client Manifest." 6 - // They're used by embeds but seems like these are being incorrectly treeshaken somewhere. 7 - export { BlueskyPostEmbed } from "./embeds/BlueskyPostEmbed"; 8 - export { LinkPreview } from "./LinkPreview"; 4 + import { loadEmbed } from "@/app/loadEmbed"; 9 5 10 6 type Props = { 11 7 params: Promise<{ ··· 43 39 loadCurrentUser(), 44 40 ]); 45 41 42 + // Preload embeds for all stops that have external links 43 + const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = trail.stops 44 + .filter((stop) => stop.external?.uri) 45 + .map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]); 46 + 46 47 return ( 47 48 <TrailView 48 49 trail={trail} 49 50 cleanHandle={handle} 50 51 rkey={rkey} 51 52 currentUserDid={currentUser?.did || null} 53 + initialEmbeds={initialEmbeds} 52 54 /> 53 55 ); 54 56 }
+16
app/at/(trail)/[handle]/trail/[rkey]/utils/bluesky.ts
··· 1 + import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs"; 2 + 3 + export type BlueskyPost = feedDefs.ThreadViewPost; 4 + 5 + export function blueskyUrlToAtUri(url: string): string | null { 6 + try { 7 + const urlObj = new URL(url); 8 + const pathParts = urlObj.pathname.split("/").filter(Boolean); 9 + if (pathParts[0] === "profile" && pathParts[2] === "post" && pathParts.length >= 4) { 10 + return `at://${pathParts[1]}/app.bsky.feed.post/${pathParts[3]}`; 11 + } 12 + return null; 13 + } catch { 14 + return null; 15 + } 16 + }
-71
app/at/(trail)/[handle]/trail/[rkey]/utils/embed-resolver.ts
··· 1 - import { Client, type l } from "@atproto/lex"; 2 - import * as getPostThread from "@/lib/lexicons/app/bsky/feed/getPostThread"; 3 - import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs"; 4 - 5 - const atprotoClient = new Client("https://public.api.bsky.app"); 6 - 7 - export type BlueskyPost = feedDefs.ThreadViewPost; 8 - 9 - export type LinkMetadata = { 10 - uri: string; 11 - title: string; 12 - description: string; 13 - thumb?: string; 14 - }; 15 - 16 - export function blueskyUrlToAtUri(url: string): string | null { 17 - try { 18 - const urlObj = new URL(url); 19 - const pathParts = urlObj.pathname.split("/").filter(Boolean); 20 - if (pathParts[0] === "profile" && pathParts[2] === "post" && pathParts.length >= 4) { 21 - return `at://${pathParts[1]}/app.bsky.feed.post/${pathParts[3]}`; 22 - } 23 - return null; 24 - } catch { 25 - return null; 26 - } 27 - } 28 - 29 - export async function getBlueskyPost(uri: string): Promise<BlueskyPost | null> { 30 - const atUri = uri.startsWith("at://") 31 - ? uri 32 - : uri.startsWith("http") 33 - ? blueskyUrlToAtUri(uri) 34 - : null; 35 - if (!atUri) return null; 36 - 37 - try { 38 - const result = await atprotoClient.call(getPostThread.main, { 39 - uri: atUri as l.AtUri, 40 - depth: 0, 41 - parentHeight: 0, 42 - }); 43 - if (feedDefs.threadViewPost.$check(result.thread)) { 44 - return result.thread; 45 - } 46 - return null; 47 - } catch (error) { 48 - console.error("Failed to fetch Bluesky post:", error); 49 - return null; 50 - } 51 - } 52 - 53 - export async function getLinkMetadata(url: string): Promise<LinkMetadata | null> { 54 - try { 55 - const response = await fetch( 56 - `https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`, 57 - ); 58 - if (!response.ok) return null; 59 - 60 - const data = await response.json(); 61 - return { 62 - uri: url, 63 - title: data.title || url, 64 - description: data.description || "", 65 - thumb: data.image || undefined, 66 - }; 67 - } catch (error) { 68 - console.error("Failed to fetch link metadata:", error); 69 - return null; 70 - } 71 - }
+11 -2
app/drafts/[rkey]/DraftEditor.tsx
··· 38 38 type Props = { 39 39 rkey: string; 40 40 initialDraft: DraftDetail; 41 + initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>; 41 42 }; 42 43 43 - export function DraftEditor({ rkey, initialDraft }: Props) { 44 + export function DraftEditor({ rkey, initialDraft, initialEmbeds }: Props) { 44 45 const { hasConflict, canSave } = useDraftLock(rkey); 45 46 return ( 46 47 <DraftEditorContent ··· 48 49 initialDraft={initialDraft} 49 50 hasTabConflict={hasConflict} 50 51 canSave={canSave} 52 + initialEmbeds={initialEmbeds} 51 53 /> 52 54 ); 53 55 } 54 56 55 57 type ContentProps = Props & { hasTabConflict: boolean; canSave: boolean }; 56 58 57 - function DraftEditorContent({ rkey, initialDraft, hasTabConflict, canSave }: ContentProps) { 59 + function DraftEditorContent({ 60 + rkey, 61 + initialDraft, 62 + hasTabConflict, 63 + canSave, 64 + initialEmbeds, 65 + }: ContentProps) { 58 66 const router = useRouter(); 59 67 const requireAuth = useAuthAction(); 60 68 ··· 324 332 onPublish={handlePublish} 325 333 publishError={publishError} 326 334 isPublishing={isPublishing} 335 + initialEmbeds={initialEmbeds} 327 336 /> 328 337 )} 329 338 </div>
+7 -1
app/drafts/[rkey]/page.tsx
··· 1 1 import { redirect, notFound } from "next/navigation"; 2 2 import { loadCurrentDid } from "@/data/queries"; 3 3 import { loadDraftDetail } from "@/data/drafts/queries"; 4 + import { loadEmbed } from "@/app/loadEmbed"; 4 5 import { DraftEditor } from "./DraftEditor"; 5 6 6 7 export default async function DraftPage({ params }: { params: Promise<{ rkey: string }> }) { ··· 16 17 notFound(); 17 18 } 18 19 19 - return <DraftEditor rkey={rkey} initialDraft={draft} />; 20 + // Preload embeds for all stops that have external links 21 + const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = draft.stops 22 + .filter((stop) => stop.external?.uri) 23 + .map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]); 24 + 25 + return <DraftEditor rkey={rkey} initialDraft={draft} initialEmbeds={initialEmbeds} />; 20 26 }
+106
app/loadEmbed.tsx
··· 1 + "use server"; 2 + 3 + import "server-only"; 4 + import { Client, type l } from "@atproto/lex"; 5 + import * as getPostThread from "@/lib/lexicons/app/bsky/feed/getPostThread"; 6 + import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs"; 7 + import { loadTrailCardByUri } from "@/data/queries"; 8 + import { TrailCard } from "./TrailCard"; 9 + import { BlueskyPostEmbed } from "./at/(trail)/[handle]/trail/[rkey]/embeds/BlueskyPostEmbed"; 10 + import { LinkPreview } from "./at/(trail)/[handle]/trail/[rkey]/LinkPreview"; 11 + import { blueskyUrlToAtUri } from "./at/(trail)/[handle]/trail/[rkey]/utils/bluesky"; 12 + 13 + type BlueskyPost = feedDefs.ThreadViewPost; 14 + 15 + type LinkMetadata = { 16 + uri: string; 17 + title: string; 18 + description: string; 19 + thumb?: string; 20 + }; 21 + 22 + function isTrailUri(uri: string): boolean { 23 + return uri.includes("/app.sidetrail.trail/"); 24 + } 25 + 26 + function isBlueskyPostUri(uri: string): boolean { 27 + return ( 28 + uri.includes("/app.bsky.feed.post/") || (uri.includes("bsky.app") && uri.includes("/post/")) 29 + ); 30 + } 31 + 32 + async function getBlueskyPost(uri: string): Promise<BlueskyPost | null> { 33 + const atUri = uri.startsWith("at://") 34 + ? uri 35 + : uri.startsWith("http") 36 + ? blueskyUrlToAtUri(uri) 37 + : null; 38 + if (!atUri) return null; 39 + 40 + try { 41 + const atprotoClient = new Client("https://public.api.bsky.app"); 42 + const result = await atprotoClient.call(getPostThread.main, { 43 + uri: atUri as l.AtUri, 44 + depth: 0, 45 + parentHeight: 0, 46 + }); 47 + if (feedDefs.threadViewPost.$check(result.thread)) { 48 + return result.thread; 49 + } 50 + return null; 51 + } catch (error) { 52 + console.error("Failed to fetch Bluesky post:", error); 53 + return null; 54 + } 55 + } 56 + 57 + async function getLinkMetadata(url: string): Promise<LinkMetadata | null> { 58 + try { 59 + const response = await fetch( 60 + `https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`, 61 + ); 62 + if (!response.ok) return null; 63 + 64 + const data = await response.json(); 65 + return { 66 + uri: url, 67 + title: data.title || url, 68 + description: data.description || "", 69 + thumb: data.image || undefined, 70 + }; 71 + } catch (error) { 72 + console.error("Failed to fetch link metadata:", error); 73 + return null; 74 + } 75 + } 76 + 77 + export async function loadEmbed(uri: string): Promise<React.ReactElement | null> { 78 + if (isTrailUri(uri)) { 79 + const trail = await loadTrailCardByUri(uri); 80 + if (!trail) return null; 81 + return <TrailCard {...trail} />; 82 + } 83 + 84 + if (isBlueskyPostUri(uri)) { 85 + const post = await getBlueskyPost(uri); 86 + if (!post) return null; 87 + // Serialize to strip non-plain objects (like CID) 88 + const plainPost = JSON.parse(JSON.stringify(post)) as typeof post; 89 + return <BlueskyPostEmbed post={plainPost} />; 90 + } 91 + 92 + // Regular link 93 + const metadata = await getLinkMetadata(uri); 94 + if (!metadata) return null; 95 + return ( 96 + <LinkPreview 97 + external={{ 98 + uri: metadata.uri, 99 + title: metadata.title, 100 + description: metadata.description, 101 + thumb: metadata.thumb, 102 + }} 103 + fallbackToUrl 104 + /> 105 + ); 106 + }
-10
app/loadTrailCard.tsx
··· 1 - "use server"; 2 - 3 - import { loadTrailCardByUri } from "../data/queries"; 4 - import { TrailCard } from "./TrailCard"; 5 - 6 - export async function loadTrailCard(uri: string): Promise<React.ReactElement | null> { 7 - const trail = await loadTrailCardByUri(uri); 8 - if (!trail) return null; 9 - return <TrailCard {...trail} />; 10 - }