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.

better embeds

+147 -97
+5 -6
app/at/(trail)/[handle]/trail/[rkey]/LinkPreview.css
··· 21 21 .LinkPreview::before { 22 22 content: ""; 23 23 position: absolute; 24 - inset: 0; 25 - border: 1px solid transparent; 26 - border-radius: 8px; 24 + inset: -1px; 25 + border: 2px solid transparent; 26 + border-radius: 9px; 27 27 pointer-events: none; 28 - z-index: -1; 28 + z-index: 1; 29 29 transition: all 0.2s; 30 30 } 31 31 ··· 54 54 55 55 .LinkPreview-thumb { 56 56 width: 100%; 57 - height: auto; 58 - max-height: 400px; 57 + aspect-ratio: 1.91 / 1; 59 58 object-fit: cover; 60 59 display: block; 61 60 }
+1 -16
app/at/(trail)/[handle]/trail/[rkey]/StopEmbed.tsx
··· 5 5 import "./embeds/TrailEmbed.css"; 6 6 import "@/app/TrailCard.css"; 7 7 8 - type EmbedPromise = Promise<React.ReactElement | null>; 8 + type EmbedPromise = Promise<React.ReactElement>; 9 9 export type EmbedCache = Map<string, EmbedPromise>; 10 10 export const EmbedCacheContext = createContext<[EmbedCache | null, (c: EmbedCache) => void]>([ 11 11 null, ··· 37 37 const isTrail = isTrailUri(uri); 38 38 const promise = getPromise(uri, cache); 39 39 const embed = use(promise); 40 - 41 - if (!embed) { 42 - return ( 43 - <div className="BlueskyPostEmbed-container"> 44 - <div className="BlueskyPostEmbed error"> 45 - <div className="BlueskyPostEmbed-error">couldn't load embed</div> 46 - </div> 47 - {onDelete && ( 48 - <button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link"> 49 - × 50 - </button> 51 - )} 52 - </div> 53 - ); 54 - } 55 40 56 41 if (onDelete) { 57 42 const containerClass = isTrail ? "TrailEmbed-container" : "BlueskyPostEmbed-container";
+1 -2
app/at/(trail)/[handle]/trail/[rkey]/TrailStop.css
··· 34 34 opacity: 0.5; 35 35 } 36 36 37 - .TrailStop--visited { 37 + .TrailStop--clickable { 38 38 cursor: pointer; 39 39 } 40 40 ··· 99 99 /* Edit mode */ 100 100 .TrailStop--editing { 101 101 opacity: 1 !important; 102 - cursor: default; 103 102 background: rgba(0, 0, 0, 0.02); 104 103 } 105 104
+6 -3
app/at/(trail)/[handle]/trail/[rkey]/TrailStop.tsx
··· 54 54 : isVisited 55 55 ? "TrailStop--visited" 56 56 : "" 57 - } ${isReorderActive && isEditing ? "TrailStop--reorderActive" : ""} ${isEditing ? "TrailStop--editing" : ""}`} 58 - onClick={() => isClickable && onGoToStop(index)} 57 + } ${isClickable && !isCurrent ? "TrailStop--clickable" : ""} ${isReorderActive && isEditing ? "TrailStop--reorderActive" : ""} ${isEditing ? "TrailStop--editing" : ""}`} 58 + onClick={() => { 59 + if (isClickable && !isCurrent) { 60 + onGoToStop(index); 61 + } 62 + }} 59 63 onKeyDown={(e) => { 60 64 if (isClickable && (e.key === "Enter" || e.key === " ") && e.target === e.currentTarget) { 61 65 e.preventDefault(); ··· 67 71 onGoToStop(index); 68 72 } 69 73 }} 70 - style={{ cursor: isClickable ? "pointer" : "default" }} 71 74 tabIndex={isClickable && !isEditing ? 0 : undefined} 72 75 role={isClickable && !isEditing ? "button" : undefined} 73 76 aria-label={isClickable && !isEditing ? stopLabel : undefined}
+36 -7
app/at/(trail)/[handle]/trail/[rkey]/TrailStopCard.tsx
··· 1 1 "use client"; 2 2 3 3 import { Suspense, use, useCallback } from "react"; 4 + import { ErrorBoundary } from "react-error-boundary"; 4 5 import { useEditMode } from "./EditModeContext"; 5 6 import type { TrailStop } from "@/data/queries"; 6 7 import { EmbedCacheContext, StopEmbed } from "./StopEmbed"; ··· 9 10 import { blueskyUrlToAtUri } from "./utils/bluesky"; 10 11 import "./TrailStopCard.css"; 11 12 12 - function EmbedLoading() { 13 + function EmbedLoading({ onDelete }: { onDelete?: () => void }) { 13 14 return ( 14 15 <div className="BlueskyPostEmbed-container"> 15 16 <div className="BlueskyPostEmbed loading"> 16 17 <div className="BlueskyPostEmbed-loading">loading...</div> 17 18 </div> 19 + {onDelete && ( 20 + <button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link"> 21 + × 22 + </button> 23 + )} 24 + </div> 25 + ); 26 + } 27 + 28 + function EmbedError({ onDelete }: { onDelete?: () => void }) { 29 + return ( 30 + <div className="BlueskyPostEmbed-container"> 31 + <div className="BlueskyPostEmbed error"> 32 + <div className="BlueskyPostEmbed-error">couldn't load embed</div> 33 + </div> 34 + {onDelete && ( 35 + <button onClick={onDelete} className="BlueskyPostEmbed-deleteButton" title="remove link"> 36 + × 37 + </button> 38 + )} 18 39 </div> 19 40 ); 20 41 } ··· 170 191 className="TrailStopCard-embed" 171 192 style={isEditing ? { opacity: 0.9, marginTop: "0.5rem" } : undefined} 172 193 > 173 - <Suspense fallback={<EmbedLoading />}> 174 - <StopEmbed 175 - external={stop.external} 176 - onDelete={isEditing ? handleRemoveLink : undefined} 177 - /> 178 - </Suspense> 194 + <ErrorBoundary 195 + fallbackRender={() => ( 196 + <EmbedError onDelete={isEditing ? handleRemoveLink : undefined} /> 197 + )} 198 + > 199 + <Suspense 200 + fallback={<EmbedLoading onDelete={isEditing ? handleRemoveLink : undefined} />} 201 + > 202 + <StopEmbed 203 + external={stop.external} 204 + onDelete={isEditing ? handleRemoveLink : undefined} 205 + /> 206 + </Suspense> 207 + </ErrorBoundary> 179 208 </div> 180 209 )} 181 210 </div>
+1 -1
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 + initialEmbeds?: Array<[string, Promise<React.ReactElement>]>; 15 15 }; 16 16 17 17 type ViewMode = "overview" | "walk";
+3 -3
app/at/(trail)/[handle]/trail/[rkey]/TrailWalk.tsx
··· 22 22 }: { 23 23 revealed: boolean; 24 24 children: ReactNode; 25 - initialEmbed?: [string, Promise<React.ReactElement | null>]; 25 + initialEmbed?: [string, Promise<React.ReactElement>]; 26 26 }) { 27 27 const cacheAndSetCache = useState<EmbedCache>(() => new Map(initialEmbed ? [initialEmbed] : [])); 28 28 return ( ··· 41 41 onPublish?: () => void; 42 42 publishError?: string[] | null; 43 43 isPublishing?: boolean; 44 - initialEmbeds?: Array<[string, Promise<React.ReactElement | null>]>; 44 + initialEmbeds?: Array<[string, Promise<React.ReactElement>]>; 45 45 }; 46 46 47 47 export function TrailWalk({ ··· 270 270 ? initialEmbedsMap.get(embedUri) 271 271 ? ([embedUri, initialEmbedsMap.get(embedUri)!] as [ 272 272 string, 273 - Promise<React.ReactElement | null>, 273 + Promise<React.ReactElement>, 274 274 ]) 275 275 : undefined 276 276 : undefined;
+21 -7
app/at/(trail)/[handle]/trail/[rkey]/embeds/BlueskyPostEmbed.css
··· 1 1 .BlueskyPostEmbed { 2 - border: 1px solid var(--accent-color, #999); 2 + border: 1px solid var(--border-subtle); 3 3 border-radius: 8px; 4 4 padding: 16px; 5 5 background: rgba(0, 0, 0, 0.02); ··· 11 11 width: 100%; 12 12 position: relative; 13 13 isolation: isolate; 14 - cursor: default; 14 + transition: border-color 0.2s; 15 15 } 16 16 17 17 .BlueskyPostEmbed::before { 18 18 content: ""; 19 19 position: absolute; 20 - inset: 0; 21 - border: 1px solid var(--accent-color, #999); 22 - border-radius: 8px; 23 - filter: var(--user-content-filter); 24 - z-index: -1; 20 + inset: -1px; 21 + border: 2px solid transparent; 22 + border-radius: 9px; 23 + pointer-events: none; 24 + z-index: 1; 25 + transition: all 0.2s; 26 + } 27 + 28 + @media (hover: hover) { 29 + .BlueskyPostEmbed:hover::before { 30 + border-color: var(--accent-color, #999); 31 + filter: var(--user-content-filter); 32 + } 25 33 } 26 34 27 35 @media (prefers-color-scheme: dark) { ··· 36 44 text-align: center; 37 45 color: var(--text-secondary); 38 46 font-size: 14px; 47 + } 48 + 49 + .BlueskyPostEmbed.loading::before, 50 + .BlueskyPostEmbed.error::before { 51 + display: none; 39 52 } 40 53 41 54 .BlueskyPostEmbed-loading { ··· 270 283 /* Delete button for edit mode */ 271 284 .BlueskyPostEmbed-container { 272 285 position: relative; 286 + margin-top: 12px; 273 287 } 274 288 275 289 .BlueskyPostEmbed-deleteButton {
+1 -1
app/at/(trail)/[handle]/trail/[rkey]/page.tsx
··· 40 40 ]); 41 41 42 42 // Preload embeds for all stops that have external links 43 - const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = trail.stops 43 + const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = trail.stops 44 44 .filter((stop) => stop.external?.uri) 45 45 .map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]); 46 46
+3 -3
app/at/(trail)/[handle]/trail/[rkey]/utils/embed-player.ts
··· 66 66 return { 67 67 type: "youtube_video", 68 68 source: "youtube", 69 - playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}&autoplay=1`, 69 + playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}`, 70 70 }; 71 71 } 72 72 } ··· 90 90 type: isShorts ? "youtube_short" : "youtube_video", 91 91 source: isShorts ? "youtubeShorts" : "youtube", 92 92 hideDetails: isShorts ? true : undefined, 93 - playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}&autoplay=1`, 93 + playerUri: `https://www.youtube.com/embed/${videoId}?start=${seek}`, 94 94 }; 95 95 } 96 96 } ··· 238 238 return { 239 239 type: "vimeo_video", 240 240 source: "vimeo", 241 - playerUri: `https://player.vimeo.com/video/${videoId}?autoplay=1`, 241 + playerUri: `https://player.vimeo.com/video/${videoId}`, 242 242 }; 243 243 } 244 244 }
+1 -1
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 + initialEmbeds?: Array<[string, Promise<React.ReactElement>]>; 42 42 }; 43 43 44 44 export function DraftEditor({ rkey, initialDraft, initialEmbeds }: Props) {
+1 -1
app/drafts/[rkey]/page.tsx
··· 18 18 } 19 19 20 20 // Preload embeds for all stops that have external links 21 - const initialEmbeds: Array<[string, Promise<React.ReactElement | null>]> = draft.stops 21 + const initialEmbeds: Array<[string, Promise<React.ReactElement>]> = draft.stops 22 22 .filter((stop) => stop.external?.uri) 23 23 .map((stop) => [stop.external!.uri, loadEmbed(stop.external!.uri)]); 24 24
+44 -46
app/loadEmbed.tsx
··· 1 1 "use server"; 2 2 3 3 import "server-only"; 4 + import { cacheLife, cacheTag } from "next/cache"; 4 5 import { Client, type l } from "@atproto/lex"; 5 6 import * as getPostThread from "@/lib/lexicons/app/bsky/feed/getPostThread"; 6 7 import * as feedDefs from "@/lib/lexicons/app/bsky/feed/defs"; ··· 29 30 ); 30 31 } 31 32 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; 33 + // Cached fetch for Bluesky posts - throws on failure 34 + async function fetchBlueskyPost(atUri: string): Promise<BlueskyPost> { 35 + "use cache: redis"; 36 + cacheTag(`embed:bsky:${atUri}`); 37 + cacheLife("days"); 39 38 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; 39 + const atprotoClient = new Client("https://public.api.bsky.app"); 40 + const result = await atprotoClient.call(getPostThread.main, { 41 + uri: atUri as l.AtUri, 42 + depth: 0, 43 + parentHeight: 0, 44 + }); 45 + if (feedDefs.threadViewPost.$check(result.thread)) { 46 + // Serialize to strip non-plain objects (like CID) 47 + return JSON.parse(JSON.stringify(result.thread)) as BlueskyPost; 54 48 } 49 + throw new Error(`Invalid thread response for ${atUri}`); 55 50 } 56 51 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; 52 + // Cached fetch for link metadata - throws on failure 53 + async function fetchLinkMetadata(url: string): Promise<LinkMetadata> { 54 + "use cache: redis"; 55 + cacheTag(`embed:link:${url}`); 56 + cacheLife("days"); 63 57 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; 58 + const response = await fetch(`https://cardyb.bsky.app/v1/extract?url=${encodeURIComponent(url)}`); 59 + if (!response.ok) { 60 + throw new Error(`Failed to fetch link metadata: ${response.status}`); 74 61 } 62 + 63 + const data = await response.json(); 64 + return { 65 + uri: url, 66 + title: data.title || url, 67 + description: data.description || "", 68 + thumb: data.image || undefined, 69 + }; 75 70 } 76 71 77 - export async function loadEmbed(uri: string): Promise<React.ReactElement | null> { 72 + export async function loadEmbed(uri: string): Promise<React.ReactElement> { 78 73 if (isTrailUri(uri)) { 79 74 const trail = await loadTrailCardByUri(uri); 80 - if (!trail) return null; 75 + if (!trail) throw new Error(`Trail not found: ${uri}`); 81 76 return <TrailCard {...trail} />; 82 77 } 83 78 84 79 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} />; 80 + const atUri = uri.startsWith("at://") 81 + ? uri 82 + : uri.startsWith("http") 83 + ? blueskyUrlToAtUri(uri) 84 + : null; 85 + if (!atUri) throw new Error(`Invalid Bluesky URI: ${uri}`); 86 + 87 + const post = await fetchBlueskyPost(atUri); 88 + return <BlueskyPostEmbed post={post} />; 90 89 } 91 90 92 91 // Regular link 93 - const metadata = await getLinkMetadata(uri); 94 - if (!metadata) return null; 92 + const metadata = await fetchLinkMetadata(uri); 95 93 return ( 96 94 <LinkPreview 97 95 external={{
+22
package-lock.json
··· 36 36 "pg": "^8.16.3", 37 37 "react": "^19", 38 38 "react-dom": "^19", 39 + "react-error-boundary": "^6.0.0", 39 40 "ws": "^8.18.3" 40 41 }, 41 42 "devDependencies": { ··· 869 870 }, 870 871 "engines": { 871 872 "node": ">=6.0.0" 873 + } 874 + }, 875 + "node_modules/@babel/runtime": { 876 + "version": "7.28.4", 877 + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", 878 + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", 879 + "license": "MIT", 880 + "engines": { 881 + "node": ">=6.9.0" 872 882 } 873 883 }, 874 884 "node_modules/@babel/template": { ··· 6884 6894 }, 6885 6895 "peerDependencies": { 6886 6896 "react": "^19.2.1" 6897 + } 6898 + }, 6899 + "node_modules/react-error-boundary": { 6900 + "version": "6.0.0", 6901 + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.0.0.tgz", 6902 + "integrity": "sha512-gdlJjD7NWr0IfkPlaREN2d9uUZUlksrfOx7SX62VRerwXbMY6ftGCIZua1VG1aXFNOimhISsTq+Owp725b9SiA==", 6903 + "license": "MIT", 6904 + "dependencies": { 6905 + "@babel/runtime": "^7.12.5" 6906 + }, 6907 + "peerDependencies": { 6908 + "react": ">=16.13.1" 6887 6909 } 6888 6910 }, 6889 6911 "node_modules/react-remove-scroll": {
+1
package.json
··· 53 53 "pg": "^8.16.3", 54 54 "react": "^19", 55 55 "react-dom": "^19", 56 + "react-error-boundary": "^6.0.0", 56 57 "ws": "^8.18.3" 57 58 }, 58 59 "devDependencies": {