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 import type { ExternalEmbed } from "@/data/queries"; 5 import { LinkPreview } from "./LinkPreview"; 6 import { BlueskyPostEmbed } from "./embeds/BlueskyPostEmbed"; 7 import { 8 getBlueskyPost, 9 getLinkMetadata, 10 type BlueskyPost, 11 type LinkMetadata, 12 } from "./utils/embed-resolver"; 13 14 - type EmbedPromise = Promise<BlueskyPost | LinkMetadata | null>; 15 export type EmbedCache = Map<string, EmbedPromise>; 16 17 type Props = { ··· 20 onDelete?: () => void; 21 }; 22 23 function getOrCreatePromise(uri: string, cache: EmbedCache): EmbedPromise { 24 const existing = cache.get(uri); 25 if (existing) return existing; 26 27 - const promise = 28 - uri.startsWith("at://") || (uri.includes("bsky.app") && uri.includes("/post/")) 29 - ? getBlueskyPost(uri) 30 - : getLinkMetadata(uri); 31 32 cache.set(uri, promise); 33 return promise; ··· 39 } 40 41 const uri = external.uri; 42 - const isBluesky = uri.startsWith("at://"); 43 const promise = getOrCreatePromise(uri, cache); 44 const data = use(promise); 45 ··· 58 ); 59 } 60 61 if (isBluesky) { 62 return <BlueskyPostEmbed post={data as BlueskyPost} onDelete={onDelete} />; 63 } 64 65 - // For links, merge fetched metadata with external data (external may have more recent edits) 66 const metadata = data as LinkMetadata; 67 const mergedExternal = { 68 ...external,
··· 4 import type { ExternalEmbed } from "@/data/queries"; 5 import { LinkPreview } from "./LinkPreview"; 6 import { BlueskyPostEmbed } from "./embeds/BlueskyPostEmbed"; 7 + import { loadTrailCard } from "@/app/loadTrailCard"; 8 import { 9 getBlueskyPost, 10 getLinkMetadata, 11 type BlueskyPost, 12 type LinkMetadata, 13 } from "./utils/embed-resolver"; 14 + import "./embeds/TrailEmbed.css"; 15 + import "@/app/TrailCard.css"; 16 17 + type EmbedPromise = Promise<BlueskyPost | LinkMetadata | React.ReactElement | null>; 18 export type EmbedCache = Map<string, EmbedPromise>; 19 20 type Props = { ··· 23 onDelete?: () => void; 24 }; 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 + 36 function getOrCreatePromise(uri: string, cache: EmbedCache): EmbedPromise { 37 const existing = cache.get(uri); 38 if (existing) return existing; 39 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 + } 48 49 cache.set(uri, promise); 50 return promise; ··· 56 } 57 58 const uri = external.uri; 59 + const isTrail = isTrailUri(uri); 60 + const isBluesky = isBlueskyPostUri(uri); 61 const promise = getOrCreatePromise(uri, cache); 62 const data = use(promise); 63 ··· 76 ); 77 } 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 + 93 if (isBluesky) { 94 return <BlueskyPostEmbed post={data as BlueskyPost} onDelete={onDelete} />; 95 } 96 97 const metadata = data as LinkMetadata; 98 const mergedExternal = { 99 ...external,
+20 -16
app/at/(trail)/[handle]/trail/[rkey]/TrailStopCard.tsx
··· 1 "use client"; 2 3 - import { Suspense, useState } from "react"; 4 import { useEditMode } from "./EditModeContext"; 5 import type { TrailStop } from "@/data/queries"; 6 import { StopEmbed, type EmbedCache } from "./StopEmbed"; ··· 32 const error = editContext?.inlineErrors[stop.tid]; 33 const [embedCache, setEmbedCache] = useState<EmbedCache>(() => new Map()); 34 35 const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 36 if (updateStop) updateStop(stop.tid, { title: e.target.value }); 37 }; ··· 48 const extractedLink = extractLink(pastedText); 49 if (!extractedLink || !updateStop) return; 50 51 - // Bluesky links - prevent paste, set external 52 - if (extractedLink.type === "bluesky") { 53 e.preventDefault(); 54 let linkUrl = extractedLink.uri; 55 if (linkUrl.startsWith("http")) { ··· 62 return; 63 } 64 65 - // HTTP links - always set external 66 - const linkUrl = extractedLink.url; 67 68 - // For embeddable links, prevent text paste 69 const embedDomains = [ 70 "youtube.com", 71 "youtu.be", ··· 96 97 const handleRemoveLink = () => { 98 if (!updateStop) return; 99 - 100 setEmbedCache(new Map()); 101 102 - // Check if there's a link in the content to remove 103 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(); 107 updateStop(stop.tid, { 108 - content: newContent, 109 external: undefined, 110 }); 111 } else { 112 - // Link text already removed, just clear the external field 113 - updateStop(stop.tid, { 114 - external: undefined, 115 - }); 116 } 117 }; 118 ··· 148 <div className="TrailStopCard-content"> 149 {isEditing ? ( 150 <textarea 151 value={stop.content} 152 onChange={handleContentChange} 153 onPaste={handlePaste}
··· 1 "use client"; 2 3 + import { Suspense, useState, useCallback } from "react"; 4 import { useEditMode } from "./EditModeContext"; 5 import type { TrailStop } from "@/data/queries"; 6 import { StopEmbed, type EmbedCache } from "./StopEmbed"; ··· 32 const error = editContext?.inlineErrors[stop.tid]; 33 const [embedCache, setEmbedCache] = useState<EmbedCache>(() => new Map()); 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 + 42 const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => { 43 if (updateStop) updateStop(stop.tid, { title: e.target.value }); 44 }; ··· 55 const extractedLink = extractLink(pastedText); 56 if (!extractedLink || !updateStop) return; 57 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") { 65 e.preventDefault(); 66 let linkUrl = extractedLink.uri; 67 if (linkUrl.startsWith("http")) { ··· 74 return; 75 } 76 77 + const linkUrl = extractedLink.uri; 78 79 const embedDomains = [ 80 "youtube.com", 81 "youtu.be", ··· 106 107 const handleRemoveLink = () => { 108 if (!updateStop) return; 109 setEmbedCache(new Map()); 110 111 const extractedLink = extractLink(stop.content); 112 + if (extractedLink?.type === "http") { 113 updateStop(stop.tid, { 114 + content: stop.content.replace(extractedLink.uri, "").trim(), 115 external: undefined, 116 }); 117 } else { 118 + updateStop(stop.tid, { external: undefined }); 119 } 120 }; 121 ··· 151 <div className="TrailStopCard-content"> 152 {isEditing ? ( 153 <textarea 154 + ref={textareaRef} 155 value={stop.content} 156 onChange={handleContentChange} 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; 4 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 export function extractLink(content: string): ExtractedLink { 14 if (!content) return null; 15 16 - // First, look for at:// URIs 17 const atUriMatch = content.match(/at:\/\/[^\s]+/); 18 if (atUriMatch) { 19 const uri = atUriMatch[0]; 20 - // It's a bluesky post 21 - return { 22 - type: "bluesky", 23 - uri: uri, 24 - }; 25 } 26 27 - // Then look for http(s):// URLs 28 const httpMatch = content.match(/https?:\/\/[^\s]+/); 29 if (httpMatch) { 30 - const url = httpMatch[0]; 31 32 - if (url.includes("bsky.app") && url.includes("/post/")) { 33 - return { 34 - type: "bluesky", 35 - uri: url, 36 - }; 37 } 38 39 - return { 40 - type: "http", 41 - url: url, 42 - }; 43 } 44 45 return null; 46 } 47 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; 60 } 61 - 62 - return content.replace(linkText, "").trim(); 63 } 64 65 - /** 66 - * Checks if content contains a link 67 - */ 68 export function hasLink(content: string): boolean { 69 return extractLink(content) !== null; 70 }
··· 1 + export type ExtractedLink = { 2 + type: "app.bsky.feed.post" | "app.sidetrail.trail" | "http"; 3 + uri: string; 4 + } | null; 5 6 export function extractLink(content: string): ExtractedLink { 7 if (!content) return null; 8 9 + // at:// URIs first 10 const atUriMatch = content.match(/at:\/\/[^\s]+/); 11 if (atUriMatch) { 12 const uri = atUriMatch[0]; 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 }; 20 } 21 22 + // http(s):// URLs 23 const httpMatch = content.match(/https?:\/\/[^\s]+/); 24 if (httpMatch) { 25 + const uri = httpMatch[0]; 26 + 27 + const trailAtUri = parseTrailUrl(uri); 28 + if (trailAtUri) return { type: "app.sidetrail.trail", uri: trailAtUri }; 29 30 + if (uri.includes("bsky.app") && uri.includes("/post/")) { 31 + return { type: "app.bsky.feed.post", uri }; 32 } 33 34 + return { type: "http", uri }; 35 } 36 37 return null; 38 } 39 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; 47 } 48 + const match = url.match(/@([\w.-]+)\/trail\/([\w]+)/); 49 + if (!match) return null; 50 + return `at://${match[1]}/app.sidetrail.trail/${match[2]}`; 51 } 52 53 export function hasLink(content: string): boolean { 54 return extractLink(content) !== null; 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 import { getDb, trails, walks, completions, accounts, type TrailRecord } from "@/data/db"; 6 import { desc, eq, inArray, sql } from "drizzle-orm"; 7 import { IdResolver } from "@atproto/identity"; 8 import { tagDid, tagHandle, tagAvatar } from "@/cache/tags"; 9 import { getCurrentDid } from "@/auth"; 10 ··· 291 createdAt: row.created_at, 292 }; 293 }); 294 }); 295 296 export const loadTrailActiveWalkers = cache(async function loadTrailActiveWalkers(
··· 5 import { getDb, trails, walks, completions, accounts, type TrailRecord } from "@/data/db"; 6 import { desc, eq, inArray, sql } from "drizzle-orm"; 7 import { IdResolver } from "@atproto/identity"; 8 + import { AtUri } from "@atproto/syntax"; 9 import { tagDid, tagHandle, tagAvatar } from "@/cache/tags"; 10 import { getCurrentDid } from "@/auth"; 11 ··· 292 createdAt: row.created_at, 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 + }; 329 }); 330 331 export const loadTrailActiveWalkers = cache(async function loadTrailActiveWalkers(
+1
package-lock.json
··· 18 "@atproto/identity": "^0.4.9", 19 "@atproto/lex": "^0.0.4", 20 "@atproto/oauth-client-node": "^0.3.11", 21 "@opentelemetry/api": "^1.9.0", 22 "@opentelemetry/instrumentation-pg": "^0.61.1", 23 "@radix-ui/react-dialog": "^1.1.15",
··· 18 "@atproto/identity": "^0.4.9", 19 "@atproto/lex": "^0.0.4", 20 "@atproto/oauth-client-node": "^0.3.11", 21 + "@atproto/syntax": "^0.4.2", 22 "@opentelemetry/api": "^1.9.0", 23 "@opentelemetry/instrumentation-pg": "^0.61.1", 24 "@radix-ui/react-dialog": "^1.1.15",
+1
package.json
··· 35 "@atproto/identity": "^0.4.9", 36 "@atproto/lex": "^0.0.4", 37 "@atproto/oauth-client-node": "^0.3.11", 38 "@opentelemetry/api": "^1.9.0", 39 "@opentelemetry/instrumentation-pg": "^0.61.1", 40 "@radix-ui/react-dialog": "^1.1.15",
··· 35 "@atproto/identity": "^0.4.9", 36 "@atproto/lex": "^0.0.4", 37 "@atproto/oauth-client-node": "^0.3.11", 38 + "@atproto/syntax": "^0.4.2", 39 "@opentelemetry/api": "^1.9.0", 40 "@opentelemetry/instrumentation-pg": "^0.61.1", 41 "@radix-ui/react-dialog": "^1.1.15",