Search interface for Tangled running on a Slice

track result clicks in a cookie and render the top 5

Changed files
+374 -135
+374 -135
main.tsx
··· 75 75 </html> 76 76 ); 77 77 78 - const SearchPage = () => ( 78 + const RecentSearches = ({ 79 + recentRecords, 80 + actors, 81 + }: { 82 + recentRecords: IndexedRecord[]; 83 + actors: Map<string, Actor>; 84 + }) => { 85 + return ( 86 + <div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm mb-4"> 87 + <div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10"> 88 + <div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider"> 89 + RECENT_ACTIVITY // QUICK_ACCESS 90 + </div> 91 + </div> 92 + <div class="p-2 sm:p-3"> 93 + <div class="flex gap-2 overflow-x-auto scrollbar-thin scrollbar-thumb-cyan-500/30 scrollbar-track-black/50"> 94 + {recentRecords.map((record, index) => { 95 + const actor = actors.get(record.did); 96 + const isRepo = record.collection === "sh.tangled.repo"; 97 + const repo = isRepo 98 + ? (record.value as unknown as ShTangledRepo) 99 + : null; 100 + const profile = !isRepo 101 + ? (record.value as unknown as AppBskyActorProfile) 102 + : null; 103 + 104 + const url = 105 + isRepo && actor?.handle && repo?.name 106 + ? `https://tangled.sh/@${actor.handle}/${repo.name}` 107 + : !isRepo && actor?.handle 108 + ? `https://tangled.sh/@${actor.handle}` 109 + : "#"; 110 + 111 + return ( 112 + <div class="flex-shrink-0 flex items-center gap-2 px-3 py-2 border border-cyan-500/20 bg-black/50 hover:bg-cyan-500/5 transition-all text-xs group"> 113 + <a 114 + href={url} 115 + target="_blank" 116 + rel="noopener noreferrer" 117 + class="flex items-center gap-2 no-underline" 118 + //@ts-expect-error hyperscript 119 + _={`on click 120 + fetch /track-click {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text`} 121 + > 122 + <span class="text-cyan-400"> 123 + [{String(index + 1).padStart(2, "0")}] 124 + </span> 125 + <span class="text-green-400 font-mono max-w-[150px] truncate"> 126 + {isRepo ? repo?.name : profile?.displayName || actor?.handle} 127 + </span> 128 + <span class="text-pink-400/70 text-[10px]"> 129 + {isRepo ? "R" : "P"} 130 + </span> 131 + </a> 132 + <button 133 + type="button" 134 + class="ml-1 text-red-400 hover:text-red-300 leading-none text-xs font-mono" 135 + title="Remove from recent" 136 + //@ts-expect-error hyperscript 137 + _={`on click 138 + halt the event 139 + fetch /remove-recent {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text 140 + then window.location.reload()`} 141 + > 142 + [-] 143 + </button> 144 + </div> 145 + ); 146 + })} 147 + </div> 148 + </div> 149 + </div> 150 + ); 151 + }; 152 + 153 + const SearchPage = ({ 154 + recentRecords, 155 + actors, 156 + }: { 157 + recentRecords?: IndexedRecord[]; 158 + actors?: Map<string, Actor>; 159 + }) => ( 79 160 <Layout> 80 161 <div class="w-full max-w-4xl relative z-10 min-h-screen sm:min-h-0 px-3 sm:px-6"> 81 162 <div ··· 166 247 </div> 167 248 168 249 <div id="search-container" class=""> 250 + <div id="recent-container"> 251 + {recentRecords && recentRecords.length > 0 && actors && ( 252 + <RecentSearches recentRecords={recentRecords} actors={actors} /> 253 + )} 254 + </div> 169 255 <div 170 256 id="loading" 171 257 class="htmx-indicator items-center justify-center py-8" ··· 223 309 }); 224 310 225 311 return ( 226 - <div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm"> 227 - <div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10"> 228 - <div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider flex flex-col sm:flex-row justify-between gap-1 sm:gap-0"> 229 - <span class="truncate">SEARCH_RESULTS // QUERY: "{query}"</span> 230 - <span class="text-[10px] sm:text-xs"> 231 - TYPE: {searchType.toUpperCase()} 232 - </span> 312 + <> 313 + <div class="border border-cyan-500/50 bg-black/70 backdrop-blur-sm"> 314 + <div class="border-b border-cyan-500/30 px-3 sm:px-4 py-2 bg-cyan-500/10"> 315 + <div class="text-cyan-400 text-[10px] sm:text-xs uppercase tracking-wider flex flex-col sm:flex-row justify-between gap-1 sm:gap-0"> 316 + <span class="truncate">SEARCH_RESULTS // QUERY: "{query}"</span> 317 + <span class="text-[10px] sm:text-xs"> 318 + TYPE: {searchType.toUpperCase()} 319 + </span> 320 + </div> 233 321 </div> 234 - </div> 235 322 236 - {displayResults.length === 0 ? ( 237 - <div class="p-3 sm:p-4 text-red-400 border-l-2 border-red-500 text-sm"> 238 - ERROR: NO_RESULTS_FOUND // QUERY: "{query}" 239 - </div> 240 - ) : ( 241 - <div class="p-3 sm:p-4"> 242 - <div class="text-green-400 text-[10px] sm:text-xs mb-3 sm:mb-4 flex items-center gap-2"> 243 - <span class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-400 rounded-full animate-pulse"></span> 244 - FOUND {displayResults.length} RESULT 245 - {displayResults.length !== 1 ? "S" : ""} // STATUS: OK 323 + {displayResults.length === 0 ? ( 324 + <div class="p-3 sm:p-4 text-red-400 border-l-2 border-red-500 text-sm"> 325 + ERROR: NO_RESULTS_FOUND // QUERY: "{query}" 246 326 </div> 327 + ) : ( 328 + <div class="p-3 sm:p-4"> 329 + <div class="text-green-400 text-[10px] sm:text-xs mb-3 sm:mb-4 flex items-center gap-2"> 330 + <span class="w-1.5 h-1.5 sm:w-2 sm:h-2 bg-green-400 rounded-full animate-pulse"></span> 331 + FOUND {displayResults.length} RESULT 332 + {displayResults.length !== 1 ? "S" : ""} // STATUS: OK 333 + </div> 247 334 248 - <div class="space-y-3 sm:space-y-4 sm:max-h-80 sm:overflow-y-auto"> 249 - {displayResults.map((record, index) => { 250 - const actor = actors.get(record.did); 251 - const isRepo = record.collection === "sh.tangled.repo"; 252 - const repo = isRepo 253 - ? (record.value as unknown as ShTangledRepo) 254 - : null; 255 - const profile = !isRepo 256 - ? (record.value as unknown as AppBskyActorProfile) 257 - : null; 258 - 259 - const repoUrl = 260 - isRepo && actor?.handle && repo?.name 261 - ? `https://tangled.sh/@${actor.handle}/${repo.name}` 335 + <div class="space-y-3 sm:space-y-4 sm:max-h-80 sm:overflow-y-auto"> 336 + {displayResults.map((record, index) => { 337 + const actor = actors.get(record.did); 338 + const isRepo = record.collection === "sh.tangled.repo"; 339 + const repo = isRepo 340 + ? (record.value as unknown as ShTangledRepo) 262 341 : null; 263 - 264 - const profileUrl = 265 - !isRepo && actor?.handle 266 - ? `https://tangled.sh/@${actor.handle}` 342 + const profile = !isRepo 343 + ? (record.value as unknown as AppBskyActorProfile) 267 344 : null; 268 345 269 - const clickUrl = repoUrl || profileUrl; 346 + const repoUrl = 347 + isRepo && actor?.handle && repo?.name 348 + ? `https://tangled.sh/@${actor.handle}/${repo.name}` 349 + : null; 270 350 271 - const ResultContainer = clickUrl ? "a" : "div"; 272 - const containerProps = clickUrl 273 - ? { 274 - href: clickUrl, 275 - target: "_blank", 276 - rel: "noopener noreferrer", 277 - } 278 - : {}; 351 + const profileUrl = 352 + !isRepo && actor?.handle 353 + ? `https://tangled.sh/@${actor.handle}` 354 + : null; 279 355 280 - return ( 281 - <ResultContainer 282 - class="border border-cyan-500/30 bg-black/50 hover:bg-cyan-500/5 transition-all block no-underline overflow-hidden" 283 - {...containerProps} 284 - > 285 - <div class="border-b border-cyan-500/20 px-2 sm:px-3 py-1 bg-cyan-500/5 flex justify-between items-center"> 286 - <div class="text-cyan-400 text-[10px] sm:text-xs"> 287 - [{String(index + 1).padStart(3, "0")}]{" "} 288 - <span class="hidden sm:inline"> 289 - {isRepo ? "REPO" : "PROFILE"}_ENTRY 290 - </span> 291 - <span class="sm:hidden">{isRepo ? "REPO" : "PROF"}</span> 356 + const clickUrl = repoUrl || profileUrl; 357 + 358 + const ResultContainer = clickUrl ? "a" : "div"; 359 + 360 + const containerProps = clickUrl 361 + ? { 362 + href: clickUrl, 363 + target: "_blank", 364 + rel: "noopener noreferrer", 365 + _: `on click 366 + fetch /track-click {method:'POST', headers: {'Content-Type': 'application/json'}, body: '{"uri": "${record.uri}"}' } as text`, 367 + } 368 + : {}; 369 + 370 + return ( 371 + <ResultContainer 372 + class="border border-cyan-500/30 bg-black/50 hover:bg-cyan-500/5 transition-all block no-underline overflow-hidden" 373 + {...containerProps} 374 + > 375 + <div class="border-b border-cyan-500/20 px-2 sm:px-3 py-1 bg-cyan-500/5 flex justify-between items-center"> 376 + <div class="text-cyan-400 text-[10px] sm:text-xs"> 377 + [{String(index + 1).padStart(3, "0")}]{" "} 378 + <span class="hidden sm:inline"> 379 + {isRepo ? "REPO" : "PROFILE"}_ENTRY 380 + </span> 381 + <span class="sm:hidden"> 382 + {isRepo ? "REPO" : "PROF"} 383 + </span> 384 + </div> 385 + <div class="text-pink-400 text-[10px] sm:text-xs"> 386 + ID:{" "} 387 + {isRepo 388 + ? record.uri.split("/").pop()?.slice(-8) 389 + : record.did.slice(-8)} 390 + </div> 292 391 </div> 293 - <div class="text-pink-400 text-[10px] sm:text-xs"> 294 - ID: {isRepo ? record.uri.split('/').pop()?.slice(-8) : record.did.slice(-8)} 295 - </div> 296 - </div> 392 + 393 + <div class="p-2 sm:p-3"> 394 + <div class="flex items-start gap-2 sm:gap-3"> 395 + {(() => { 396 + // For profiles, use their own avatar 397 + const avatarProfile = isRepo 398 + ? ownerProfiles.get(record.did) 399 + : profile; 297 400 298 - <div class="p-2 sm:p-3"> 299 - <div class="flex items-start gap-2 sm:gap-3"> 300 - {(() => { 301 - // For profiles, use their own avatar 302 - const avatarProfile = isRepo 303 - ? ownerProfiles.get(record.did) 304 - : profile; 401 + if (avatarProfile?.avatar) { 402 + return ( 403 + <img 404 + src={recordBlobToCdnUrl( 405 + record as IndexedRecord, 406 + avatarProfile.avatar, 407 + "avatar" 408 + )} 409 + alt={ 410 + avatarProfile.displayName || 411 + actor?.handle || 412 + "Avatar" 413 + } 414 + class="w-7 h-7 sm:w-8 sm:h-8 rounded border border-cyan-500/50 flex-shrink-0" 415 + /> 416 + ); 417 + } 305 418 306 - if (avatarProfile?.avatar) { 307 419 return ( 308 - <img 309 - src={recordBlobToCdnUrl( 310 - record as IndexedRecord, 311 - avatarProfile.avatar, 312 - "avatar" 313 - )} 314 - alt={ 315 - avatarProfile.displayName || 316 - actor?.handle || 317 - "Avatar" 318 - } 319 - class="w-7 h-7 sm:w-8 sm:h-8 rounded border border-cyan-500/50 flex-shrink-0" 320 - /> 420 + <div class="w-7 h-7 sm:w-8 sm:h-8 border border-cyan-500/50 bg-cyan-500/10 flex items-center justify-center flex-shrink-0"> 421 + <span class="text-cyan-400 text-[10px] sm:text-xs"> 422 + {( 423 + profile?.displayName || 424 + repo?.name || 425 + actor?.handle || 426 + "?" 427 + ) 428 + .charAt(0) 429 + .toUpperCase()} 430 + </span> 431 + </div> 321 432 ); 322 - } 433 + })()} 323 434 324 - return ( 325 - <div class="w-7 h-7 sm:w-8 sm:h-8 border border-cyan-500/50 bg-cyan-500/10 flex items-center justify-center flex-shrink-0"> 326 - <span class="text-cyan-400 text-[10px] sm:text-xs"> 327 - {( 328 - profile?.displayName || 329 - repo?.name || 330 - actor?.handle || 331 - "?" 332 - ) 333 - .charAt(0) 334 - .toUpperCase()} 335 - </span> 435 + <div class="flex-1 min-w-0"> 436 + <div class="flex flex-wrap items-center gap-2 mb-1"> 437 + <div class="text-green-400 font-mono font-semibold text-sm sm:text-base truncate flex-1"> 438 + {isRepo 439 + ? repo?.name || "UNNAMED_REPO" 440 + : profile?.displayName || 441 + actor?.handle || 442 + "UNNAMED_PROFILE"} 443 + </div> 444 + {isRepo && starCounts.get(record.uri) && ( 445 + <div class="flex items-center gap-1 px-1.5 sm:px-2 py-0.5 sm:py-1 bg-yellow-500/20 border border-yellow-500/50 rounded text-[10px] sm:text-xs flex-shrink-0"> 446 + <span class="text-yellow-400">★</span> 447 + <span class="text-yellow-300 font-mono"> 448 + {starCounts.get(record.uri)} 449 + </span> 450 + </div> 451 + )} 336 452 </div> 337 - ); 338 - })()} 339 453 340 - <div class="flex-1 min-w-0"> 341 - <div class="flex flex-wrap items-center gap-2 mb-1"> 342 - <div class="text-green-400 font-mono font-semibold text-sm sm:text-base truncate flex-1"> 343 - {isRepo 344 - ? repo?.name || "UNNAMED_REPO" 345 - : profile?.displayName || 346 - actor?.handle || 347 - "UNNAMED_PROFILE"} 348 - </div> 349 - {isRepo && starCounts.get(record.uri) && ( 350 - <div class="flex items-center gap-1 px-1.5 sm:px-2 py-0.5 sm:py-1 bg-yellow-500/20 border border-yellow-500/50 rounded text-[10px] sm:text-xs flex-shrink-0"> 351 - <span class="text-yellow-400">★</span> 352 - <span class="text-yellow-300 font-mono"> 353 - {starCounts.get(record.uri)} 354 - </span> 454 + {(actor?.handle || profile?.displayName) && ( 455 + <div class="text-pink-400 text-xs sm:text-sm mb-1 sm:mb-2 font-mono truncate"> 456 + {isRepo 457 + ? `OWNER: ${ 458 + actor?.handle || 459 + record.did.slice(0, 16) + "..." 460 + }` 461 + : `HANDLE: @${actor?.handle || "unknown"}`} 355 462 </div> 356 463 )} 357 - </div> 358 464 359 - {(actor?.handle || profile?.displayName) && ( 360 - <div class="text-pink-400 text-xs sm:text-sm mb-1 sm:mb-2 font-mono truncate"> 465 + <div class="text-cyan-300/80 text-xs sm:text-sm font-mono leading-relaxed line-clamp-3"> 361 466 {isRepo 362 - ? `OWNER: ${ 363 - actor?.handle || 364 - record.did.slice(0, 16) + "..." 365 - }` 366 - : `HANDLE: @${actor?.handle || "unknown"}`} 467 + ? repo?.description || "NO_DESCRIPTION_AVAILABLE" 468 + : profile?.description || "NO_BIO_AVAILABLE"} 367 469 </div> 368 - )} 369 - 370 - <div class="text-cyan-300/80 text-xs sm:text-sm font-mono leading-relaxed line-clamp-3"> 371 - {isRepo 372 - ? repo?.description || "NO_DESCRIPTION_AVAILABLE" 373 - : profile?.description || "NO_BIO_AVAILABLE"} 374 470 </div> 375 471 </div> 376 472 </div> 377 - </div> 378 - </ResultContainer> 379 - ); 380 - })} 473 + </ResultContainer> 474 + ); 475 + })} 476 + </div> 381 477 </div> 382 - </div> 383 - )} 384 - </div> 478 + )} 479 + </div> 480 + </> 385 481 ); 386 482 }; 387 483 ··· 389 485 const url = new URL(req.url); 390 486 391 487 if (url.pathname === "/" && req.method === "GET") { 392 - const html = renderToString(<SearchPage />); 488 + // Parse cookies to get recent URIs 489 + const cookieHeader = req.headers.get("cookie") || ""; 490 + const cookies = Object.fromEntries( 491 + cookieHeader.split("; ").map((c) => { 492 + const [key, ...value] = c.split("="); 493 + return [key, value.join("=")]; 494 + }) 495 + ); 496 + 497 + let recentUris: string[] = []; 498 + if (cookies.recent_uris) { 499 + try { 500 + recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris)); 501 + } catch { 502 + // Invalid cookie data, ignore 503 + } 504 + } 505 + 506 + // Fetch the recent records and their actors 507 + let recentRecords: IndexedRecord[] = []; 508 + const actors = new Map<string, Actor>(); 509 + 510 + if (recentUris.length > 0) { 511 + try { 512 + const response = await client.getSliceRecords({ 513 + where: { uri: { in: recentUris } }, 514 + limit: 5, 515 + }); 516 + recentRecords = response.records; 517 + 518 + // Sort by the order in recentUris to maintain recent order 519 + recentRecords.sort( 520 + (a, b) => recentUris.indexOf(a.uri) - recentUris.indexOf(b.uri) 521 + ); 522 + 523 + // Fetch actors for handles 524 + const ownerDids = [ 525 + ...new Set(recentRecords.map((record) => record.did)), 526 + ]; 527 + if (ownerDids.length > 0) { 528 + const actorResults = await client.getActors({ 529 + where: { did: { in: ownerDids } }, 530 + limit: 5, 531 + }); 532 + for (const actor of actorResults.actors) { 533 + actors.set(actor.did, actor); 534 + } 535 + } 536 + } catch (error) { 537 + console.error("Error fetching recent records:", error); 538 + } 539 + } 540 + 541 + const html = renderToString( 542 + <SearchPage recentRecords={recentRecords} actors={actors} /> 543 + ); 393 544 return new Response(html, { 394 545 headers: { "content-type": "text/html; charset=utf-8" }, 395 546 }); 547 + } 548 + 549 + if (url.pathname === "/remove-recent" && req.method === "POST") { 550 + try { 551 + const { uri } = await req.json(); 552 + 553 + // Get existing recent URIs 554 + const cookieHeader = req.headers.get("cookie") || ""; 555 + const cookies = Object.fromEntries( 556 + cookieHeader.split("; ").map((c) => { 557 + const [key, ...value] = c.split("="); 558 + return [key, value.join("=")]; 559 + }) 560 + ); 561 + 562 + let recentUris: string[] = []; 563 + if (cookies.recent_uris) { 564 + try { 565 + recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris)); 566 + } catch { 567 + // Invalid cookie data, ignore 568 + } 569 + } 570 + 571 + // Remove the specified URI 572 + recentUris = recentUris.filter((u) => u !== uri); 573 + 574 + // Set cookie for 30 days 575 + const expires = new Date(); 576 + expires.setDate(expires.getDate() + 30); 577 + const cookieValue = encodeURIComponent(JSON.stringify(recentUris)); 578 + 579 + return new Response("", { 580 + status: 200, 581 + headers: { 582 + "Set-Cookie": `recent_uris=${cookieValue}; Expires=${expires.toUTCString()}; Path=/; SameSite=Lax`, 583 + }, 584 + }); 585 + } catch (error) { 586 + console.error("Error removing recent:", error); 587 + return new Response("", { status: 200 }); 588 + } 589 + } 590 + 591 + if (url.pathname === "/track-click" && req.method === "POST") { 592 + try { 593 + const { uri } = await req.json(); 594 + 595 + // Get existing recent URIs 596 + const cookieHeader = req.headers.get("cookie") || ""; 597 + const cookies = Object.fromEntries( 598 + cookieHeader.split("; ").map((c) => { 599 + const [key, ...value] = c.split("="); 600 + return [key, value.join("=")]; 601 + }) 602 + ); 603 + 604 + let recentUris: string[] = []; 605 + if (cookies.recent_uris) { 606 + try { 607 + recentUris = JSON.parse(decodeURIComponent(cookies.recent_uris)); 608 + } catch { 609 + // Invalid cookie data, ignore 610 + } 611 + } 612 + 613 + // Remove if exists (to move to front) 614 + recentUris = recentUris.filter((u) => u !== uri); 615 + // Add to front 616 + recentUris.unshift(uri); 617 + // Keep only last 5 618 + recentUris = recentUris.slice(0, 5); 619 + 620 + // Set cookie for 30 days 621 + const expires = new Date(); 622 + expires.setDate(expires.getDate() + 30); 623 + const cookieValue = encodeURIComponent(JSON.stringify(recentUris)); 624 + 625 + return new Response("", { 626 + status: 200, 627 + headers: { 628 + "Set-Cookie": `recent_uris=${cookieValue}; Expires=${expires.toUTCString()}; Path=/; SameSite=Lax`, 629 + }, 630 + }); 631 + } catch (error) { 632 + console.error("Error tracking click:", error); 633 + return new Response("", { status: 200 }); 634 + } 396 635 } 397 636 398 637 if (url.pathname === "/search" && req.method === "POST") {