an app to share curated trails sidetrail.app
1

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 - }