an app to share curated trails sidetrail.app
1

Configure Feed

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

1"use server"; 2 3import "server-only"; 4import { after } from "next/server"; 5import { getLexClient } from "@/data/lex-client"; 6import { generateTid } from "./tid"; 7import { loadCurrentUser, resolveHandleToDid } from "./queries"; 8import { refresh } from "next/cache"; 9import * as trail from "@/lib/lexicons/app/sidetrail/trail"; 10import * as walk from "@/lib/lexicons/app/sidetrail/walk"; 11import * as completion from "@/lib/lexicons/app/sidetrail/completion"; 12import * as uploadBlob from "@/lib/lexicons/com/atproto/repo/uploadBlob"; 13import type { l } from "@atproto/lex"; 14import { 15 getDb, 16 trails, 17 walks as walksTable, 18 completions as completionsTable, 19 type TrailRecord, 20 type WalkRecord, 21 type CompletionRecord, 22} from "@/data/db"; 23import { eq, and } from "drizzle-orm"; 24import { cidForLex } from "@atproto/lex-cbor"; 25import { AtUri } from "@atproto/syntax"; 26 27// ============================================================================ 28// Helpers 29// ============================================================================ 30 31function extractRkey(uri: string): string { 32 const parts = uri.split("/"); 33 return parts[parts.length - 1]; 34} 35 36/** 37 * Resolves an at:// URI to use a DID instead of a handle. 38 * If the URI already uses a DID or is not an at:// URI, returns it unchanged. 39 */ 40async function resolveAtUri(uri: string): Promise<string> { 41 if (!uri.startsWith("at://")) { 42 return uri; 43 } 44 45 const parsed = new AtUri(uri); 46 if (parsed.host.startsWith("did:")) { 47 return uri; 48 } 49 50 const did = await resolveHandleToDid(parsed.host); 51 return `at://${did}/${parsed.collection}/${parsed.rkey}`; 52} 53 54// ============================================================================ 55// Walk Mutations 56// ============================================================================ 57 58export async function startWalk(trailUri: string, trailCid: string): Promise<void> { 59 const client = await getLexClient(); 60 const db = getDb(); 61 const authorDid = client.assertDid; 62 63 const [existingWalk] = await db 64 .select({ uri: walksTable.uri }) 65 .from(walksTable) 66 .where(and(eq(walksTable.authorDid, authorDid), eq(walksTable.trailUri, trailUri))) 67 .limit(1); 68 69 if (existingWalk) { 70 return; 71 } 72 73 const [trailRow] = await db.select().from(trails).where(eq(trails.uri, trailUri)).limit(1); 74 75 if (!trailRow) { 76 throw new Error("Trail not found"); 77 } 78 79 const trailData = trailRow.record; 80 81 if (!trailData.stops || trailData.stops.length === 0) { 82 throw new Error("Trail has no stops"); 83 } 84 85 const now = new Date().toISOString(); 86 const firstStopTid = trailData.stops[0].tid; 87 88 const rkey = generateTid(); 89 const uri = `at://${authorDid}/app.sidetrail.walk/${rkey}`; 90 91 const walkRecord: WalkRecord = { 92 $type: "app.sidetrail.walk", 93 trail: { uri: trailUri, cid: trailCid }, 94 visitedStops: [firstStopTid], 95 createdAt: now, 96 updatedAt: now, 97 }; 98 99 const cid = (await cidForLex(walkRecord)).toString(); 100 101 await db.insert(walksTable).values({ 102 uri, 103 cid, 104 authorDid, 105 rkey, 106 trailUri, 107 record: walkRecord, 108 createdAt: new Date(now), 109 }); 110 111 after(async () => { 112 await client.create( 113 walk.main, 114 { 115 trail: { 116 uri: trailUri as l.AtUri, 117 cid: trailCid, 118 }, 119 visitedStops: [firstStopTid], 120 createdAt: now, 121 updatedAt: now, 122 }, 123 { rkey }, 124 ); 125 }); 126 127 refresh(); 128} 129 130export async function visitStop(walkUri: string, stopTid: string): Promise<void> { 131 const client = await getLexClient(); 132 const db = getDb(); 133 134 const walkRkey = extractRkey(walkUri); 135 136 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1); 137 138 if (!walkRow) { 139 throw new Error("Walk not found"); 140 } 141 142 const walkData = walkRow.record; 143 144 const visitedStops = walkData.visitedStops.includes(stopTid) 145 ? [...walkData.visitedStops.filter((t) => t !== stopTid), stopTid] 146 : [...walkData.visitedStops, stopTid]; 147 const now = new Date().toISOString(); 148 149 const updatedRecord: WalkRecord = { 150 $type: "app.sidetrail.walk", 151 trail: { uri: walkData.trail.uri, cid: walkData.trail.cid }, 152 visitedStops, 153 createdAt: walkData.createdAt, 154 updatedAt: now, 155 }; 156 157 await db.update(walksTable).set({ record: updatedRecord }).where(eq(walksTable.uri, walkUri)); 158 159 after(async () => { 160 await client.put( 161 walk.main, 162 { 163 trail: { 164 uri: walkData.trail.uri as l.AtUri, 165 cid: walkData.trail.cid, 166 }, 167 visitedStops, 168 createdAt: walkData.createdAt as l.Datetime, 169 updatedAt: now, 170 }, 171 { rkey: walkRkey }, 172 ); 173 }); 174 175 refresh(); 176} 177 178export async function completeTrail(walkUri: string): Promise<void> { 179 const client = await getLexClient(); 180 const db = getDb(); 181 const authorDid = client.assertDid; 182 183 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1); 184 185 if (!walkRow) { 186 throw new Error("Walk not found"); 187 } 188 189 const walkData = walkRow.record; 190 const trailUri = walkData.trail.uri; 191 const now = new Date().toISOString(); 192 193 const allWalks = await db 194 .select() 195 .from(walksTable) 196 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 197 198 const completionRkey = generateTid(); 199 const completionUri = `at://${authorDid}/app.sidetrail.completion/${completionRkey}`; 200 201 const completionRecord: CompletionRecord = { 202 $type: "app.sidetrail.completion", 203 trail: { uri: trailUri, cid: walkData.trail.cid }, 204 createdAt: now, 205 }; 206 207 const cid = (await cidForLex(completionRecord)).toString(); 208 209 await db.insert(completionsTable).values({ 210 uri: completionUri, 211 cid, 212 authorDid, 213 rkey: completionRkey, 214 trailUri, 215 record: completionRecord, 216 createdAt: new Date(now), 217 }); 218 219 await db 220 .delete(walksTable) 221 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 222 223 after(async () => { 224 await client.create( 225 completion.main, 226 { 227 trail: { 228 uri: trailUri as l.AtUri, 229 cid: walkData.trail.cid, 230 }, 231 createdAt: now, 232 }, 233 { rkey: completionRkey }, 234 ); 235 for (const w of allWalks) { 236 await client.delete(walk.main, { rkey: w.rkey }); 237 } 238 }); 239 240 refresh(); 241} 242 243export async function abandonWalk(walkUri: string): Promise<void> { 244 const client = await getLexClient(); 245 const db = getDb(); 246 const authorDid = client.assertDid; 247 248 const [walkRow] = await db.select().from(walksTable).where(eq(walksTable.uri, walkUri)).limit(1); 249 250 if (!walkRow) { 251 return; 252 } 253 254 const trailUri = walkRow.record.trail.uri; 255 256 const allWalks = await db 257 .select() 258 .from(walksTable) 259 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 260 261 await db 262 .delete(walksTable) 263 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 264 265 after(async () => { 266 for (const w of allWalks) { 267 await client.delete(walk.main, { rkey: w.rkey }); 268 } 269 }); 270 271 refresh(); 272} 273 274// ============================================================================ 275// Completion Mutations 276// ============================================================================ 277 278export async function deleteCompletion(completionUri: string): Promise<void> { 279 const client = await getLexClient(); 280 const db = getDb(); 281 282 const completionRkey = extractRkey(completionUri); 283 284 await db.delete(completionsTable).where(eq(completionsTable.uri, completionUri)); 285 286 after(async () => { 287 await client.delete(completion.main, { rkey: completionRkey }); 288 }); 289 290 refresh(); 291} 292 293export async function forgetTrail(trailUri: string): Promise<void> { 294 const client = await getLexClient(); 295 const db = getDb(); 296 297 const authorDid = client.assertDid; 298 299 const matchingWalks = await db 300 .select() 301 .from(walksTable) 302 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 303 304 const matchingCompletions = await db 305 .select() 306 .from(completionsTable) 307 .where(and(eq(completionsTable.trailUri, trailUri), eq(completionsTable.authorDid, authorDid))); 308 309 await db 310 .delete(walksTable) 311 .where(and(eq(walksTable.trailUri, trailUri), eq(walksTable.authorDid, authorDid))); 312 313 await db 314 .delete(completionsTable) 315 .where(and(eq(completionsTable.trailUri, trailUri), eq(completionsTable.authorDid, authorDid))); 316 317 after(async () => { 318 for (const w of matchingWalks) { 319 await client.delete(walk.main, { rkey: w.rkey }); 320 } 321 for (const c of matchingCompletions) { 322 await client.delete(completion.main, { rkey: c.rkey }); 323 } 324 }); 325 326 refresh(); 327} 328 329// ============================================================================ 330// Trail Mutations 331// ============================================================================ 332 333type PublishDraftResult = 334 | { success: true; uri: string; rkey: string; handle: string } 335 | { success: false; errors: string[]; inlineErrors: Record<string, string> }; 336 337type ExternalEmbedInput = { 338 uri: string; 339 title?: string; 340 description?: string; 341 thumb?: string; // URL string from draft 342}; 343 344export async function publishDraft(draftData: { 345 title: string; 346 description: string; 347 stops: Array<{ 348 tid: string; 349 title: string; 350 content: string; 351 buttonText?: string; 352 external?: ExternalEmbedInput; 353 }>; 354 accentColor: string; 355 backgroundColor: string; 356}): Promise<PublishDraftResult> { 357 const errors: string[] = []; 358 const inlineErrors: Record<string, string> = {}; 359 360 // Length limits mirror the lexicon (lexicons/app/sidetrail/trail.json) - 361 // records that exceed them would be rejected by our own ingester. 362 const graphemes = (s: string) => [...new Intl.Segmenter().segment(s)].length; 363 364 if (!draftData.title.trim()) { 365 errors.push("trail needs a title"); 366 } else if (graphemes(draftData.title) > 64) { 367 errors.push("trail title is too long (max 64 characters)"); 368 } 369 370 if (!draftData.description.trim()) { 371 errors.push("trail needs a description"); 372 } else if (graphemes(draftData.description) > 300) { 373 errors.push("trail description is too long (max 300 characters)"); 374 } 375 376 if (draftData.stops.length < 2) { 377 errors.push("trails need at least 2 stops to be published"); 378 } 379 380 if (draftData.stops.length > 24) { 381 errors.push("trails can have a maximum of 24 stops"); 382 } 383 384 for (let i = 0; i < draftData.stops.length; i++) { 385 const stop = draftData.stops[i]; 386 const stopErrors: string[] = []; 387 388 if (!stop.title.trim()) { 389 stopErrors.push("needs a title"); 390 } else if (graphemes(stop.title) > 128) { 391 stopErrors.push("title is too long (max 128 characters)"); 392 } 393 394 if (!stop.content.trim()) { 395 stopErrors.push("needs content"); 396 } else if (graphemes(stop.content) > 5000) { 397 stopErrors.push("content is too long (max 5000 characters)"); 398 } 399 400 if (stop.buttonText && graphemes(stop.buttonText) > 64) { 401 stopErrors.push("button text is too long (max 64 characters)"); 402 } 403 404 if (stopErrors.length > 0) { 405 inlineErrors[stop.tid] = stopErrors.join(", "); 406 errors.push(`stop ${i + 1} ${stopErrors.join(", ")}`); 407 } 408 } 409 410 if (errors.length > 0) { 411 return { 412 success: false, 413 errors, 414 inlineErrors, 415 }; 416 } 417 418 const client = await getLexClient(); 419 const db = getDb(); 420 const now = new Date().toISOString(); 421 422 const stops = await Promise.all( 423 draftData.stops.map(async (stop) => { 424 let thumbBlob: l.BlobRef | undefined = undefined; 425 426 if (stop.external?.thumb) { 427 try { 428 const response = await fetch(stop.external.thumb); 429 if (response.ok) { 430 const blob = await response.blob(); 431 const buffer = await blob.arrayBuffer(); 432 433 const uploadResponse = await client.call(uploadBlob.main, new Uint8Array(buffer)); 434 thumbBlob = uploadResponse.blob as l.BlobRef; 435 } 436 } catch (error) { 437 console.error("Failed to upload thumbnail:", error); 438 } 439 } 440 441 const stopRecord: trail.Stop = { 442 tid: generateTid(), 443 title: stop.title, 444 content: stop.content, 445 }; 446 447 if (stop.buttonText) { 448 stopRecord.buttonText = stop.buttonText; 449 } 450 451 if (stop.external) { 452 const resolvedUri = await resolveAtUri(stop.external.uri); 453 const external: trail.External = { 454 uri: resolvedUri as l.Uri, 455 }; 456 if (stop.external.title) { 457 external.title = stop.external.title; 458 } 459 if (stop.external.description) { 460 external.description = stop.external.description; 461 } 462 if (thumbBlob) { 463 external.thumb = thumbBlob; 464 } 465 stopRecord.external = external; 466 } 467 468 return stopRecord; 469 }), 470 ); 471 472 const rkey = generateTid(); 473 const authorDid = client.assertDid; 474 const uri = `at://${authorDid}/app.sidetrail.trail/${rkey}`; 475 476 const currentUser = await loadCurrentUser(); 477 const handle = currentUser?.handle ?? "unknown"; 478 479 const trailRecord: TrailRecord = { 480 $type: "app.sidetrail.trail", 481 title: draftData.title, 482 description: draftData.description, 483 stops: stops.map((s) => { 484 const stop: TrailRecord["stops"][0] = { 485 tid: s.tid, 486 title: s.title, 487 content: s.content, 488 }; 489 if (s.buttonText) stop.buttonText = s.buttonText; 490 if (s.external) { 491 const external: NonNullable<TrailRecord["stops"][0]["external"]> = { 492 uri: s.external.uri, 493 }; 494 if (s.external.title) external.title = s.external.title; 495 if (s.external.description) external.description = s.external.description; 496 if (s.external.thumb) external.thumb = s.external.thumb as typeof external.thumb; 497 stop.external = external; 498 } 499 return stop; 500 }), 501 accentColor: draftData.accentColor, 502 backgroundColor: draftData.backgroundColor, 503 createdAt: now, 504 }; 505 506 const cid = (await cidForLex(trailRecord)).toString(); 507 508 await db.insert(trails).values({ 509 uri, 510 cid, 511 authorDid, 512 rkey, 513 record: trailRecord, 514 createdAt: new Date(now), 515 }); 516 517 after(async () => { 518 await client.create( 519 trail.main, 520 { 521 title: draftData.title, 522 description: draftData.description, 523 stops, 524 accentColor: draftData.accentColor, 525 backgroundColor: draftData.backgroundColor, 526 createdAt: now, 527 }, 528 { rkey }, 529 ); 530 }); 531 532 return { success: true, uri, rkey, handle }; 533} 534 535export async function deleteTrail(trailUri: string): Promise<void> { 536 const client = await getLexClient(); 537 const db = getDb(); 538 539 const trailRkey = extractRkey(trailUri); 540 541 await db.delete(trails).where(eq(trails.uri, trailUri)); 542 543 after(async () => { 544 await client.delete(trail.main, { rkey: trailRkey }); 545 }); 546}