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 if (!draftData.title.trim()) { 361 errors.push("trail needs a title"); 362 } 363 364 if (!draftData.description.trim()) { 365 errors.push("trail needs a description"); 366 } 367 368 if (draftData.stops.length < 2) { 369 errors.push("trails need at least 2 stops to be published"); 370 } 371 372 if (draftData.stops.length > 12) { 373 errors.push("trails can have a maximum of 12 stops"); 374 } 375 376 for (let i = 0; i < draftData.stops.length; i++) { 377 const stop = draftData.stops[i]; 378 const stopErrors: string[] = []; 379 380 if (!stop.title.trim()) { 381 stopErrors.push("needs a title"); 382 } 383 384 if (!stop.content.trim()) { 385 stopErrors.push("needs content"); 386 } 387 388 if (stopErrors.length > 0) { 389 inlineErrors[stop.tid] = stopErrors.join(", "); 390 errors.push(`stop ${i + 1} ${stopErrors.join(", ")}`); 391 } 392 } 393 394 if (errors.length > 0) { 395 return { 396 success: false, 397 errors, 398 inlineErrors, 399 }; 400 } 401 402 const client = await getLexClient(); 403 const db = getDb(); 404 const now = new Date().toISOString(); 405 406 const stops = await Promise.all( 407 draftData.stops.map(async (stop) => { 408 let thumbBlob: l.BlobRef | undefined = undefined; 409 410 if (stop.external?.thumb) { 411 try { 412 const response = await fetch(stop.external.thumb); 413 if (response.ok) { 414 const blob = await response.blob(); 415 const buffer = await blob.arrayBuffer(); 416 417 const uploadResponse = await client.call(uploadBlob.main, new Uint8Array(buffer)); 418 thumbBlob = uploadResponse.blob as l.BlobRef; 419 } 420 } catch (error) { 421 console.error("Failed to upload thumbnail:", error); 422 } 423 } 424 425 const stopRecord: trail.Stop = { 426 tid: generateTid(), 427 title: stop.title, 428 content: stop.content, 429 }; 430 431 if (stop.buttonText) { 432 stopRecord.buttonText = stop.buttonText; 433 } 434 435 if (stop.external) { 436 const resolvedUri = await resolveAtUri(stop.external.uri); 437 const external: trail.External = { 438 uri: resolvedUri as l.Uri, 439 }; 440 if (stop.external.title) { 441 external.title = stop.external.title; 442 } 443 if (stop.external.description) { 444 external.description = stop.external.description; 445 } 446 if (thumbBlob) { 447 external.thumb = thumbBlob; 448 } 449 stopRecord.external = external; 450 } 451 452 return stopRecord; 453 }), 454 ); 455 456 const rkey = generateTid(); 457 const authorDid = client.assertDid; 458 const uri = `at://${authorDid}/app.sidetrail.trail/${rkey}`; 459 460 const currentUser = await loadCurrentUser(); 461 const handle = currentUser?.handle ?? "unknown"; 462 463 const trailRecord: TrailRecord = { 464 $type: "app.sidetrail.trail", 465 title: draftData.title, 466 description: draftData.description, 467 stops: stops.map((s) => { 468 const stop: TrailRecord["stops"][0] = { 469 tid: s.tid, 470 title: s.title, 471 content: s.content, 472 }; 473 if (s.buttonText) stop.buttonText = s.buttonText; 474 if (s.external) { 475 const external: NonNullable<TrailRecord["stops"][0]["external"]> = { 476 uri: s.external.uri, 477 }; 478 if (s.external.title) external.title = s.external.title; 479 if (s.external.description) external.description = s.external.description; 480 if (s.external.thumb) external.thumb = s.external.thumb as typeof external.thumb; 481 stop.external = external; 482 } 483 return stop; 484 }), 485 accentColor: draftData.accentColor, 486 backgroundColor: draftData.backgroundColor, 487 createdAt: now, 488 }; 489 490 const cid = (await cidForLex(trailRecord)).toString(); 491 492 await db.insert(trails).values({ 493 uri, 494 cid, 495 authorDid, 496 rkey, 497 record: trailRecord, 498 createdAt: new Date(now), 499 }); 500 501 after(async () => { 502 await client.create( 503 trail.main, 504 { 505 title: draftData.title, 506 description: draftData.description, 507 stops, 508 accentColor: draftData.accentColor, 509 backgroundColor: draftData.backgroundColor, 510 createdAt: now, 511 }, 512 { rkey }, 513 ); 514 }); 515 516 return { success: true, uri, rkey, handle }; 517} 518 519export async function deleteTrail(trailUri: string): Promise<void> { 520 const client = await getLexClient(); 521 const db = getDb(); 522 523 const trailRkey = extractRkey(trailUri); 524 525 await db.delete(trails).where(eq(trails.uri, trailUri)); 526 527 after(async () => { 528 await client.delete(trail.main, { rkey: trailRkey }); 529 }); 530}