csr tangled client in solid-js
15

Configure Feed

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

add permalink and highlighting to comments, add missing skeleton loading anims on profiles

dawn (May 30, 2026, 7:19 PM +0300) f53af1d1 23eb6740

+442 -172
+30
src/components/repo.tsx
··· 2092 2092 ); 2093 2093 }; 2094 2094 2095 + export const RepoCardSkeleton: Component<{ compact?: boolean; noBorder?: boolean }> = (props) => { 2096 + return ( 2097 + <div 2098 + class={clsx( 2099 + props.compact ? 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' : 'min-h-32', 2100 + 'py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800', 2101 + !props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm' 2102 + )} 2103 + > 2104 + <div class="font-medium dark:text-white flex items-center justify-between"> 2105 + <div class="flex items-center min-w-0 flex-1 mr-2 gap-2"> 2106 + <SkeletonBlock class="w-4 h-4 shrink-0 rounded-sm" /> 2107 + <SkeletonBlock class="h-5 w-1/3" /> 2108 + </div> 2109 + </div> 2110 + 2111 + <div class="space-y-2 mt-2"> 2112 + <SkeletonBlock class="h-4 w-5/6" /> 2113 + <SkeletonBlock class="h-4 w-2/3" /> 2114 + </div> 2115 + 2116 + <div class="flex items-center gap-4 mt-3"> 2117 + <SkeletonBlock class="h-4 w-12" /> 2118 + <SkeletonBlock class="h-4 w-12" /> 2119 + </div> 2120 + </div> 2121 + ); 2122 + }; 2123 + 2124 +
+47
src/index.css
··· 3620 3620 } 3621 3621 } 3622 3622 3623 + .untangled-comment-highlight { 3624 + position: relative; 3625 + background-color: rgb(254 240 138 / 0.15) !important; 3626 + border-radius: 0; 3627 + } 3628 + 3629 + .untangled-comment-highlight::before { 3630 + content: ''; 3631 + position: absolute; 3632 + top: 0; 3633 + bottom: 0; 3634 + left: 0; 3635 + width: 3px; 3636 + background-color: #eab308; 3637 + border-top-left-radius: 0.25rem; 3638 + border-bottom-left-radius: 0.25rem; 3639 + z-index: 1; 3640 + } 3641 + 3642 + /* Align solid bar with the vertical replies thread line */ 3643 + .-ml-4.untangled-comment-highlight::before, 3644 + .-ml-4 > .untangled-comment-highlight::before { 3645 + left: calc(1rem - 2px); 3646 + border-radius: 0; 3647 + } 3648 + 3649 + .-ml-4.untangled-comment-highlight, 3650 + .-ml-4 > .untangled-comment-highlight { 3651 + background-color: transparent !important; 3652 + background-image: linear-gradient(to right, transparent calc(1rem - 2px), rgb(254 240 138 / 0.15) calc(1rem - 2px)) !important; 3653 + } 3654 + 3655 + @media (prefers-color-scheme: dark) { 3656 + .untangled-comment-highlight { 3657 + background-color: rgb(161 98 7 / 0.1) !important; 3658 + } 3659 + .untangled-comment-highlight::before { 3660 + background-color: #ca8a04; 3661 + } 3662 + .-ml-4.untangled-comment-highlight, 3663 + .-ml-4 > .untangled-comment-highlight { 3664 + background-color: transparent !important; 3665 + background-image: linear-gradient(to right, transparent calc(1rem - 2px), rgb(161 98 7 / 0.1) calc(1rem - 2px)) !important; 3666 + } 3667 + } 3668 + 3669 +
+306 -163
src/pages/profile.tsx
··· 22 22 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 23 23 import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; 24 24 import type { Did } from '@atcute/lexicons/syntax'; 25 - import { ErrorState, LoadingState, PlaceholderAvatar, textareaStyles, inputStyles } from '../components/common'; 26 - import { RepoCard } from '../components/repo'; 25 + import { ErrorState, LoadingState, PlaceholderAvatar, SkeletonBlock, textareaStyles, inputStyles } from '../components/common'; 26 + import { RepoCard, RepoCardSkeleton } from '../components/repo'; 27 27 import { 28 28 listFollowRecords, 29 29 createFollow, ··· 48 48 activeTab: string; 49 49 reposCount: number; 50 50 starsCount: number; 51 + reposLoading?: boolean; 52 + starsLoading?: boolean; 51 53 }> = (props) => { 52 54 const tabs = [ 53 55 { id: 'overview', label: 'overview', icon: () => <GalleryVertical class="w-4 h-4 mr-2" /> }, 54 - { id: 'repos', label: 'repos', icon: () => <BookMarked class="w-4 h-4 mr-2" />, count: () => props.reposCount }, 55 - { id: 'starred', label: 'starred', icon: () => <Star class="w-4 h-4 mr-2" />, count: () => props.starsCount }, 56 + { id: 'repos', label: 'repos', icon: () => <BookMarked class="w-4 h-4 mr-2" />, count: () => props.reposCount, loading: () => props.reposLoading }, 57 + { id: 'starred', label: 'starred', icon: () => <Star class="w-4 h-4 mr-2" />, count: () => props.starsCount, loading: () => props.starsLoading }, 56 58 ]; 57 59 58 60 return ( ··· 78 80 {tab.icon()} 79 81 {tab.label} 80 82 {tab.count !== undefined && ( 81 - <span class="bg-gray-200 dark:bg-gray-700 rounded py-0.5 px-1 text-sm ml-1"> 82 - {tab.count()} 83 + <span class="bg-gray-200 dark:bg-gray-700 rounded py-0.5 px-1 text-sm ml-1 min-w-[20px] inline-flex items-center justify-center"> 84 + <Show when={!tab.loading?.()} fallback={<SkeletonBlock class="h-3 w-4" />}> 85 + {tab.count()} 86 + </Show> 83 87 </span> 84 88 )} 85 89 </span> ··· 115 119 openPRCount: number; 116 120 openIssueCount: number; 117 121 closedIssueCount: number; 122 + profileLoading?: boolean; 123 + followersLoading?: boolean; 124 + followingLoading?: boolean; 125 + vouchLoading?: boolean; 118 126 }> = (props) => { 119 127 const userIdent = () => props.actor.handle || props.actor.did; 120 128 ··· 265 273 <p title={userIdent()} class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 266 274 {userIdent()} 267 275 </p> 268 - <Show when={pronouns()}> 269 - <p class="text-gray-500 dark:text-gray-400">{pronouns()}</p> 276 + <Show when={!props.profileLoading} fallback={<SkeletonBlock class="h-4 w-12" />}> 277 + <Show when={pronouns()}> 278 + <p class="text-gray-500 dark:text-gray-400">{pronouns()}</p> 279 + </Show> 270 280 </Show> 271 281 </div> 272 282 ··· 274 284 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 275 285 <span class="flex-shrink-0"><Users class="size-4" /></span> 276 286 <span id="followers"> 277 - <A href="?tab=followers">{props.followersCount} followers</A> 287 + <A href="?tab=followers"> 288 + <Show when={!props.followersLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}> 289 + {props.followersCount} 290 + </Show> 291 + {' '}followers 292 + </A> 278 293 </span> 279 294 <span class="select-none after:content-['·']"></span> 280 295 <span id="following"> 281 - <A href="?tab=following">{props.followingCount} following</A> 296 + <A href="?tab=following"> 297 + <Show when={!props.followingLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}> 298 + {props.followingCount} 299 + </Show> 300 + {' '}following 301 + </A> 282 302 </span> 283 303 </div> 284 304 </div> ··· 291 311 when={isEditing()} 292 312 fallback={ 293 313 <> 294 - <Show when={description()}> 295 - <p class="text-base pb-4 md:pb-2">{description()}</p> 314 + <Show 315 + when={!props.profileLoading} 316 + fallback={ 317 + <div class="space-y-2 pb-4 md:pb-2"> 318 + <SkeletonBlock class="h-4 w-full" /> 319 + <SkeletonBlock class="h-4 w-5/6" /> 320 + </div> 321 + } 322 + > 323 + <Show when={description()}> 324 + <p class="text-base pb-4 md:pb-2">{description()}</p> 325 + </Show> 296 326 </Show> 297 327 298 328 <div class="hidden md:block"> 299 329 <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 300 330 <span class="flex-shrink-0"><Users class="size-4" /></span> 301 331 <span id="followers"> 302 - <A href="?tab=followers">{props.followersCount} followers</A> 332 + <A href="?tab=followers"> 333 + <Show when={!props.followersLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}> 334 + {props.followersCount} 335 + </Show> 336 + {' '}followers 337 + </A> 303 338 </span> 304 339 <span class="select-none after:content-['·']"></span> 305 340 <span id="following"> 306 - <A href="?tab=following">{props.followingCount} following</A> 341 + <A href="?tab=following"> 342 + <Show when={!props.followingLoading} fallback={<SkeletonBlock class="h-4 w-8 inline-block mr-1 align-middle" />}> 343 + {props.followingCount} 344 + </Show> 345 + {' '}following 346 + </A> 307 347 </span> 308 348 </div> 309 349 </div> 310 350 311 - <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 312 - <Show when={location()}> 313 - <div class="flex items-center gap-2"> 314 - <span class="flex-shrink-0"><MapPin class="size-4" /></span> 315 - <span>{location()}</span> 351 + <Show 352 + when={!props.profileLoading} 353 + fallback={ 354 + <div class="flex flex-col gap-2 mb-2"> 355 + <div class="flex items-center gap-2"> 356 + <SkeletonBlock class="size-4 shrink-0 rounded-full" /> 357 + <SkeletonBlock class="h-4 w-24" /> 358 + </div> 359 + <div class="flex items-center gap-2"> 360 + <SkeletonBlock class="size-4 shrink-0 rounded-full" /> 361 + <SkeletonBlock class="h-4 w-36" /> 362 + </div> 316 363 </div> 317 - </Show> 364 + } 365 + > 366 + <div class="flex flex-col gap-2 mb-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 367 + <Show when={location()}> 368 + <div class="flex items-center gap-2"> 369 + <span class="flex-shrink-0"><MapPin class="size-4" /></span> 370 + <span>{location()}</span> 371 + </div> 372 + </Show> 318 373 319 - <Show when={includeBluesky()}> 320 - <div class="flex items-center gap-2"> 321 - <span class="flex-shrink-0"> 322 - <svg class="w-4 h-4 text-black dark:text-white" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30"> 323 - <title>Bluesky</title> 324 - <path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/> 325 - </svg> 326 - </span> 327 - <a id="bluesky-link" href={`https://bsky.app/profile/${props.actor.did}`} target="_blank" rel="noopener noreferrer"> 328 - {props.actor.handle} 329 - </a> 330 - </div> 331 - </Show> 374 + <Show when={includeBluesky()}> 375 + <div class="flex items-center gap-2"> 376 + <span class="flex-shrink-0"> 377 + <svg class="w-4 h-4 text-black dark:text-white" xmlns="http://www.w3.org/2000/svg" role="img" viewBox="-3 -3 30 30"> 378 + <title>Bluesky</title> 379 + <path fill="none" stroke="currentColor" d="M12 10.8c-1.087-2.114-4.046-6.053-6.798-7.995C2.566.944 1.561 1.266.902 1.565.139 1.908 0 3.08 0 3.768c0 .69.378 5.65.624 6.479.815 2.736 3.713 3.66 6.383 3.364.136-.02.275-.039.415-.056-.138.022-.276.04-.415.056-3.912.58-7.387 2.005-2.83 7.078 5.013 5.19 6.87-1.113 7.823-4.308.953 3.195 2.05 9.271 7.733 4.308 4.267-4.308 1.172-6.498-2.74-7.078a8.741 8.741 0 0 1-.415-.056c.14.017.279.036.415.056 2.67.297 5.568-.628 6.383-3.364.246-.828.624-5.79.624-6.478 0-.69-.139-1.861-.902-2.206-.659-.298-1.664-.62-4.3 1.24C16.046 4.748 13.087 8.687 12 10.8Z" stroke-width="2.25"/> 380 + </svg> 381 + </span> 382 + <a id="bluesky-link" href={`https://bsky.app/profile/${props.actor.did}`} target="_blank" rel="noopener noreferrer"> 383 + {props.actor.handle} 384 + </a> 385 + </div> 386 + </Show> 332 387 333 - <For each={websiteLinks()}> 334 - {(link) => ( 335 - <Show when={link}> 336 - <div class="flex items-center gap-2"> 337 - <span class="flex-shrink-0"><Link class="size-4" /></span> 338 - <a rel="nofollow me" href={link} target="_blank" class="truncate"> 339 - {link} 340 - </a> 341 - </div> 342 - </Show> 343 - )} 344 - </For> 345 - </div> 346 - 347 - <Show when={props.profile?.stats && props.profile.stats.length > 0}> 348 - <div class="flex items-center justify-evenly gap-2 py-2"> 349 - <For each={props.profile.stats}> 350 - {(statKind) => ( 351 - <Show when={statKind.length > 0}> 352 - <div class="flex flex-col items-center gap-2"> 353 - <span class="text-xl font-bold">{getStatValue(statKind)}</span> 354 - <span>{formatStatLabel(statKind)}</span> 388 + <For each={websiteLinks()}> 389 + {(link) => ( 390 + <Show when={link}> 391 + <div class="flex items-center gap-2"> 392 + <span class="flex-shrink-0"><Link class="size-4" /></span> 393 + <a rel="nofollow me" href={link} target="_blank" class="truncate"> 394 + {link} 395 + </a> 355 396 </div> 356 397 </Show> 357 398 )} ··· 359 400 </div> 360 401 </Show> 361 402 403 + <Show 404 + when={!props.profileLoading} 405 + fallback={ 406 + <div class="flex items-center justify-evenly gap-2 py-2"> 407 + <div class="flex flex-col items-center gap-2"> 408 + <SkeletonBlock class="h-6 w-8" /> 409 + <SkeletonBlock class="h-4 w-12" /> 410 + </div> 411 + <div class="flex flex-col items-center gap-2"> 412 + <SkeletonBlock class="h-6 w-8" /> 413 + <SkeletonBlock class="h-4 w-12" /> 414 + </div> 415 + </div> 416 + } 417 + > 418 + <Show when={props.profile?.stats && props.profile.stats.length > 0}> 419 + <div class="flex items-center justify-evenly gap-2 py-2"> 420 + <For each={props.profile.stats}> 421 + {(statKind) => ( 422 + <Show when={statKind.length > 0}> 423 + <div class="flex flex-col items-center gap-2"> 424 + <span class="text-xl font-bold">{getStatValue(statKind)}</span> 425 + <span>{formatStatLabel(statKind)}</span> 426 + </div> 427 + </Show> 428 + )} 429 + </For> 430 + </div> 431 + </Show> 432 + </Show> 433 + 362 434 <div class="flex my-2 items-center gap-2"> 363 435 <Show 364 - when={!isSelf()} 365 - fallback={ 436 + when={!props.profileLoading} 437 + fallback={<SkeletonBlock class="h-8 w-full rounded" />} 438 + > 439 + <Show 440 + when={!isSelf()} 441 + fallback={ 442 + <button 443 + type="button" 444 + onClick={startEditing} 445 + class="btn w-full flex gap-2 items-center justify-center group text-sm" 446 + > 447 + <Pencil class="size-4 inline" /> 448 + edit 449 + </button> 450 + } 451 + > 366 452 <button 367 - type="button" 368 - onClick={startEditing} 453 + onClick={handleFollowClick} 454 + disabled={props.actionLoading} 369 455 class="btn w-full flex gap-2 items-center justify-center group text-sm" 370 456 > 371 - <Pencil class="size-4 inline" /> 372 - edit 373 - </button> 374 - } 375 - > 376 - <button 377 - onClick={handleFollowClick} 378 - disabled={props.actionLoading} 379 - class="btn w-full flex gap-2 items-center justify-center group text-sm" 380 - > 381 - <Show 382 - when={props.actionLoading} 383 - fallback={ 384 - <Show 385 - when={props.isFollowing} 386 - fallback={ 457 + <Show 458 + when={props.actionLoading} 459 + fallback={ 460 + <Show 461 + when={props.isFollowing} 462 + fallback={ 463 + <> 464 + <UserRoundPlus class="size-4 inline" /> 465 + follow 466 + </> 467 + } 468 + > 387 469 <> 388 - <UserRoundPlus class="size-4 inline" /> 389 - follow 470 + <UserRoundMinus class="size-4 inline" /> 471 + unfollow 390 472 </> 391 - } 392 - > 393 - <> 394 - <UserRoundMinus class="size-4 inline" /> 395 - unfollow 396 - </> 473 + </Show> 474 + } 475 + > 476 + <LoaderCircle class="size-4 animate-spin inline" /> 477 + <Show when={props.isFollowing} fallback="follow"> 478 + unfollow 397 479 </Show> 398 - } 399 - > 400 - <LoaderCircle class="size-4 animate-spin inline" /> 401 - <Show when={props.isFollowing} fallback="follow"> 402 - unfollow 403 480 </Show> 404 - </Show> 405 - </button> 481 + </button> 482 + </Show> 406 483 </Show> 407 484 408 485 <a ··· 415 492 416 493 <Show when={!isSelf()}> 417 494 <div class="relative w-full mt-2"> 418 - <button 419 - id={`vouch-btn-${popoverId()}`} 420 - type="button" 421 - popovertarget={popoverId()} 422 - popovertargetaction="toggle" 423 - onClick={handleVouchClick} 424 - class={clsx( 425 - 'w-full flex gap-2 items-center justify-center text-sm', 426 - isVouched() ? 'btn-create' : isDenounced() ? 'btn-cancel' : 'btn' 427 - )} 428 - > 429 - <Show 430 - when={isVouched()} 431 - fallback={ 432 - <Show 433 - when={isDenounced()} 434 - fallback={ 495 + <Show when={!props.profileLoading && !props.vouchLoading} fallback={<SkeletonBlock class="h-8 w-full rounded" />}> 496 + <button 497 + id={`vouch-btn-${popoverId()}`} 498 + type="button" 499 + popovertarget={popoverId()} 500 + popovertargetaction="toggle" 501 + onClick={handleVouchClick} 502 + class={clsx( 503 + 'w-full flex gap-2 items-center justify-center text-sm', 504 + isVouched() ? 'btn-create' : isDenounced() ? 'btn-cancel' : 'btn' 505 + )} 506 + > 507 + <Show 508 + when={isVouched()} 509 + fallback={ 510 + <Show 511 + when={isDenounced()} 512 + fallback={ 513 + <> 514 + <Shield class="size-4" /> 515 + vouch 516 + </> 517 + } 518 + > 435 519 <> 436 - <Shield class="size-4" /> 437 - vouch 520 + <ShieldAlert class="size-4" /> 521 + denounced 438 522 </> 439 - } 440 - > 441 - <> 442 - <ShieldAlert class="size-4" /> 443 - denounced 444 - </> 445 - </Show> 446 - } 447 - > 448 - <> 449 - <ShieldCheck class="size-4" /> 450 - vouched 451 - </> 452 - </Show> 453 - </button> 523 + </Show> 524 + } 525 + > 526 + <> 527 + <ShieldCheck class="size-4" /> 528 + vouched 529 + </> 530 + </Show> 531 + </button> 532 + </Show> 454 533 455 534 <div 456 535 id={popoverId()} ··· 747 826 </div> 748 827 ); 749 828 }; 829 + 830 + export const FollowCardSkeleton: Component = () => ( 831 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 832 + <div class="py-4 px-6 drop-shadow-sm bg-white dark:bg-gray-800 flex items-center gap-4"> 833 + <div class="flex-shrink-0 w-24 h-24"> 834 + <SkeletonBlock class="w-24 h-24 rounded-full p-2" /> 835 + </div> 836 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 837 + <div class="flex-1 min-h-0 justify-around flex flex-col space-y-2"> 838 + <SkeletonBlock class="h-5 w-1/3" /> 839 + <SkeletonBlock class="h-4 w-2/3" /> 840 + <div class="flex items-center gap-2"> 841 + <SkeletonBlock class="size-4 shrink-0 rounded-full" /> 842 + <SkeletonBlock class="h-4 w-24" /> 843 + </div> 844 + </div> 845 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 846 + <SkeletonBlock class="h-8 w-24 rounded" /> 847 + </div> 848 + </div> 849 + </div> 850 + </div> 851 + ); 750 852 751 853 const FollowCard: Component<{ 752 854 item: FollowerFollowingItem; ··· 1159 1261 <Match when={actorQuery.data}> 1160 1262 {(resolvedActor) => ( 1161 1263 <Switch> 1162 - <Match when={profileQuery.isLoading}> 1163 - <LoadingState label="Loading profile data..." /> 1164 - </Match> 1165 - <Match when={!profileQuery.data}> 1264 + <Match when={!profileQuery.isLoading && !profileQuery.data}> 1166 1265 {(() => { 1167 1266 const unresolvedAvatarQuery = createQuery(() => ({ 1168 1267 queryKey: ['avatar', resolvedActor().did], ··· 1192 1291 ); 1193 1292 })()} 1194 1293 </Match> 1195 - <Match when={profileQuery.data}> 1294 + <Match when={profileQuery.data || profileQuery.isLoading}> 1196 1295 <div class="flex flex-col"> 1197 1296 <ProfileTabs 1198 1297 activeTab={activeTab()} 1199 1298 reposCount={reposQuery.data?.length ?? 0} 1200 1299 starsCount={starsQuery.data?.length ?? 0} 1300 + reposLoading={reposQuery.isLoading} 1301 + starsLoading={starsQuery.isLoading} 1201 1302 /> 1202 1303 1203 1304 <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm"> ··· 1231 1332 openPRCount={openPRCount()} 1232 1333 openIssueCount={openIssueCount()} 1233 1334 closedIssueCount={closedIssueCount()} 1335 + profileLoading={profileQuery.isLoading} 1336 + followersLoading={followersQuery.isLoading} 1337 + followingLoading={followingQuery.isLoading} 1338 + vouchLoading={vouchQuery.isLoading} 1234 1339 /> 1235 1340 1236 1341 <Show when={error()}> ··· 1256 1361 </A> 1257 1362 </div> 1258 1363 <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 1259 - <For 1260 - each={pinnedRepos()} 1364 + <Show 1365 + when={!reposQuery.isLoading} 1261 1366 fallback={ 1262 - <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1263 - <span>This user does not have any pinned repos.</span> 1264 - </div> 1367 + <> 1368 + <RepoCardSkeleton /> 1369 + <RepoCardSkeleton /> 1370 + <RepoCardSkeleton /> 1371 + </> 1265 1372 } 1266 1373 > 1267 - {(repo) => ( 1268 - <RepoCard 1269 - owner={resolvedActor().handle} 1270 - name={repo.value.name || repo.rkey} 1271 - description={repo.value.description} 1272 - isFork={Boolean(repo.value.source)} 1273 - showOwner={false} 1274 - /> 1275 - )} 1276 - </For> 1374 + <For 1375 + each={pinnedRepos()} 1376 + fallback={ 1377 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1378 + <span>This user does not have any pinned repos.</span> 1379 + </div> 1380 + } 1381 + > 1382 + {(repo) => ( 1383 + <RepoCard 1384 + owner={resolvedActor().handle} 1385 + name={repo.value.name || repo.rkey} 1386 + description={repo.value.description} 1387 + isFork={Boolean(repo.value.source)} 1388 + showOwner={false} 1389 + /> 1390 + )} 1391 + </For> 1392 + </Show> 1277 1393 </div> 1278 1394 </div> 1279 1395 </div> ··· 1324 1440 </div> 1325 1441 1326 1442 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 1327 - <For 1328 - each={filteredRepos()} 1443 + <Show 1444 + when={!reposQuery.isLoading} 1329 1445 fallback={ 1330 - <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1331 - <span>This user does not have any repos yet.</span> 1332 - </div> 1446 + <For each={Array.from({ length: 5 })}> 1447 + {() => <RepoCardSkeleton />} 1448 + </For> 1333 1449 } 1334 1450 > 1335 - {(repo) => ( 1336 - <RepoCard 1337 - owner={resolvedActor().handle} 1338 - name={repo.value.name || repo.rkey} 1339 - description={repo.value.description} 1340 - isFork={Boolean(repo.value.source)} 1341 - showOwner={false} 1342 - /> 1343 - )} 1344 - </For> 1451 + <For 1452 + each={filteredRepos()} 1453 + fallback={ 1454 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1455 + <span>This user does not have any repos yet.</span> 1456 + </div> 1457 + } 1458 + > 1459 + {(repo) => ( 1460 + <RepoCard 1461 + owner={resolvedActor().handle} 1462 + name={repo.value.name || repo.rkey} 1463 + description={repo.value.description} 1464 + isFork={Boolean(repo.value.source)} 1465 + showOwner={false} 1466 + /> 1467 + )} 1468 + </For> 1469 + </Show> 1345 1470 </div> 1346 1471 </div> 1347 1472 </Match> ··· 1351 1476 <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 1352 1477 <Show 1353 1478 when={!starsQuery.isLoading} 1354 - fallback={<LoadingState label="Loading starred repos..." />} 1479 + fallback={ 1480 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 1481 + <For each={Array.from({ length: 4 })}> 1482 + {() => <RepoCardSkeleton />} 1483 + </For> 1484 + </div> 1485 + } 1355 1486 > 1356 1487 <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 1357 1488 <For ··· 1383 1514 <p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all followers</p> 1384 1515 <Show 1385 1516 when={!followersQuery.isLoading} 1386 - fallback={<LoadingState label="Loading followers..." />} 1517 + fallback={ 1518 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 1519 + <For each={Array.from({ length: 3 })}> 1520 + {() => <FollowCardSkeleton />} 1521 + </For> 1522 + </div> 1523 + } 1387 1524 > 1388 1525 <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 1389 1526 <For ··· 1414 1551 <p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all following</p> 1415 1552 <Show 1416 1553 when={!followingQuery.isLoading} 1417 - fallback={<LoadingState label="Loading following list..." />} 1554 + fallback={ 1555 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 1556 + <For each={Array.from({ length: 3 })}> 1557 + {() => <FollowCardSkeleton />} 1558 + </For> 1559 + </div> 1560 + } 1418 1561 > 1419 1562 <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 1420 1563 <For ··· 1443 1586 </section> 1444 1587 </div> 1445 1588 </Match> 1446 - </Switch> 1589 + </Switch> 1447 1590 )} 1448 1591 </Match> 1449 1592 </Switch>
+24 -5
src/pages/repo/issues.tsx
··· 1 1 import clsx from 'clsx'; 2 2 import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid'; 3 - import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 3 + import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import type { ResourceUri } from '@atcute/lexicons/syntax'; 6 6 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js'; ··· 302 302 const repoQuery = useRepoQuery(); 303 303 const params = useParams(); 304 304 const client = useQueryClient(); 305 + const location = useLocation(); 305 306 const issueRef = createMemo(() => params.issueRef ?? ''); 306 307 const [comment, setComment] = createSignal(''); 307 308 const [replyingTo, setReplyingTo] = createSignal<IssueComment | null>(null); ··· 331 332 state: detail.issue.state, 332 333 author: detail.issue.author.handle, 333 334 }); 335 + } 336 + }); 337 + 338 + createEffect(() => { 339 + const hash = location.hash; 340 + const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash; 341 + if (cleanHash && cleanHash.startsWith('comment-') && issueQuery.data?.comments) { 342 + setTimeout(() => { 343 + const element = document.getElementById(cleanHash); 344 + if (element) { 345 + // Remove highlight from any other elements first 346 + document.querySelectorAll('.untangled-comment-highlight').forEach((el) => { 347 + el.classList.remove('untangled-comment-highlight'); 348 + }); 349 + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 350 + element.classList.add('untangled-comment-highlight'); 351 + } 352 + }, 150); 334 353 } 335 354 }); 336 355 ··· 516 535 <For each={commentThreads()}> 517 536 {(thread) => ( 518 537 <div class="overflow-hidden rounded border border-gray-200 bg-gray-50 shadow-sm dark:border-gray-700 dark:bg-gray-800/50"> 519 - <div class="flex gap-2 rounded bg-white px-6 py-4 dark:bg-gray-800"> 538 + <div id={`comment-${thread.item.rkey}`} class="flex gap-2 rounded bg-white px-6 py-4 dark:bg-gray-800"> 520 539 <div class="shrink-0"> 521 540 <Avatar did={thread.item.author.did} size="size-8 mr-1" /> 522 541 </div> ··· 527 546 <span>(author)</span> 528 547 </Show> 529 548 <span class="select-none">·</span> 530 - <span>{formatRelativeTime(thread.item.value.createdAt)}</span> 549 + <a href={`#comment-${thread.item.rkey}`} class="hover:underline">{formatRelativeTime(thread.item.value.createdAt)}</a> 531 550 </div> 532 551 <MarkdownBlock markdown={thread.item.value.body} /> 533 552 </div> ··· 537 556 <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> 538 557 <For each={thread.replies}> 539 558 {(reply) => ( 540 - <div class="-ml-4 flex gap-2 py-4 pr-4"> 559 + <div id={`comment-${reply.rkey}`} class="-ml-4 flex gap-2 py-4 pr-4"> 541 560 <div class="shrink-0 relative z-10"> 542 561 <Avatar did={reply.author.did} size="size-8 mr-1" /> 543 562 </div> ··· 548 567 <span>(author)</span> 549 568 </Show> 550 569 <span class="select-none">·</span> 551 - <span>{formatRelativeTime(reply.value.createdAt)}</span> 570 + <a href={`#comment-${reply.rkey}`} class="hover:underline">{formatRelativeTime(reply.value.createdAt)}</a> 552 571 </div> 553 572 <MarkdownBlock markdown={reply.value.body} /> 554 573 </div>
+31 -2
src/pages/repo/pulls.tsx
··· 19 19 X, 20 20 } from 'lucide-solid'; 21 21 import type { Did } from '@atcute/lexicons/syntax'; 22 - import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router'; 22 + import { A, useNavigate, useParams, useSearchParams, useLocation } from '@solidjs/router'; 23 23 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 24 24 import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js'; 25 25 import { resolveActor } from '../../lib/api/identity'; ··· 1014 1014 createdAt: string; 1015 1015 markdown: string; 1016 1016 isReply?: boolean; 1017 + id?: string; 1017 1018 }> = (props) => ( 1018 1019 <div 1020 + id={props.id} 1019 1021 class={clsx( 1020 1022 'flex gap-2 w-full mx-auto text-sm text-gray-900 dark:text-gray-100 py-4', 1021 1023 props.isReply && 'pr-4' ··· 1028 1030 <div class="flex flex-wrap items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400 mb-0.5"> 1029 1031 <a class="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 font-medium hover:underline" href={`/${props.did}`}>{props.author}</a> 1030 1032 <span class="select-none">·</span> 1031 - <span>{formatRelativeTime(props.createdAt)}</span> 1033 + <Show when={props.id} fallback={<span>{formatRelativeTime(props.createdAt)}</span>}> 1034 + <a href={`#${props.id}`} class="hover:underline">{formatRelativeTime(props.createdAt)}</a> 1035 + </Show> 1032 1036 </div> 1033 1037 <div class="min-w-0 break-words text-gray-800 dark:text-gray-200"> 1034 1038 <MarkdownBlock markdown={props.markdown} /> ··· 1042 1046 const repoQuery = useRepoQuery(); 1043 1047 const params = useParams(); 1044 1048 const client = useQueryClient(); 1049 + const location = useLocation(); 1045 1050 const pullRef = createMemo(() => params.pullRef ?? ''); 1046 1051 const [roundIndex, setRoundIndex] = createSignal(0); 1047 1052 const [comment, setComment] = createSignal(''); ··· 1100 1105 state: detail.pull.state, 1101 1106 author: detail.pull.author.handle, 1102 1107 }); 1108 + } 1109 + }); 1110 + 1111 + createEffect(() => { 1112 + const hash = location.hash; 1113 + const cleanHash = hash.startsWith('#') ? hash.slice(1) : hash; 1114 + if (cleanHash && cleanHash.startsWith('comment-') && pullQuery.data?.comments) { 1115 + setTimeout(() => { 1116 + const element = document.getElementById(cleanHash); 1117 + if (element) { 1118 + const details = element.closest('details'); 1119 + if (details) { 1120 + details.open = true; 1121 + } 1122 + // Remove highlight from any other elements first 1123 + document.querySelectorAll('.untangled-comment-highlight').forEach((el) => { 1124 + el.classList.remove('untangled-comment-highlight'); 1125 + }); 1126 + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); 1127 + element.classList.add('untangled-comment-highlight'); 1128 + } 1129 + }, 150); 1103 1130 } 1104 1131 }); 1105 1132 ··· 1515 1542 {(thread) => ( 1516 1543 <div class="flex flex-col -ml-4"> 1517 1544 <PRCommentView 1545 + id={`comment-${thread.item.rkey}`} 1518 1546 author={thread.item.author.handle} 1519 1547 did={thread.item.author.did} 1520 1548 createdAt={thread.item.value.createdAt} ··· 1526 1554 {(reply) => ( 1527 1555 <div class="-ml-4"> 1528 1556 <PRCommentView 1557 + id={`comment-${reply.rkey}`} 1529 1558 author={reply.author.handle} 1530 1559 did={reply.author.did} 1531 1560 createdAt={reply.value.createdAt}
+4 -2
src/pages/search.tsx
··· 174 174 kind: 'issue' | 'pull'; 175 175 repoDid: Did; 176 176 threadRef: string; 177 + commentRef?: string; 177 178 children: JSX.Element; 178 179 }> = (props) => { 179 180 const repoQuery = createQuery(() => ({ ··· 185 186 const repo = repoQuery.data; 186 187 if (!repo) return undefined; 187 188 const fn = props.kind === 'issue' ? issueHref : pullHref; 188 - return fn(repo, props.threadRef); 189 + const baseHref = fn(repo, props.threadRef); 190 + return props.commentRef ? `${baseHref}#comment-${props.commentRef}` : baseHref; 189 191 }); 190 192 191 193 return ( ··· 240 242 ? (parent().record.value as { repo: Did }).repo 241 243 : (parent().record.value as { target: { repo: Did } }).target.repo); 242 244 return ( 243 - <RepoThreadLink kind={parent().kind} repoDid={repoDid()} threadRef={parent().record.rkey}> 245 + <RepoThreadLink kind={parent().kind} repoDid={repoDid()} threadRef={parent().record.rkey} commentRef={props.hit.rkey}> 244 246 comment on {parent().kind} 245 247 </RepoThreadLink> 246 248 );