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

trail embeds

+169 -68
+38 -7
app/at/(trail)/[handle]/trail/[rkey]/StopEmbed.tsx
··· 4 4 import type { ExternalEmbed } from "@/data/queries"; 5 5 import { LinkPreview } from "./LinkPreview"; 6 6 import { BlueskyPostEmbed } from "./embeds/BlueskyPostEmbed"; 7 + import { loadTrailCard } from "@/app/loadTrailCard"; 7 8 import { 8 9 getBlueskyPost, 9 10 getLinkMetadata, 10 11 type BlueskyPost, 11 12 type LinkMetadata, 12 13 } from "./utils/embed-resolver"; 14 + import "./embeds/TrailEmbed.css"; 15 + import "@/app/TrailCard.css"; 13 16 14 - type EmbedPromise = Promise<BlueskyPost | LinkMetadata | null>; 17 + type EmbedPromise = Promise<BlueskyPost | LinkMetadata | React.ReactElement | null>; 15 18 export type EmbedCache = Map<string, EmbedPromise>; 16 19 17 20 type Props = { ··· 20 23 onDelete?: () => void; 21 24 }; 22 25 26 + function isTrailUri(uri: string): boolean { 27 + return uri.includes("/app.sidetrail.trail/"); 28 + } 29 + 30 + function isBlueskyPostUri(uri: string): boolean { 31 + return ( 32 + uri.includes("/app.bsky.feed.post/") || (uri.includes("bsky.app") && uri.includes("/post/")) 33 + ); 34 + } 35 + 23 36 function getOrCreatePromise(uri: string, cache: EmbedCache): EmbedPromise { 24 37 const existing = cache.get(uri); 25 38 if (existing) return existing; 26 39 27 - const promise = 28 - uri.startsWith("at://") || (uri.includes("bsky.app") && uri.includes("/post/")) 29 - ? getBlueskyPost(uri) 30 - : getLinkMetadata(uri); 40 + let promise: EmbedPromise; 41 + if (isTrailUri(uri)) { 42 + promise = Promise.resolve().then(() => loadTrailCard(uri)); 43 + } else if (isBlueskyPostUri(uri)) { 44 + promise = getBlueskyPost(uri); 45 + } else { 46 + promise = getLinkMetadata(uri); 47 + } 31 48 32 49 cache.set(uri, promise); 33 50 return promise; ··· 39 56 } 40 57 41 58 const uri = external.uri; 42 - const isBluesky = uri.startsWith("at://"); 59 + const isTrail = isTrailUri(uri); 60 + const isBluesky = isBlueskyPostUri(uri); 43 61 const promise = getOrCreatePromise(uri, cache); 44 62 const data = use(promise); 45 63 ··· 58 76 ); 59 77 } 60 78 79 + if (isTrail) { 80 + if (onDelete) { 81 + return ( 82 + <div className="TrailEmbed-container"> 83 + {data as React.ReactElement} 84 + <button onClick={onDelete} className="TrailEmbed-deleteButton" title="remove link"> 85 + × 86 + </button> 87 + </div> 88 + ); 89 + } 90 + return data as React.ReactElement; 91 + } 92 + 61 93 if (isBluesky) { 62 94 return <BlueskyPostEmbed post={data as BlueskyPost} onDelete={onDelete} />; 63 95 } 64 96 65 - // For links, merge fetched metadata with external data (external may have more recent edits) 66 97 const metadata = data as LinkMetadata; 67 98 const mergedExternal = { 68 99 ...external,
+20 -16
app/at/(trail)/[handle]/trail/[rkey]/TrailStopCard.tsx
··· 1 1 "use client"; 2 2 3 - import { Suspense, useState } from "react"; 3 + import { Suspense, useState, useCallback } from "react"; 4 4 import { useEditMode } from "./EditModeContext"; 5 5 import type { TrailStop } from "@/data/queries"; 6 6 import { StopEmbed, type EmbedCache } from "./StopEmbed"; ··· 32 32 const error = editContext?.inlineErrors[stop.tid]; 33 33 const [embedCache, setEmbedCache] = useState<EmbedCache>(() => new Map()); 34 34 35 + const textareaRef = useCallback((el: HTMLTextAreaElement | null) => { 36 + if (el) { 37 + el.style.height = "auto"; 38 + el.style.height = el.scrollHeight + "px"; 39 + } 40 + }, []); 41 + 35 42 const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 36 43 if (updateStop) updateStop(stop.tid, { title: e.target.value }); 37 44 }; ··· 48 55 const extractedLink = extractLink(pastedText); 49 56 if (!extractedLink || !updateStop) return; 50 57 51 - // Bluesky links - prevent paste, set external 52 - if (extractedLink.type === "bluesky") { 58 + if (extractedLink.type === "app.sidetrail.trail") { 59 + e.preventDefault(); 60 + updateStop(stop.tid, { external: { uri: extractedLink.uri } }); 61 + return; 62 + } 63 + 64 + if (extractedLink.type === "app.bsky.feed.post") { 53 65 e.preventDefault(); 54 66 let linkUrl = extractedLink.uri; 55 67 if (linkUrl.startsWith("http")) { ··· 62 74 return; 63 75 } 64 76 65 - // HTTP links - always set external 66 - const linkUrl = extractedLink.url; 77 + const linkUrl = extractedLink.uri; 67 78 68 - // For embeddable links, prevent text paste 69 79 const embedDomains = [ 70 80 "youtube.com", 71 81 "youtu.be", ··· 96 106 97 107 const handleRemoveLink = () => { 98 108 if (!updateStop) return; 99 - 100 109 setEmbedCache(new Map()); 101 110 102 - // Check if there's a link in the content to remove 103 111 const extractedLink = extractLink(stop.content); 104 - if (extractedLink) { 105 - const linkText = extractedLink.type === "bluesky" ? extractedLink.uri : extractedLink.url; 106 - const newContent = stop.content.replace(linkText, "").trim(); 112 + if (extractedLink?.type === "http") { 107 113 updateStop(stop.tid, { 108 - content: newContent, 114 + content: stop.content.replace(extractedLink.uri, "").trim(), 109 115 external: undefined, 110 116 }); 111 117 } else { 112 - // Link text already removed, just clear the external field 113 - updateStop(stop.tid, { 114 - external: undefined, 115 - }); 118 + updateStop(stop.tid, { external: undefined }); 116 119 } 117 120 }; 118 121 ··· 148 151 <div className="TrailStopCard-content"> 149 152 {isEditing ? ( 150 153 <textarea 154 + ref={textareaRef} 151 155 value={stop.content} 152 156 onChange={handleContentChange} 153 157 onPaste={handlePaste}
+34
app/at/(trail)/[handle]/trail/[rkey]/embeds/TrailEmbed.css
··· 1 + .TrailEmbed-container { 2 + position: relative; 3 + margin-top: 12px; 4 + } 5 + 6 + .TrailEmbed-container .TrailCard { 7 + pointer-events: none; 8 + } 9 + 10 + .TrailEmbed-deleteButton { 11 + position: absolute; 12 + top: 0.5rem; 13 + right: 0.5rem; 14 + width: 28px; 15 + height: 28px; 16 + background: rgba(255, 255, 255, 0.9); 17 + border: none; 18 + border-radius: 4px; 19 + color: #ccc; 20 + font-size: 1.25rem; 21 + line-height: 1; 22 + cursor: pointer; 23 + transition: all 0.2s; 24 + z-index: 10; 25 + display: flex; 26 + align-items: center; 27 + justify-content: center; 28 + padding: 0; 29 + } 30 + 31 + .TrailEmbed-deleteButton:hover { 32 + color: #888; 33 + background: rgba(0, 0, 0, 0.05); 34 + }
+30 -45
app/at/(trail)/[handle]/trail/[rkey]/utils/linkExtraction.ts
··· 1 - // Link extraction utilities for trail stop content 2 - 3 - export type ExtractedLink = { type: "bluesky"; uri: string } | { type: "http"; url: string } | null; 1 + export type ExtractedLink = { 2 + type: "app.bsky.feed.post" | "app.sidetrail.trail" | "http"; 3 + uri: string; 4 + } | null; 4 5 5 - /** 6 - * Extracts the first link from text content. 7 - * Supports: 8 - * - at:// URIs (Bluesky/ATProto) 9 - * - http:// and https:// URLs (including bsky.app URLs) 10 - * 11 - * Returns the first link found, prioritizing at:// URIs. 12 - */ 13 6 export function extractLink(content: string): ExtractedLink { 14 7 if (!content) return null; 15 8 16 - // First, look for at:// URIs 9 + // at:// URIs first 17 10 const atUriMatch = content.match(/at:\/\/[^\s]+/); 18 11 if (atUriMatch) { 19 12 const uri = atUriMatch[0]; 20 - // It's a bluesky post 21 - return { 22 - type: "bluesky", 23 - uri: uri, 24 - }; 13 + if (uri.includes("/app.sidetrail.trail/")) { 14 + return { type: "app.sidetrail.trail", uri }; 15 + } 16 + if (uri.includes("/app.bsky.feed.post/")) { 17 + return { type: "app.bsky.feed.post", uri }; 18 + } 19 + return { type: "http", uri }; 25 20 } 26 21 27 - // Then look for http(s):// URLs 22 + // http(s):// URLs 28 23 const httpMatch = content.match(/https?:\/\/[^\s]+/); 29 24 if (httpMatch) { 30 - const url = httpMatch[0]; 25 + const uri = httpMatch[0]; 26 + 27 + const trailAtUri = parseTrailUrl(uri); 28 + if (trailAtUri) return { type: "app.sidetrail.trail", uri: trailAtUri }; 31 29 32 - if (url.includes("bsky.app") && url.includes("/post/")) { 33 - return { 34 - type: "bluesky", 35 - uri: url, 36 - }; 30 + if (uri.includes("bsky.app") && uri.includes("/post/")) { 31 + return { type: "app.bsky.feed.post", uri }; 37 32 } 38 33 39 - return { 40 - type: "http", 41 - url: url, 42 - }; 34 + return { type: "http", uri }; 43 35 } 44 36 45 37 return null; 46 38 } 47 39 48 - /** 49 - * Removes the extracted link from the text content. 50 - * Useful for displaying text without the raw link. 51 - */ 52 - export function removeLink(content: string, link: ExtractedLink): string { 53 - if (!link) return content; 54 - 55 - let linkText = ""; 56 - if (link.type === "bluesky") { 57 - linkText = link.uri; 58 - } else { 59 - linkText = link.url; 40 + function parseTrailUrl(url: string): string | null { 41 + if ( 42 + !url.includes("sidetrail.app") && 43 + !url.includes("127.0.0.1:3000") && 44 + !url.includes("localhost:3000") 45 + ) { 46 + return null; 60 47 } 61 - 62 - return content.replace(linkText, "").trim(); 48 + const match = url.match(/@([\w.-]+)\/trail\/([\w]+)/); 49 + if (!match) return null; 50 + return `at://${match[1]}/app.sidetrail.trail/${match[2]}`; 63 51 } 64 52 65 - /** 66 - * Checks if content contains a link 67 - */ 68 53 export function hasLink(content: string): boolean { 69 54 return extractLink(content) !== null; 70 55 }
+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 + }
+35
data/queries.ts
··· 5 5 import { getDb, trails, walks, completions, accounts, type TrailRecord } from "@/data/db"; 6 6 import { desc, eq, inArray, sql } from "drizzle-orm"; 7 7 import { IdResolver } from "@atproto/identity"; 8 + import { AtUri } from "@atproto/syntax"; 8 9 import { tagDid, tagHandle, tagAvatar } from "@/cache/tags"; 9 10 import { getCurrentDid } from "@/auth"; 10 11 ··· 291 292 createdAt: row.created_at, 292 293 }; 293 294 }); 295 + }); 296 + 297 + export const loadTrailCardByUri = cache(async function loadTrailCardByUri( 298 + uri: string, 299 + ): Promise<TrailCardData | null> { 300 + const db = getDb(); 301 + const parsed = new AtUri(uri); 302 + const did = parsed.host.startsWith("did:") ? parsed.host : await resolveHandleToDid(parsed.host); 303 + 304 + const trailRows = await db 305 + .select() 306 + .from(trails) 307 + .where(eq(trails.uri, `at://${did}/${parsed.collection}/${parsed.rkey}`)) 308 + .limit(1); 309 + 310 + if (trailRows.length === 0) return null; 311 + 312 + const row = trailRows[0]; 313 + const userMap = await batchResolveUsers([row.authorDid]); 314 + const creator = userMap.get(row.authorDid); 315 + if (!creator) return null; 316 + 317 + return { 318 + uri: row.uri, 319 + rkey: row.rkey, 320 + creatorHandle: creator.handle, 321 + title: row.record.title, 322 + description: row.record.description, 323 + accentColor: row.record.accentColor, 324 + backgroundColor: row.record.backgroundColor, 325 + creator, 326 + stopsCount: row.record.stops.length, 327 + createdAt: row.createdAt, 328 + }; 294 329 }); 295 330 296 331 export const loadTrailActiveWalkers = cache(async function loadTrailActiveWalkers(
+1
package-lock.json
··· 18 18 "@atproto/identity": "^0.4.9", 19 19 "@atproto/lex": "^0.0.4", 20 20 "@atproto/oauth-client-node": "^0.3.11", 21 + "@atproto/syntax": "^0.4.2", 21 22 "@opentelemetry/api": "^1.9.0", 22 23 "@opentelemetry/instrumentation-pg": "^0.61.1", 23 24 "@radix-ui/react-dialog": "^1.1.15",
+1
package.json
··· 35 35 "@atproto/identity": "^0.4.9", 36 36 "@atproto/lex": "^0.0.4", 37 37 "@atproto/oauth-client-node": "^0.3.11", 38 + "@atproto/syntax": "^0.4.2", 38 39 "@opentelemetry/api": "^1.9.0", 39 40 "@opentelemetry/instrumentation-pg": "^0.61.1", 40 41 "@radix-ui/react-dialog": "^1.1.15",