csr tangled client in solid-js
15

Configure Feed

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

add user profiles

dawn (May 30, 2026, 1:12 AM +0300) f8a8864b 27bacd74

+1751 -50
+106 -28
src/components/repo.tsx
··· 29 29 import { A } from '@solidjs/router'; 30 30 import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; 31 31 import type { RepoContext, TreeEntry } from '../lib/api/repos'; 32 + import { createQuery } from '@tanstack/solid-query'; 33 + import { getRepo, getRepoLanguages } from '../lib/api/repos'; 34 + import { getRepoStarCount } from '../lib/api/stars'; 35 + import { getRepoIssueCount } from '../lib/api/issues'; 36 + import { getRepoPullCount } from '../lib/api/pulls'; 32 37 import { formatRelativeTime, languageColor } from '../lib/repo-utils'; 33 38 import { getTangledAppviewService } from '../lib/settings'; 34 39 import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common'; ··· 1620 1625 compact?: boolean; 1621 1626 href?: string; 1622 1627 noBorder?: boolean; 1628 + showOwner?: boolean; 1623 1629 } 1624 1630 1625 1631 export const RepoCard: Component<RepoCardProps> = (props) => { 1626 1632 const linkHref = () => props.href || `/${props.owner}/${props.name}`; 1627 - const langColor = () => props.language ? languageColor(props.language) : undefined; 1633 + 1634 + // Define queries for statistics 1635 + const repoContextQuery = createQuery(() => ({ 1636 + queryKey: ['repo-context', props.owner, props.name], 1637 + queryFn: () => getRepo(props.owner, props.name), 1638 + staleTime: 300_000, 1639 + })); 1640 + 1641 + const repoContext = () => repoContextQuery.data; 1642 + 1643 + const starCountQuery = createQuery(() => ({ 1644 + queryKey: ['repo-stars-count', repoContext()?.repoDid], 1645 + queryFn: () => getRepoStarCount(repoContext()!), 1646 + enabled: !!repoContext(), 1647 + staleTime: 300_000, 1648 + })); 1649 + 1650 + const issueCountQuery = createQuery(() => ({ 1651 + queryKey: ['repo-issues-count', repoContext()?.repoDid], 1652 + queryFn: () => getRepoIssueCount(repoContext()!), 1653 + enabled: !!repoContext(), 1654 + staleTime: 300_000, 1655 + })); 1656 + 1657 + const pullCountQuery = createQuery(() => ({ 1658 + queryKey: ['repo-pulls-count', repoContext()?.repoDid], 1659 + queryFn: () => getRepoPullCount(repoContext()!), 1660 + enabled: !!repoContext(), 1661 + staleTime: 300_000, 1662 + })); 1663 + 1664 + const languagesQuery = createQuery(() => ({ 1665 + queryKey: ['repo-languages', repoContext()?.repoDid], 1666 + queryFn: () => getRepoLanguages(repoContext()!, 'HEAD'), 1667 + enabled: !!repoContext(), 1668 + staleTime: 300_000, 1669 + })); 1670 + 1671 + const resolvedLanguage = () => { 1672 + if (props.language !== undefined) return props.language; 1673 + const list = languagesQuery.data?.languages; 1674 + if (!list || list.length === 0) return undefined; 1675 + const sorted = [...list].sort((a, b) => b.percentage - a.percentage); 1676 + return sorted[0].name; 1677 + }; 1678 + 1679 + const langColor = () => { 1680 + const lang = resolvedLanguage(); 1681 + return lang ? languageColor(lang) : undefined; 1682 + }; 1683 + 1684 + const stars = () => props.stars !== undefined ? props.stars : starCountQuery.data; 1685 + const openIssues = () => props.openIssues !== undefined ? props.openIssues : issueCountQuery.data; 1686 + const openPRs = () => props.openPRs !== undefined ? props.openPRs : pullCountQuery.data; 1687 + const forks = () => props.forks; 1688 + 1689 + const isMetricsLoading = () => { 1690 + const langLoading = props.language === undefined && (languagesQuery.isLoading || repoContextQuery.isLoading); 1691 + const starsLoading = props.stars === undefined && (starCountQuery.isLoading || repoContextQuery.isLoading); 1692 + const issuesLoading = props.openIssues === undefined && (issueCountQuery.isLoading || repoContextQuery.isLoading); 1693 + const prsLoading = props.openPRs === undefined && (pullCountQuery.isLoading || repoContextQuery.isLoading); 1694 + return langLoading || starsLoading || issuesLoading || prsLoading; 1695 + }; 1628 1696 1629 1697 return ( 1630 1698 <div 1631 1699 class={clsx( 1632 - 'flex flex-col gap-1 bg-white dark:bg-gray-800 drop-shadow-sm', 1633 - props.compact ? 'p-3 md:p-4' : 'p-4 md:p-6', 1634 - !props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm', 1635 - !props.compact && 'min-h-[8rem]', 1636 - props.compact && 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' 1700 + props.compact ? 'focus-within:bg-gray-100 dark:focus-within:bg-gray-800/80' : 'min-h-32', 1701 + 'py-4 px-6 gap-1 flex flex-col drop-shadow-sm bg-white dark:bg-gray-800', 1702 + !props.noBorder && 'border border-gray-200 dark:border-gray-700 rounded-sm' 1637 1703 )} 1638 1704 > 1639 1705 <div class="font-medium dark:text-white flex items-center justify-between"> 1640 - <div class="flex items-center min-w-0 flex-1 mr-2 text-base"> 1706 + <div class="flex items-center min-w-0 flex-1 mr-2"> 1641 1707 <Show 1642 1708 when={props.isFork} 1643 - fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0 text-gray-400" />} 1709 + fallback={<BookMarked class="w-4 h-4 mr-1.5 shrink-0" />} 1644 1710 > 1645 - <GitFork class="w-4 h-4 mr-1.5 shrink-0 text-gray-400" /> 1711 + <GitFork class="w-4 h-4 mr-1.5 shrink-0" /> 1646 1712 </Show> 1647 1713 <A 1648 1714 href={linkHref()} ··· 1651 1717 props.compact && 'focus:outline-none' 1652 1718 )} 1653 1719 > 1654 - {props.owner}/{props.name} 1720 + <Show when={props.showOwner !== false} fallback={props.name}> 1721 + {props.owner}/{props.name} 1722 + </Show> 1655 1723 </A> 1656 1724 </div> 1657 1725 </div> 1658 1726 1659 1727 <Show when={props.description}> 1660 - <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2 mt-1"> 1728 + <div class="text-gray-600 dark:text-gray-300 text-sm line-clamp-2"> 1661 1729 {props.description} 1662 1730 </div> 1663 1731 </Show> 1664 1732 1665 - <Show when={props.language || props.stars !== undefined || props.forks !== undefined || props.openIssues !== undefined || props.openPRs !== undefined}> 1666 - <div class="text-gray-400 text-xs font-mono inline-flex gap-4 mt-auto pt-2"> 1667 - <Show when={props.language}> 1668 - <div class="flex gap-1.5 items-center"> 1733 + <Show 1734 + when={!isMetricsLoading()} 1735 + fallback={ 1736 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 1737 + <SkeletonBlock class="h-4 w-16" /> 1738 + <SkeletonBlock class="h-4 w-12" /> 1739 + <SkeletonBlock class="h-4 w-12" /> 1740 + <SkeletonBlock class="h-4 w-12" /> 1741 + </div> 1742 + } 1743 + > 1744 + <div class="text-gray-400 text-sm font-mono inline-flex gap-4 mt-auto"> 1745 + <Show when={resolvedLanguage()}> 1746 + <div class="flex gap-2 items-center text-sm"> 1669 1747 <span 1670 1748 class="rounded-full inline-block" 1671 1749 style={{ 'background-color': langColor(), width: '10px', height: '10px' }} 1672 1750 /> 1673 - <span>{props.language}</span> 1751 + <span>{resolvedLanguage()}</span> 1674 1752 </div> 1675 1753 </Show> 1676 - <Show when={props.stars !== undefined && props.stars > 0}> 1677 - <div class="flex gap-1 items-center"> 1754 + <Show when={stars() !== undefined && stars()! > 0}> 1755 + <div class="flex gap-1 items-center text-sm"> 1678 1756 <Star class="w-3 h-3 fill-current" /> 1679 - <span>{props.stars}</span> 1757 + <span>{stars()}</span> 1680 1758 </div> 1681 1759 </Show> 1682 - <Show when={props.forks !== undefined && props.forks > 0}> 1683 - <div class="flex gap-1 items-center"> 1760 + <Show when={forks() !== undefined && forks()! > 0}> 1761 + <div class="flex gap-1 items-center text-sm"> 1684 1762 <GitFork class="w-3 h-3" /> 1685 - <span>{props.forks}</span> 1763 + <span>{forks()}</span> 1686 1764 </div> 1687 1765 </Show> 1688 - <Show when={props.openIssues !== undefined && props.openIssues > 0}> 1689 - <div class="flex gap-1 items-center"> 1766 + <Show when={openIssues() !== undefined && openIssues()! > 0}> 1767 + <div class="flex gap-1 items-center text-sm"> 1690 1768 <CircleDot class="w-3 h-3" /> 1691 - <span>{props.openIssues}</span> 1769 + <span>{openIssues()}</span> 1692 1770 </div> 1693 1771 </Show> 1694 - <Show when={props.openPRs !== undefined && props.openPRs > 0}> 1695 - <div class="flex gap-1 items-center"> 1772 + <Show when={openPRs() !== undefined && openPRs()! > 0}> 1773 + <div class="flex gap-1 items-center text-sm"> 1696 1774 <GitPullRequest class="w-3 h-3" /> 1697 - <span>{props.openPRs}</span> 1775 + <span>{openPRs()}</span> 1698 1776 </div> 1699 1777 </Show> 1700 1778 </div>
+6
src/index.css
··· 3470 3470 color: rgb(244 114 182); 3471 3471 } 3472 3472 } 3473 + 3474 + .untangled-vouch-label { 3475 + display: grid; 3476 + grid-template-columns: auto 1fr auto; 3477 + } 3478 +
+16 -1
src/layout.tsx
··· 1 1 import clsx from 'clsx'; 2 - import { Bell, LogOut, RotateCcw, Save, Search, Settings } from 'lucide-solid'; 2 + import { Bell, LogOut, RotateCcw, Save, Search, Settings, UserRound } from 'lucide-solid'; 3 3 import { A, useLocation, useNavigate } from '@solidjs/router'; 4 4 import { createQuery, useQueryClient } from '@tanstack/solid-query'; 5 5 import { For, Show, createEffect, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js'; ··· 229 229 <div class="font-medium truncate">{viewerQuery.data?.handle ?? auth.currentDid()}</div> 230 230 <div class="text-gray-500 dark:text-gray-400 truncate">{auth.currentDid()}</div> 231 231 </div> 232 + <Show when={viewerQuery.data?.handle}> 233 + {(handle) => ( 234 + <A 235 + href={`/${handle()}`} 236 + class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50 text-black dark:text-white no-underline hover:no-underline border-b border-gray-200 dark:border-gray-700" 237 + onClick={(e) => { 238 + const details = e.currentTarget.closest('details'); 239 + if (details) details.open = false; 240 + }} 241 + > 242 + <UserRound class="size-4" /> 243 + profile 244 + </A> 245 + )} 246 + </Show> 232 247 <button 233 248 type="button" 234 249 class="w-full px-4 py-3 flex items-center gap-2 text-left hover:bg-gray-50 hover:dark:bg-gray-700/50"
+171 -3
src/lib/api/graph.ts
··· 1 1 import { ok } from '@atcute/client'; 2 2 import type { ResolvedActor } from '@atcute/identity-resolver'; 3 - import type { Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 3 + import type { Did, Nsid, ResourceUri } from '@atcute/lexicons/syntax'; 4 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 4 5 import { ShTangledGraphFollow } from '@atcute/tangled'; 5 6 6 7 import { getRpc } from './client'; 7 - import { resolveActor } from './identity'; 8 - import { parseAtUri } from './records'; 8 + import { createAuthRpc, resolveActor, resolveActorForDisplay } from './identity'; 9 + import { parseAtUri, type RecordCreationResult } from './records'; 10 + import { listAllAppviewRecords } from './appview'; 9 11 10 12 const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; 11 13 ··· 48 50 49 51 return records; 50 52 }; 53 + 54 + export const createFollow = async (agent: OAuthUserAgent, followeeDid: Did): Promise<RecordCreationResult> => { 55 + const rpc = createAuthRpc(agent); 56 + const record: ShTangledGraphFollow.Main = { 57 + $type: 'sh.tangled.graph.follow', 58 + subject: followeeDid, 59 + createdAt: new Date().toISOString(), 60 + }; 61 + const result = await ok( 62 + rpc.post('com.atproto.repo.createRecord', { 63 + input: { 64 + repo: agent.sub, 65 + collection: FOLLOW_COLLECTION, 66 + record, 67 + }, 68 + }), 69 + ); 70 + 71 + return { 72 + uri: result.uri, 73 + rkey: parseAtUri(result.uri).rkey, 74 + }; 75 + }; 76 + 77 + export const deleteFollow = async (agent: OAuthUserAgent, rkey: string): Promise<void> => { 78 + const rpc = createAuthRpc(agent); 79 + await ok( 80 + rpc.post('com.atproto.repo.deleteRecord', { 81 + input: { 82 + repo: agent.sub, 83 + collection: FOLLOW_COLLECTION, 84 + rkey, 85 + }, 86 + }), 87 + ); 88 + }; 89 + 90 + export interface FollowerFollowingItem { 91 + did: Did; 92 + handle: string; 93 + actor: ResolvedActor; 94 + followRecord: FollowRecord; 95 + } 96 + 97 + export const getFollowers = async (did: Did): Promise<FollowerFollowingItem[]> => { 98 + const records = await listAllAppviewRecords<ShTangledGraphFollow.Main>('sh.tangled.graph.listFollows', did); 99 + return Promise.all( 100 + records.map(async (record) => { 101 + const parsed = parseAtUri(record.uri); 102 + const actor = await resolveActorForDisplay(parsed.did); 103 + return { 104 + did: parsed.did, 105 + handle: actor.handle, 106 + actor, 107 + followRecord: { 108 + uri: record.uri, 109 + cid: record.cid ?? '', 110 + rkey: parsed.rkey, 111 + value: record.value, 112 + }, 113 + }; 114 + }) 115 + ); 116 + }; 117 + 118 + export const getFollowing = async (did: Did): Promise<FollowerFollowingItem[]> => { 119 + const records = await listAllAppviewRecords<ShTangledGraphFollow.Main>('sh.tangled.graph.listFollowsBy', did); 120 + return Promise.all( 121 + records.map(async (record) => { 122 + const targetDid = record.value.subject as Did; 123 + const actor = await resolveActorForDisplay(targetDid); 124 + const parsed = parseAtUri(record.uri); 125 + return { 126 + did: targetDid, 127 + handle: actor.handle, 128 + actor, 129 + followRecord: { 130 + uri: record.uri, 131 + cid: record.cid ?? '', 132 + rkey: parsed.rkey, 133 + value: record.value, 134 + }, 135 + }; 136 + }) 137 + ); 138 + }; 139 + 140 + const VOUCH_COLLECTION = 'sh.tangled.graph.vouch'; 141 + 142 + export interface VouchRecord { 143 + uri: string; 144 + cid: string; 145 + value: { 146 + $type: 'sh.tangled.graph.vouch'; 147 + createdAt: string; 148 + kind: 'vouch' | 'denounce'; 149 + reason?: string; 150 + evidences?: string[]; 151 + }; 152 + } 153 + 154 + export const getVouchRecord = async ( 155 + viewerDid: Did, 156 + subjectDid: Did 157 + ): Promise<VouchRecord | null> => { 158 + try { 159 + const resolvedViewer = await resolveActor(viewerDid); 160 + const rpc = getRpc(resolvedViewer.pds); 161 + const result = await ok( 162 + rpc.get('com.atproto.repo.getRecord', { 163 + params: { 164 + repo: resolvedViewer.did, 165 + collection: VOUCH_COLLECTION, 166 + rkey: subjectDid, 167 + }, 168 + }) 169 + ); 170 + return result as unknown as VouchRecord; 171 + } catch (e) { 172 + return null; 173 + } 174 + }; 175 + 176 + export const putVouchRecord = async ( 177 + agent: OAuthUserAgent, 178 + subjectDid: Did, 179 + kind: 'vouch' | 'denounce', 180 + reason?: string, 181 + evidences?: string[] 182 + ): Promise<void> => { 183 + const rpc = createAuthRpc(agent); 184 + const record = { 185 + $type: VOUCH_COLLECTION, 186 + createdAt: new Date().toISOString(), 187 + kind, 188 + reason: reason || undefined, 189 + evidences: evidences || undefined, 190 + }; 191 + await ok( 192 + rpc.post('com.atproto.repo.putRecord', { 193 + input: { 194 + repo: agent.sub, 195 + collection: VOUCH_COLLECTION, 196 + rkey: subjectDid, 197 + record, 198 + }, 199 + }) 200 + ); 201 + }; 202 + 203 + export const deleteVouchRecord = async ( 204 + agent: OAuthUserAgent, 205 + subjectDid: Did 206 + ): Promise<void> => { 207 + const rpc = createAuthRpc(agent); 208 + await ok( 209 + rpc.post('com.atproto.repo.deleteRecord', { 210 + input: { 211 + repo: agent.sub, 212 + collection: VOUCH_COLLECTION, 213 + rkey: subjectDid, 214 + }, 215 + }) 216 + ); 217 + }; 218 +
+35
src/lib/api/identity.ts
··· 1 1 import { ok } from '@atcute/client'; 2 + import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; 2 3 import { 3 4 type ActorResolver, 4 5 type ResolveActorOptions, ··· 120 121 return profile.value; 121 122 }; 122 123 124 + export const getActorProfile = async (actor: ResolvedActor): Promise<ShTangledActorProfile.Main | null> => { 125 + try { 126 + return await getActorProfileFromAppview(actor); 127 + } catch (cause) { 128 + if (!(cause instanceof AppviewUnavailableError)) { 129 + throw cause; 130 + } 131 + 132 + return await getOptionalRecord<ShTangledActorProfile.Main>( 133 + actor, 134 + ACTOR_PROFILE_COLLECTION, 135 + 'self', 136 + ); 137 + } 138 + }; 139 + 140 + 123 141 export const resolveAvatarUrl = async (identifier: string): Promise<string | null> => { 124 142 const actor = await resolveActor(identifier); 125 143 let tangledProfile: ShTangledActorProfile.Main | null; ··· 162 180 url.searchParams.set('cid', cid); 163 181 return url.toString(); 164 182 }; 183 + 184 + export const putActorProfile = async ( 185 + agent: OAuthUserAgent, 186 + profile: ShTangledActorProfile.Main 187 + ): Promise<void> => { 188 + const rpc = createAuthRpc(agent); 189 + await ok( 190 + rpc.post('com.atproto.repo.putRecord', { 191 + input: { 192 + repo: agent.sub, 193 + collection: ACTOR_PROFILE_COLLECTION, 194 + rkey: 'self', 195 + record: profile, 196 + }, 197 + }) 198 + ); 199 + };
+16 -14
src/pages/home.tsx
··· 472 472 473 473 <Show 474 474 when={auth.currentDid()} 475 - fallback={<HomeFeedEmpty title="Sign in for repo activity" body="Issues, pull requests, and repositories from your account will appear here." />} 475 + fallback={<HomeFeedEmpty title="Sign in for repo activity" body="Issues, pull requests, and repos from your account will appear here." />} 476 476 > 477 477 <Show 478 478 when={!ownReposQuery.isLoading} ··· 484 484 > 485 485 <Show 486 486 when={activityItems().length > 0} 487 - fallback={<HomeFeedEmpty title="No repo activity yet" body="New repository, issue, and pull request activity will land here." />} 487 + fallback={<HomeFeedEmpty title="No repo activity yet" body="New repo, issue, and pull request activity will land here." />} 488 488 > 489 489 <div class="untangled-home-activity-list"> 490 490 <For each={activityItems()}> ··· 503 503 <HomeSectionHeader icon={<BookMarked class="size-5 text-gray-500" />} title="recent" /> 504 504 <Show 505 505 when={recentRepos().length > 0} 506 - fallback={<HomeEmpty label="No recent repositories." />} 506 + fallback={<HomeEmpty label="No recent repos." />} 507 507 > 508 508 <div class="flex flex-col gap-3"> 509 509 <For each={recentRepos().slice(0, 5)}> ··· 538 538 <A href={item.href} class="untangled-home-repo-row"> 539 539 <div class="text-sm font-semibold text-black dark:text-white truncate"> 540 540 {item.title}{' '} 541 - <span class="text-gray-500 dark:text-gray-400 font-normal">#{item.number}</span> 541 + <span class="text-gray-500 dark:text-gray-400 font-normal">#{item.uri.split('/').pop()}</span> 542 542 </div> 543 543 <div class="mt-2 flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 min-w-0"> 544 544 <StateBadge state={item.state} kind={item.kind} /> ··· 584 584 </aside> 585 585 586 586 <section class="untangled-home-wide-section untangled-home-lower-left"> 587 - <HomeSectionHeader icon={<UserRound class="size-5" />} title="following activity" /> 587 + <HomeSectionHeader icon={<UserRound class="size-5" />} title="timeline" /> 588 588 <Show 589 589 when={auth.currentDid()} 590 - fallback={<HomeEmpty label="Sign in to see followed repository activity." />} 590 + fallback={<HomeEmpty label="Sign in to see followed repo activity." />} 591 591 > 592 592 <Show 593 593 when={!followingQuery.isLoading} 594 - fallback={<LoadingState label="Loading followed repositories..." />} 594 + fallback={<LoadingState label="Loading followed repos..." />} 595 595 > 596 596 <Show 597 597 when={!followingQuery.error} 598 - fallback={<ErrorState message={followingQuery.error instanceof Error ? followingQuery.error.message : 'Failed to load following activity'} />} 598 + fallback={<ErrorState message={followingQuery.error instanceof Error ? followingQuery.error.message : 'Failed to load timeline'} />} 599 599 > 600 600 <Show 601 601 when={(followingQuery.data?.activity.length ?? 0) > 0} 602 - fallback={<HomeEmpty label="No followed repository activity yet." />} 602 + fallback={<HomeEmpty label="No followed repo activity yet." />} 603 603 > 604 604 <div class="untangled-home-activity-list"> 605 605 <For each={followingQuery.data?.activity ?? []}> ··· 666 666 }; 667 667 return ( 668 668 <div class="flex items-center gap-3"> 669 - <Avatar did={suggestion.did} size="size-10" /> 669 + <A href={`/${suggestion.handle}`}> 670 + <Avatar did={suggestion.did} size="size-10" /> 671 + </A> 670 672 <div class="flex flex-col min-w-0 flex-1"> 671 - <a href={`https://tangled.org/${suggestion.handle}`} target="_blank" rel="noopener noreferrer" class="text-sm font-medium dark:text-white hover:underline truncate"> 673 + <A href={`/${suggestion.handle}`} class="text-sm font-medium dark:text-white hover:underline truncate"> 672 674 {suggestion.handle} 673 - </a> 675 + </A> 674 676 <span class="text-xs text-gray-500 dark:text-gray-400 truncate"> 675 677 &nbsp; 676 678 </span> ··· 724 726 <div class="untangled-home-feed-empty"> 725 727 <LoaderCircle class="size-8 animate-spin" /> 726 728 <h3>Loading activity</h3> 727 - <p>Checking your repositories.</p> 729 + <p>Checking your repos.</p> 728 730 </div> 729 731 ); 730 732 ··· 733 735 const actorUrl = () => props.item.actorHandle ? `/${props.item.actorHandle}` : '#'; 734 736 735 737 const actionText = () => { 736 - if (props.item.kind === 'repo') return 'published repository'; 738 + if (props.item.kind === 'repo') return 'published repo'; 737 739 if (props.item.kind === 'issue') return 'created issue in'; 738 740 if (props.item.kind === 'pull') return 'opened pull request in'; 739 741 return 'updated';
+1389
src/pages/profile.tsx
··· 1 + import clsx from 'clsx'; 2 + import { 3 + BookMarked, 4 + Link, 5 + LoaderCircle, 6 + MapPin, 7 + Rss, 8 + Star, 9 + UserRoundPlus, 10 + UserRoundMinus, 11 + Users, 12 + GalleryVertical, 13 + Shield, 14 + ShieldCheck, 15 + ShieldAlert, 16 + ArrowRight, 17 + X, 18 + Check, 19 + Pencil, 20 + } from 'lucide-solid'; 21 + import { A, useParams, useSearchParams } from '@solidjs/router'; 22 + import { createQuery, useQueryClient } from '@tanstack/solid-query'; 23 + import { For, Show, Switch, Match, createMemo, createSignal, createEffect, type Component } from 'solid-js'; 24 + import type { Did } from '@atcute/lexicons/syntax'; 25 + import { ErrorState, LoadingState, PlaceholderAvatar, textareaStyles, inputStyles } from '../components/common'; 26 + import { RepoCard } from '../components/repo'; 27 + import { 28 + listFollowRecords, 29 + createFollow, 30 + deleteFollow, 31 + getFollowers, 32 + getFollowing, 33 + getVouchRecord, 34 + putVouchRecord, 35 + deleteVouchRecord, 36 + type FollowerFollowingItem, 37 + type FollowRecord, 38 + type VouchRecord, 39 + } from '../lib/api/graph'; 40 + import { resolveActor, getActorProfile, resolveAvatarUrl, putActorProfile } from '../lib/api/identity'; 41 + import { listRepoRecords, getRepoByDid, type RepoContext } from '../lib/api/repos'; 42 + import { useAuth } from '../lib/auth'; 43 + import { listAllAppviewRecords } from '../lib/api/appview'; 44 + import { formatRelativeTime } from '../lib/repo-utils'; 45 + import { ShTangledFeedStar, ShTangledActorProfile } from '@atcute/tangled'; 46 + 47 + const ProfileTabs: Component<{ 48 + activeTab: string; 49 + reposCount: number; 50 + starsCount: number; 51 + }> = (props) => { 52 + const tabs = [ 53 + { 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 + ]; 57 + 58 + return ( 59 + <nav class="w-full pl-4 overflow-x-auto overflow-y-hidden"> 60 + <div class="flex z-60"> 61 + <For each={tabs}> 62 + {(tab) => { 63 + const isActive = () => props.activeTab === tab.id; 64 + return ( 65 + <A 66 + href={`?tab=${tab.id}`} 67 + class="relative -mr-px group no-underline hover:no-underline" 68 + > 69 + <div 70 + class={clsx( 71 + 'px-4 py-1 mr-1 text-black dark:text-white text-base min-w-[80px] text-center relative rounded-t whitespace-nowrap', 72 + isActive() 73 + ? '-mb-px bg-white dark:bg-gray-800 font-medium' 74 + : 'group-hover:bg-gray-100/25 group-hover:dark:bg-gray-700/25' 75 + )} 76 + > 77 + <span class="flex items-center justify-center"> 78 + {tab.icon()} 79 + {tab.label} 80 + {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> 84 + )} 85 + </span> 86 + </div> 87 + </A> 88 + ); 89 + }} 90 + </For> 91 + </div> 92 + </nav> 93 + ); 94 + }; 95 + 96 + const ProfileCardComponent: Component<{ 97 + actor: any; 98 + profile?: any; 99 + followersCount: number; 100 + followingCount: number; 101 + loggedInUserDid?: string; 102 + isFollowing: boolean; 103 + onFollowToggle: () => void; 104 + actionLoading: boolean; 105 + onTabChange: (tab: string) => void; 106 + vouchRecord?: VouchRecord | null; 107 + onVouchSubmit: (kind: 'vouch' | 'denounce' | 'none', reason?: string) => Promise<void>; 108 + vouchActionLoading: boolean; 109 + onProfileUpdate: (profile: ShTangledActorProfile.Main) => Promise<void>; 110 + profileUpdateLoading: boolean; 111 + reposCount: number; 112 + starsCount: number; 113 + mergedPRCount: number; 114 + closedPRCount: number; 115 + openPRCount: number; 116 + openIssueCount: number; 117 + closedIssueCount: number; 118 + }> = (props) => { 119 + const userIdent = () => props.actor.handle || props.actor.did; 120 + 121 + const pronouns = () => props.profile?.pronouns; 122 + const description = () => props.profile?.description; 123 + const location = () => props.profile?.location; 124 + const websiteLinks = () => props.profile?.links ?? []; 125 + const includeBluesky = () => props.profile?.bluesky; 126 + 127 + const isSelf = () => props.loggedInUserDid === props.actor.did; 128 + 129 + const [kind, setKind] = createSignal<'vouch' | 'denounce' | 'none'>('none'); 130 + const [reason, setReason] = createSignal(''); 131 + let popoverRef: HTMLDivElement | undefined; 132 + 133 + createEffect(() => { 134 + const record = props.vouchRecord; 135 + if (record) { 136 + setKind(record.value.kind); 137 + setReason(record.value.reason || ''); 138 + } else { 139 + setKind('none'); 140 + setReason(''); 141 + } 142 + }); 143 + 144 + const popoverId = () => `vouch-modal-${props.actor.did.replace(/[^a-zA-Z0-9]/g, '-')}`; 145 + 146 + const isVouched = () => props.vouchRecord?.value.kind === 'vouch'; 147 + const isDenounced = () => props.vouchRecord?.value.kind === 'denounce'; 148 + 149 + const handleFollowClick = () => { 150 + if (!props.loggedInUserDid) { 151 + alert('Please sign in to follow users.'); 152 + return; 153 + } 154 + props.onFollowToggle(); 155 + }; 156 + 157 + const handleVouchClick = (e: MouseEvent) => { 158 + if (!props.loggedInUserDid) { 159 + e.preventDefault(); 160 + alert('Please sign in to vouch for users.'); 161 + } 162 + }; 163 + 164 + const [isEditing, setIsEditing] = createSignal(false); 165 + const [editBioText, setEditBioText] = createSignal(''); 166 + const [editPronounsText, setEditPronounsText] = createSignal(''); 167 + const [editLocationText, setEditLocationText] = createSignal(''); 168 + const [editBluesky, setEditBluesky] = createSignal(false); 169 + const [editLinks, setEditLinks] = createSignal<string[]>(['', '', '', '', '']); 170 + const [editStat1, setEditStat1] = createSignal(''); 171 + const [editStat2, setEditStat2] = createSignal(''); 172 + 173 + const startEditing = () => { 174 + const p = props.profile; 175 + setEditBioText(p?.description || ''); 176 + setEditPronounsText(p?.pronouns || ''); 177 + setEditLocationText(p?.location || ''); 178 + setEditBluesky(!!p?.bluesky); 179 + 180 + const existingLinks = p?.links || []; 181 + const newLinks = ['', '', '', '', '']; 182 + for (let i = 0; i < 5; i++) { 183 + newLinks[i] = existingLinks[i] || ''; 184 + } 185 + setEditLinks(newLinks); 186 + 187 + const existingStats = p?.stats || []; 188 + setEditStat1(existingStats[0] || ''); 189 + setEditStat2(existingStats[1] || ''); 190 + 191 + setIsEditing(true); 192 + }; 193 + 194 + const handleSubmitProfile = async (e: SubmitEvent) => { 195 + e.preventDefault(); 196 + const finalLinks = editLinks().map(l => l.trim()).filter(Boolean); 197 + const finalStats = [editStat1(), editStat2()].filter(Boolean); 198 + 199 + const updatedProfile: ShTangledActorProfile.Main = { 200 + $type: 'sh.tangled.actor.profile', 201 + ...props.profile, 202 + description: editBioText().trim() || undefined, 203 + pronouns: editPronounsText().trim() || undefined, 204 + location: editLocationText().trim() || undefined, 205 + bluesky: editBluesky(), 206 + links: finalLinks.length > 0 ? finalLinks : undefined, 207 + stats: finalStats.length > 0 ? finalStats : undefined, 208 + }; 209 + 210 + await props.onProfileUpdate(updatedProfile); 211 + setIsEditing(false); 212 + }; 213 + 214 + const getStatValue = (kind: string) => { 215 + if (kind === 'repository-count') return props.reposCount; 216 + if (kind === 'star-count') return props.starsCount; 217 + if (kind === 'merged-pull-request-count') return props.mergedPRCount; 218 + if (kind === 'closed-pull-request-count') return props.closedPRCount; 219 + if (kind === 'open-pull-request-count') return props.openPRCount; 220 + if (kind === 'open-issue-count') return props.openIssueCount; 221 + if (kind === 'closed-issue-count') return props.closedIssueCount; 222 + return 0; 223 + }; 224 + 225 + const formatStatLabel = (kind: string) => { 226 + const labels: Record<string, string> = { 227 + 'merged-pull-request-count': 'merged prs', 228 + 'closed-pull-request-count': 'closed prs', 229 + 'open-pull-request-count': 'open prs', 230 + 'open-issue-count': 'open issues', 231 + 'closed-issue-count': 'closed issues', 232 + 'repository-count': 'repos', 233 + 'star-count': 'stars received', 234 + }; 235 + return labels[kind] || kind; 236 + }; 237 + 238 + const avatarQuery = createQuery(() => ({ 239 + queryKey: ['avatar', props.actor.did], 240 + queryFn: async () => resolveAvatarUrl(props.actor.did), 241 + staleTime: 300_000, 242 + })); 243 + 244 + return ( 245 + <div class="grid grid-cols-3 md:grid-cols-1 gap-1 items-center"> 246 + {/* Avatar */} 247 + <div id="avatar" class="col-span-1 flex justify-center items-center"> 248 + <div class="w-3/4 aspect-square relative"> 249 + <Show 250 + when={avatarQuery.data} 251 + fallback={<PlaceholderAvatar size="absolute inset-0 w-full h-full p-2" iconSize="size-1/2" />} 252 + > 253 + <img 254 + class="absolute inset-0 w-full h-full object-cover rounded-full p-2" 255 + src={avatarQuery.data!} 256 + alt={userIdent()} 257 + /> 258 + </Show> 259 + </div> 260 + </div> 261 + 262 + {/* Details */} 263 + <div class="col-span-2"> 264 + <div class="flex items-center flex-row flex-nowrap gap-2"> 265 + <p title={userIdent()} class="text-lg font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"> 266 + {userIdent()} 267 + </p> 268 + <Show when={pronouns()}> 269 + <p class="text-gray-500 dark:text-gray-400">{pronouns()}</p> 270 + </Show> 271 + </div> 272 + 273 + <div class="md:hidden"> 274 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 275 + <span class="flex-shrink-0"><Users class="size-4" /></span> 276 + <span id="followers"> 277 + <A href="?tab=followers">{props.followersCount} followers</A> 278 + </span> 279 + <span class="select-none after:content-['·']"></span> 280 + <span id="following"> 281 + <A href="?tab=following">{props.followingCount} following</A> 282 + </span> 283 + </div> 284 + </div> 285 + </div> 286 + 287 + {/* Bio and links */} 288 + <div class="col-span-3 md:col-span-full"> 289 + <div id="profile-bio" class="text-sm space-y-2"> 290 + <Show 291 + when={isEditing()} 292 + fallback={ 293 + <> 294 + <Show when={description()}> 295 + <p class="text-base pb-4 md:pb-2">{description()}</p> 296 + </Show> 297 + 298 + <div class="hidden md:block"> 299 + <div class="flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full text-sm"> 300 + <span class="flex-shrink-0"><Users class="size-4" /></span> 301 + <span id="followers"> 302 + <A href="?tab=followers">{props.followersCount} followers</A> 303 + </span> 304 + <span class="select-none after:content-['·']"></span> 305 + <span id="following"> 306 + <A href="?tab=following">{props.followingCount} following</A> 307 + </span> 308 + </div> 309 + </div> 310 + 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> 316 + </div> 317 + </Show> 318 + 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> 332 + 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 + <div class="flex flex-col items-center gap-2"> 352 + <span class="text-xl font-bold">{getStatValue(statKind)}</span> 353 + <span>{formatStatLabel(statKind)}</span> 354 + </div> 355 + )} 356 + </For> 357 + </div> 358 + </Show> 359 + 360 + <div class="flex my-2 items-center gap-2"> 361 + <Show 362 + when={!isSelf()} 363 + fallback={ 364 + <button 365 + type="button" 366 + onClick={startEditing} 367 + class="btn w-full flex gap-2 items-center justify-center group text-sm" 368 + > 369 + <Pencil class="size-4 inline" /> 370 + edit 371 + </button> 372 + } 373 + > 374 + <button 375 + onClick={handleFollowClick} 376 + disabled={props.actionLoading} 377 + class="btn w-full flex gap-2 items-center justify-center group text-sm" 378 + > 379 + <Show 380 + when={props.actionLoading} 381 + fallback={ 382 + <Show 383 + when={props.isFollowing} 384 + fallback={ 385 + <> 386 + <UserRoundPlus class="size-4 inline" /> 387 + follow 388 + </> 389 + } 390 + > 391 + <> 392 + <UserRoundMinus class="size-4 inline" /> 393 + unfollow 394 + </> 395 + </Show> 396 + } 397 + > 398 + <LoaderCircle class="size-4 animate-spin inline" /> 399 + <Show when={props.isFollowing} fallback="follow"> 400 + unfollow 401 + </Show> 402 + </Show> 403 + </button> 404 + </Show> 405 + 406 + <a 407 + class="btn text-sm no-underline hover:no-underline flex items-center gap-2 group" 408 + href={`/${userIdent()}/feed.atom`} 409 + > 410 + <Rss class="size-4" /> 411 + </a> 412 + </div> 413 + 414 + <Show when={!isSelf()}> 415 + <div class="relative w-full mt-2"> 416 + <button 417 + id={`vouch-btn-${popoverId()}`} 418 + type="button" 419 + popovertarget={popoverId()} 420 + popovertargetaction="toggle" 421 + onClick={handleVouchClick} 422 + class={clsx( 423 + 'w-full flex gap-2 items-center justify-center text-sm', 424 + isVouched() ? 'btn-create' : isDenounced() ? 'btn-cancel' : 'btn' 425 + )} 426 + > 427 + <Show 428 + when={isVouched()} 429 + fallback={ 430 + <Show 431 + when={isDenounced()} 432 + fallback={ 433 + <> 434 + <Shield class="size-4" /> 435 + vouch 436 + </> 437 + } 438 + > 439 + <> 440 + <ShieldAlert class="size-4" /> 441 + denounced 442 + </> 443 + </Show> 444 + } 445 + > 446 + <> 447 + <ShieldCheck class="size-4" /> 448 + vouched 449 + </> 450 + </Show> 451 + </button> 452 + 453 + <div 454 + id={popoverId()} 455 + popover="auto" 456 + ref={popoverRef} 457 + class="bg-white w-[95%] md:w-96 dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 drop-shadow dark:text-white backdrop:bg-gray-400/50 dark:backdrop:bg-gray-800/50 space-y-2" 458 + > 459 + <form 460 + onSubmit={async (e) => { 461 + e.preventDefault(); 462 + await props.onVouchSubmit(kind(), kind() === 'none' ? undefined : reason()); 463 + popoverRef?.hidePopover(); 464 + }} 465 + class="flex flex-col gap-6 group" 466 + > 467 + <div> 468 + <p class="font-semibold mb-1 text-base">Vouch for {userIdent()}</p> 469 + <p class="text-gray-600 dark:text-gray-400 text-sm"> 470 + <Show 471 + when={props.vouchRecord} 472 + fallback={ 473 + <> 474 + Vouching builds a web-of-trust across Tangled. Vouch for users you 475 + have had positive interactions with.{' '} 476 + <a href="https://blog.tangled.org/vouching" class="inline-flex items-center gap-1" target="_blank" rel="noopener noreferrer"> 477 + Read more <ArrowRight class="size-3" /> 478 + </a> 479 + </> 480 + } 481 + > 482 + {(record) => ( 483 + <> 484 + You {record().value.kind === 'vouch' ? 'vouched' : 'denounced'} {userIdent()}{' '} 485 + {formatRelativeTime(record().value.createdAt)}. 486 + You can change your decision below. 487 + </> 488 + )} 489 + </Show> 490 + </p> 491 + </div> 492 + 493 + <div class="grid grid-cols-3 gap-2"> 494 + <input 495 + id={`vouch-input-${popoverId()}`} 496 + type="radio" 497 + name="kind" 498 + value="vouch" 499 + checked={kind() === 'vouch'} 500 + onChange={() => setKind('vouch')} 501 + class="peer/vouch hidden appearance-none" 502 + /> 503 + <input 504 + id={`denounce-input-${popoverId()}`} 505 + type="radio" 506 + name="kind" 507 + value="denounce" 508 + checked={kind() === 'denounce'} 509 + onChange={() => setKind('denounce')} 510 + class="peer/denounce hidden appearance-none" 511 + /> 512 + <input 513 + id={`none-input-${popoverId()}`} 514 + type="radio" 515 + name="kind" 516 + value="none" 517 + checked={kind() === 'none'} 518 + onChange={() => setKind('none')} 519 + class="peer/none hidden appearance-none" 520 + /> 521 + 522 + <label 523 + for={`vouch-input-${popoverId()}`} 524 + class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/vouch:bg-green-200 peer-checked/vouch:text-green-800 peer-checked/vouch:ring-green-400 dark:hover:bg-white/5 dark:peer-checked/vouch:bg-green-700 dark:peer-checked/vouch:text-green-200 dark:peer-checked/vouch:ring-green-600" 525 + > 526 + <ShieldCheck class="size-5" /> 527 + <span class="text-sm font-medium">vouch</span> 528 + </label> 529 + <label 530 + for={`denounce-input-${popoverId()}`} 531 + class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/denounce:bg-red-200 peer-checked/denounce:text-red-800 peer-checked/denounce:ring-red-400 dark:hover:bg-white/5 dark:peer-checked/denounce:bg-red-700 dark:peer-checked/denounce:text-red-200 dark:peer-checked/denounce:ring-red-600" 532 + > 533 + <ShieldAlert class="size-5" /> 534 + <span class="text-sm font-medium">denounce</span> 535 + </label> 536 + <label 537 + for={`none-input-${popoverId()}`} 538 + class="untangled-vouch-label items-center gap-2 rounded p-2 ring-1 ring-gray-200 dark:ring-gray-700 cursor-pointer hover:bg-gray-100 peer-checked/none:bg-gray-200 peer-checked/none:text-gray-800 peer-checked/none:ring-gray-400 dark:hover:bg-white/5 dark:peer-checked/none:bg-gray-700 dark:peer-checked/none:text-gray-200 dark:peer-checked/none:ring-gray-600" 539 + > 540 + <Shield class="size-5 opacity-50" /> 541 + <span class="text-sm font-medium">none</span> 542 + </label> 543 + 544 + <textarea 545 + id={`reason-${popoverId()}`} 546 + name="reason" 547 + rows="1" 548 + maxLength="300" 549 + placeholder="Write a reason..." 550 + value={reason()} 551 + onInput={(e) => setReason(e.currentTarget.value)} 552 + class={clsx(textareaStyles(), 'resize-none col-span-3 peer-checked/none:hidden', kind() === 'none' && 'hidden')} 553 + /> 554 + </div> 555 + 556 + <div class="flex gap-2"> 557 + <button 558 + type="button" 559 + onClick={() => { 560 + const record = props.vouchRecord; 561 + if (record) { 562 + setKind(record.value.kind); 563 + setReason(record.value.reason || ''); 564 + } else { 565 + setKind('none'); 566 + setReason(''); 567 + } 568 + popoverRef?.hidePopover(); 569 + }} 570 + class="btn flex-1 flex items-center justify-center gap-2" 571 + > 572 + <X class="size-4" /> 573 + cancel 574 + </button> 575 + <button 576 + type="submit" 577 + disabled={props.vouchActionLoading} 578 + class="btn-create flex-1 flex items-center justify-center gap-2 text-white group" 579 + > 580 + <Show 581 + when={props.vouchActionLoading} 582 + fallback={ 583 + <> 584 + <Check class="size-4 inline" /> 585 + {props.vouchRecord ? 'update' : 'save'} 586 + </> 587 + } 588 + > 589 + <> 590 + <LoaderCircle class="size-4 animate-spin inline" /> 591 + {props.vouchRecord ? 'update' : 'save'} 592 + </> 593 + </Show> 594 + </button> 595 + </div> 596 + </form> 597 + </div> 598 + </div> 599 + </Show> 600 + </> 601 + } 602 + > 603 + {/* EDIT FORM */} 604 + <form onSubmit={handleSubmitProfile} class="flex flex-col gap-4 my-2 max-w-full"> 605 + <div class="flex flex-col gap-1"> 606 + <label class="m-0 p-0 text-sm font-semibold" for="description">bio</label> 607 + <textarea 608 + id="description" 609 + class={clsx(textareaStyles(), 'w-full p-2')} 610 + rows="3" 611 + placeholder="write a bio" 612 + value={editBioText()} 613 + onInput={(e) => setEditBioText(e.currentTarget.value)} 614 + /> 615 + </div> 616 + 617 + <div class="flex flex-col gap-1"> 618 + <label class="m-0 p-0 text-sm font-semibold" for="pronouns">pronouns</label> 619 + <input 620 + id="pronouns" 621 + type="text" 622 + class={clsx(inputStyles(), 'w-full p-2')} 623 + placeholder="they/them" 624 + value={editPronounsText()} 625 + onInput={(e) => setEditPronounsText(e.currentTarget.value)} 626 + /> 627 + </div> 628 + 629 + <div class="flex flex-col gap-1"> 630 + <label class="m-0 p-0 text-sm font-semibold" for="location">location</label> 631 + <div class="flex items-center gap-2 w-full"> 632 + <span class="flex-shrink-0"><MapPin class="size-4" /></span> 633 + <input 634 + id="location" 635 + type="text" 636 + class={clsx(inputStyles(), 'w-full p-2')} 637 + value={editLocationText()} 638 + onInput={(e) => setEditLocationText(e.currentTarget.value)} 639 + /> 640 + </div> 641 + </div> 642 + 643 + <div class="flex flex-col gap-1"> 644 + <label class="m-0 p-0 text-sm font-semibold">social links</label> 645 + <div class="flex items-center gap-2 py-1"> 646 + <input 647 + type="checkbox" 648 + id="includeBluesky" 649 + checked={editBluesky()} 650 + onChange={(e) => setEditBluesky(e.currentTarget.checked)} 651 + class="mr-2" 652 + /> 653 + <label for="includeBluesky" class="my-0 py-0 normal-case font-normal text-sm">link to bluesky account</label> 654 + </div> 655 + 656 + <For each={editLinks()}> 657 + {(link, index) => ( 658 + <div class="flex items-center gap-2 w-full"> 659 + <span class="flex-shrink-0"><Link class="size-4" /></span> 660 + <input 661 + type="text" 662 + class={clsx(inputStyles(), 'w-full p-2')} 663 + value={link} 664 + placeholder={`social link ${index() + 1}`} 665 + onInput={(e) => { 666 + const next = [...editLinks()]; 667 + next[index()] = e.currentTarget.value; 668 + setEditLinks(next); 669 + }} 670 + /> 671 + </div> 672 + )} 673 + </For> 674 + </div> 675 + 676 + <div class="flex flex-col gap-1"> 677 + <label class="m-0 p-0 text-sm font-semibold">vanity stats</label> 678 + <select 679 + value={editStat1()} 680 + onChange={(e) => setEditStat1(e.currentTarget.value)} 681 + class="w-full p-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white text-sm" 682 + > 683 + <option value="">choose stat 1</option> 684 + <option value="merged-pull-request-count">merged pr count</option> 685 + <option value="closed-pull-request-count">closed pr count</option> 686 + <option value="open-pull-request-count">open pr count</option> 687 + <option value="open-issue-count">open issue count</option> 688 + <option value="closed-issue-count">closed issue count</option> 689 + <option value="repository-count">repository count</option> 690 + <option value="star-count">star count</option> 691 + </select> 692 + 693 + <select 694 + value={editStat2()} 695 + onChange={(e) => setEditStat2(e.currentTarget.value)} 696 + class="w-full p-2 mt-2 rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-800 dark:text-white text-sm" 697 + > 698 + <option value="">choose stat 2</option> 699 + <option value="merged-pull-request-count">merged pr count</option> 700 + <option value="closed-pull-request-count">closed pr count</option> 701 + <option value="open-pull-request-count">open pr count</option> 702 + <option value="open-issue-count">open issue count</option> 703 + <option value="closed-issue-count">closed issue count</option> 704 + <option value="repository-count">repository count</option> 705 + <option value="star-count">star count</option> 706 + </select> 707 + </div> 708 + 709 + <div class="flex items-center gap-2 justify-between"> 710 + <button 711 + id="save-btn" 712 + type="submit" 713 + disabled={props.profileUpdateLoading} 714 + class="btn-create p-1 w-full flex items-center justify-center gap-2 text-white text-sm" 715 + > 716 + <Show 717 + when={props.profileUpdateLoading} 718 + fallback={ 719 + <> 720 + <Check class="size-4" /> 721 + save 722 + </> 723 + } 724 + > 725 + <> 726 + <LoaderCircle class="w-4 h-4 animate-spin" /> 727 + save 728 + </> 729 + </Show> 730 + </button> 731 + <button 732 + id="cancel-btn" 733 + type="button" 734 + onClick={() => setIsEditing(false)} 735 + class="btn p-1 w-full flex items-center justify-center gap-2 text-sm" 736 + > 737 + <X class="size-4" /> 738 + cancel 739 + </button> 740 + </div> 741 + </form> 742 + </Show> 743 + </div> 744 + </div> 745 + </div> 746 + ); 747 + }; 748 + 749 + const FollowCard: Component<{ 750 + item: FollowerFollowingItem; 751 + loggedInUserDid?: string; 752 + viewerFollows?: FollowRecord[]; 753 + onFollowToggle?: (did: Did) => void; 754 + }> = (props) => { 755 + const userIdent = () => props.item.handle || props.item.did; 756 + 757 + const profileQuery = createQuery(() => ({ 758 + queryKey: ['profile-record', props.item.did], 759 + queryFn: () => getActorProfile(props.item.actor), 760 + staleTime: 300_000, 761 + })); 762 + 763 + const followersQuery = createQuery(() => ({ 764 + queryKey: ['profile-followers', props.item.did], 765 + queryFn: () => getFollowers(props.item.did), 766 + staleTime: 300_000, 767 + })); 768 + 769 + const followingQuery = createQuery(() => ({ 770 + queryKey: ['profile-following', props.item.did], 771 + queryFn: () => getFollowing(props.item.did), 772 + staleTime: 300_000, 773 + })); 774 + 775 + const isFollowing = () => { 776 + const list = props.viewerFollows; 777 + if (!list) return false; 778 + return list.some(follow => follow.value.subject === props.item.did); 779 + }; 780 + 781 + const isSelf = () => props.loggedInUserDid === props.item.did; 782 + 783 + const avatarQuery = createQuery(() => ({ 784 + queryKey: ['avatar', props.item.did], 785 + queryFn: async () => resolveAvatarUrl(props.item.did), 786 + staleTime: 300_000, 787 + })); 788 + 789 + return ( 790 + <div class="flex flex-col divide-y divide-gray-200 dark:divide-gray-700"> 791 + <div class="py-4 px-6 drop-shadow-sm bg-white dark:bg-gray-800 flex items-center gap-4"> 792 + <div class="flex-shrink-0 max-h-full w-24 h-24"> 793 + <Show 794 + when={avatarQuery.data} 795 + fallback={<PlaceholderAvatar size="w-24 h-24 p-2" iconSize="size-10" />} 796 + > 797 + <img 798 + class="object-cover rounded-full p-2 w-24 h-24" 799 + src={avatarQuery.data!} 800 + alt={userIdent()} 801 + /> 802 + </Show> 803 + </div> 804 + 805 + <div class="flex flex-col md:flex-row md:items-center md:justify-between gap-2 w-full min-w-0"> 806 + <div class="flex-1 min-h-0 justify-around flex flex-col"> 807 + <A href={`/${userIdent()}`}> 808 + <span class="font-bold dark:text-white overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 809 + {userIdent()} 810 + </span> 811 + </A> 812 + <Show when={profileQuery.data?.description}> 813 + <p class="text-sm pb-2 md:pb-2 break-words"> 814 + {profileQuery.data!.description} 815 + </p> 816 + </Show> 817 + <div class="text-sm flex items-center gap-2 my-2 overflow-hidden text-ellipsis whitespace-nowrap max-w-full"> 818 + <span class="flex-shrink-0"><Users class="size-4" /></span> 819 + <span id="followers"> 820 + <A href={`/${userIdent()}?tab=followers`}> 821 + {followersQuery.data?.length ?? 0} followers 822 + </A> 823 + </span> 824 + <span class="select-none after:content-['·']"></span> 825 + <span id="following"> 826 + <A href={`/${userIdent()}?tab=following`}> 827 + {followingQuery.data?.length ?? 0} following 828 + </A> 829 + </span> 830 + </div> 831 + </div> 832 + <Show when={props.loggedInUserDid && !isSelf()}> 833 + <div class="w-full md:w-auto md:max-w-24 order-last md:order-none"> 834 + <button 835 + onClick={() => props.onFollowToggle?.(props.item.did)} 836 + class="btn w-full flex gap-2 items-center justify-center group" 837 + > 838 + <Show 839 + when={isFollowing()} 840 + fallback={ 841 + <> 842 + <UserRoundPlus class="size-4 inline" /> 843 + Follow 844 + </> 845 + } 846 + > 847 + <> 848 + <UserRoundMinus class="size-4 inline" /> 849 + Unfollow 850 + </> 851 + </Show> 852 + </button> 853 + </div> 854 + </Show> 855 + </div> 856 + </div> 857 + </div> 858 + ); 859 + }; 860 + 861 + export const ProfilePage: Component = () => { 862 + const params = useParams(); 863 + const [searchParams, setSearchParams] = useSearchParams(); 864 + const queryClient = useQueryClient(); 865 + const auth = useAuth(); 866 + 867 + const [repoSearch, setRepoSearch] = createSignal(''); 868 + const [actionLoading, setActionLoading] = createSignal(false); 869 + 870 + const actorParam = () => params.actor as string; 871 + const activeTab = () => { 872 + const tab = searchParams.tab; 873 + return (Array.isArray(tab) ? tab[0] : tab) || 'overview'; 874 + }; 875 + 876 + const actorQuery = createQuery(() => ({ 877 + queryKey: ['profile-actor', actorParam()], 878 + queryFn: () => resolveActor(actorParam()), 879 + })); 880 + 881 + const profileQuery = createQuery(() => ({ 882 + queryKey: ['profile-record', actorQuery.data?.did], 883 + enabled: !!actorQuery.data, 884 + queryFn: () => getActorProfile(actorQuery.data!), 885 + })); 886 + 887 + const followersQuery = createQuery(() => ({ 888 + queryKey: ['profile-followers', actorQuery.data?.did], 889 + enabled: !!actorQuery.data, 890 + queryFn: () => getFollowers(actorQuery.data!.did), 891 + })); 892 + 893 + const followingQuery = createQuery(() => ({ 894 + queryKey: ['profile-following', actorQuery.data?.did], 895 + enabled: !!actorQuery.data, 896 + queryFn: () => getFollowing(actorQuery.data!.did), 897 + })); 898 + 899 + const reposQuery = createQuery(() => ({ 900 + queryKey: ['profile-repos', actorQuery.data?.did], 901 + enabled: !!actorQuery.data, 902 + queryFn: () => listRepoRecords(actorQuery.data!), 903 + })); 904 + 905 + const starsQuery = createQuery(() => ({ 906 + queryKey: ['profile-stars', actorQuery.data?.did], 907 + enabled: !!actorQuery.data, 908 + queryFn: async () => { 909 + const did = actorQuery.data!.did; 910 + const starRecords = await listAllAppviewRecords<ShTangledFeedStar.Main>('sh.tangled.feed.listStarsBy', did); 911 + const repos = await Promise.all( 912 + starRecords.map(async (star) => { 913 + const subject = star.value.subject as { did?: string; subjectDid?: string }; 914 + const repoDid = (subject.did ?? subject.subjectDid) as Did; 915 + try { 916 + return await getRepoByDid(repoDid); 917 + } catch (e) { 918 + console.error('failed to resolve starred repo', repoDid, e); 919 + return null; 920 + } 921 + }) 922 + ); 923 + return repos.filter(Boolean) as RepoContext[]; 924 + }, 925 + })); 926 + 927 + const viewerFollowsQuery = createQuery(() => ({ 928 + queryKey: ['viewer-follows', auth.currentDid()], 929 + enabled: !!auth.currentDid(), 930 + queryFn: () => listFollowRecords(auth.currentDid()!), 931 + })); 932 + 933 + const issuesQuery = createQuery(() => ({ 934 + queryKey: ['profile-issues-stat', actorQuery.data?.did], 935 + enabled: !!actorQuery.data, 936 + queryFn: async () => { 937 + const did = actorQuery.data!.did; 938 + try { 939 + return await listAllAppviewRecords<any>('sh.tangled.repo.listIssuesBy', did); 940 + } catch (e) { 941 + console.error('Failed to fetch issues for stat', e); 942 + return []; 943 + } 944 + } 945 + })); 946 + 947 + const pullsQuery = createQuery(() => ({ 948 + queryKey: ['profile-pulls-stat', actorQuery.data?.did], 949 + enabled: !!actorQuery.data, 950 + queryFn: async () => { 951 + const did = actorQuery.data!.did; 952 + try { 953 + return await listAllAppviewRecords<any>('sh.tangled.repo.listPullsBy', did); 954 + } catch (e) { 955 + console.error('Failed to fetch pulls for stat', e); 956 + return []; 957 + } 958 + } 959 + })); 960 + 961 + const mergedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'merged').length ?? 0; 962 + const closedPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'closed').length ?? 0; 963 + const openPRCount = () => (pullsQuery.data as any[])?.filter((p) => p.state === 'open').length ?? 0; 964 + const openIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'open').length ?? 0; 965 + const closedIssueCount = () => (issuesQuery.data as any[])?.filter((i) => i.state === 'closed').length ?? 0; 966 + 967 + const isFollowing = () => { 968 + const list = viewerFollowsQuery.data; 969 + const profileDid = actorQuery.data?.did; 970 + if (!list || !profileDid) return false; 971 + return list.some((follow) => follow.value.subject === profileDid); 972 + }; 973 + 974 + const followRecordRkey = () => { 975 + const list = viewerFollowsQuery.data; 976 + const profileDid = actorQuery.data?.did; 977 + if (!list || !profileDid) return undefined; 978 + return list.find((follow) => follow.value.subject === profileDid)?.rkey; 979 + }; 980 + 981 + const handleFollowToggle = async () => { 982 + const agent = auth.agent(); 983 + const profileDid = actorQuery.data?.did; 984 + if (!agent || !profileDid) return; 985 + 986 + setActionLoading(true); 987 + try { 988 + const rkey = followRecordRkey(); 989 + if (rkey) { 990 + await deleteFollow(agent, rkey); 991 + } else { 992 + await createFollow(agent, profileDid); 993 + } 994 + queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] }); 995 + queryClient.invalidateQueries({ queryKey: ['profile-followers', profileDid] }); 996 + } catch (e) { 997 + console.error('Failed to toggle follow status', e); 998 + } finally { 999 + setActionLoading(false); 1000 + } 1001 + }; 1002 + 1003 + const handleItemFollowToggle = async (did: Did) => { 1004 + const agent = auth.agent(); 1005 + if (!agent) return; 1006 + 1007 + try { 1008 + const viewerFollows = viewerFollowsQuery.data ?? []; 1009 + const existing = viewerFollows.find((f) => f.value.subject === did); 1010 + if (existing) { 1011 + await deleteFollow(agent, existing.rkey); 1012 + } else { 1013 + await createFollow(agent, did); 1014 + } 1015 + queryClient.invalidateQueries({ queryKey: ['viewer-follows', auth.currentDid()] }); 1016 + if (actorQuery.data?.did === did) { 1017 + queryClient.invalidateQueries({ queryKey: ['profile-followers', did] }); 1018 + } 1019 + } catch (e) { 1020 + console.error('Failed to toggle follow status for list item', e); 1021 + } 1022 + }; 1023 + 1024 + const vouchQuery = createQuery(() => ({ 1025 + queryKey: ['vouch-record', auth.currentDid(), actorQuery.data?.did], 1026 + enabled: !!auth.currentDid() && !!actorQuery.data?.did && auth.currentDid() !== actorQuery.data?.did, 1027 + queryFn: () => getVouchRecord(auth.currentDid()!, actorQuery.data!.did), 1028 + })); 1029 + 1030 + const [vouchActionLoading, setVouchActionLoading] = createSignal(false); 1031 + 1032 + const handleVouchSubmit = async (kind: 'vouch' | 'denounce' | 'none', reason?: string) => { 1033 + const agent = auth.agent(); 1034 + const profileDid = actorQuery.data?.did; 1035 + if (!agent || !profileDid) return; 1036 + 1037 + setVouchActionLoading(true); 1038 + try { 1039 + if (kind === 'none') { 1040 + await deleteVouchRecord(agent, profileDid); 1041 + } else { 1042 + await putVouchRecord(agent, profileDid, kind, reason); 1043 + } 1044 + queryClient.invalidateQueries({ queryKey: ['vouch-record', auth.currentDid(), profileDid] }); 1045 + } catch (e) { 1046 + console.error('Failed to submit vouch status', e); 1047 + } finally { 1048 + setVouchActionLoading(false); 1049 + } 1050 + }; 1051 + 1052 + const [profileUpdateLoading, setProfileUpdateLoading] = createSignal(false); 1053 + 1054 + const handleProfileUpdate = async (profile: ShTangledActorProfile.Main) => { 1055 + const agent = auth.agent(); 1056 + const profileDid = actorQuery.data?.did; 1057 + if (!agent || !profileDid) return; 1058 + 1059 + setProfileUpdateLoading(true); 1060 + try { 1061 + await putActorProfile(agent, profile); 1062 + queryClient.invalidateQueries({ queryKey: ['profile-record', profileDid] }); 1063 + } catch (e) { 1064 + console.error('Failed to update profile record', e); 1065 + } finally { 1066 + setProfileUpdateLoading(false); 1067 + } 1068 + }; 1069 + 1070 + const pinnedRepos = createMemo(() => { 1071 + const list = reposQuery.data ?? []; 1072 + const pinnedDids = profileQuery.data?.pinnedRepositories ?? []; 1073 + if (pinnedDids.length > 0) { 1074 + return list.filter((repo) => { 1075 + const did = repo.value.repoDid; 1076 + const uri = repo.uri; 1077 + return (did && pinnedDids.includes(did)) || pinnedDids.includes(uri); 1078 + }); 1079 + } 1080 + return list.slice(0, 4); 1081 + }); 1082 + 1083 + const filteredRepos = createMemo(() => { 1084 + const q = repoSearch().toLowerCase().trim(); 1085 + const list = reposQuery.data ?? []; 1086 + if (!q) return list; 1087 + return list.filter( 1088 + (repo) => 1089 + repo.value.name?.toLowerCase().includes(q) || 1090 + repo.value.description?.toLowerCase().includes(q) 1091 + ); 1092 + }); 1093 + 1094 + return ( 1095 + <Switch> 1096 + <Match when={actorQuery.isLoading}> 1097 + <LoadingState label="Loading profile..." /> 1098 + </Match> 1099 + 1100 + <Match when={actorQuery.error}> 1101 + <div class="p-6 border border-gray-200 dark:border-gray-700 rounded bg-white dark:bg-gray-800"> 1102 + <ErrorState message="Could not resolve user profile." /> 1103 + </div> 1104 + </Match> 1105 + 1106 + <Match when={actorQuery.data}> 1107 + {(resolvedActor) => ( 1108 + <Switch> 1109 + <Match when={profileQuery.isLoading}> 1110 + <LoadingState label="Loading profile data..." /> 1111 + </Match> 1112 + <Match when={!profileQuery.data}> 1113 + {(() => { 1114 + const unresolvedAvatarQuery = createQuery(() => ({ 1115 + queryKey: ['avatar', resolvedActor().did], 1116 + queryFn: async () => resolveAvatarUrl(resolvedActor().did), 1117 + staleTime: 300_000, 1118 + })); 1119 + return ( 1120 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm"> 1121 + <div class="flex items-center gap-6 p-4"> 1122 + <Show 1123 + when={unresolvedAvatarQuery.data} 1124 + fallback={<PlaceholderAvatar size="w-28 h-28" iconSize="size-12" />} 1125 + > 1126 + <img 1127 + class="w-28 h-28 shrink-0 object-cover rounded-full" 1128 + src={unresolvedAvatarQuery.data!} 1129 + alt={resolvedActor().handle || resolvedActor().did} 1130 + /> 1131 + </Show> 1132 + <div> 1133 + <p class="text-lg font-bold">{resolvedActor().handle || resolvedActor().did}</p> 1134 + <p class="text-gray-700 dark:text-gray-300 mt-2">This user hasn't joined Tangled yet.</p> 1135 + <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">Let them know we're waiting for them!</p> 1136 + </div> 1137 + </div> 1138 + </section> 1139 + ); 1140 + })()} 1141 + </Match> 1142 + <Match when={profileQuery.data}> 1143 + <div class="flex flex-col"> 1144 + <ProfileTabs 1145 + activeTab={activeTab()} 1146 + reposCount={reposQuery.data?.length ?? 0} 1147 + starsCount={starsQuery.data?.length ?? 0} 1148 + /> 1149 + 1150 + <section class="bg-white dark:bg-gray-800 px-2 py-6 md:p-6 rounded w-full dark:text-white shadow-sm"> 1151 + <div class="grid grid-cols-1 md:grid-cols-11 gap-4"> 1152 + {/* Sidebar */} 1153 + <div 1154 + class={clsx( 1155 + activeTab() === 'overview' ? 'md:col-span-3' : 'hidden md:block md:col-span-3', 1156 + 'order-1' 1157 + )} 1158 + > 1159 + <ProfileCardComponent 1160 + actor={resolvedActor()} 1161 + profile={profileQuery.data} 1162 + followersCount={followersQuery.data?.length ?? 0} 1163 + followingCount={followingQuery.data?.length ?? 0} 1164 + loggedInUserDid={auth.currentDid() ?? undefined} 1165 + isFollowing={isFollowing()} 1166 + onFollowToggle={handleFollowToggle} 1167 + actionLoading={actionLoading()} 1168 + onTabChange={(tab) => setSearchParams({ tab })} 1169 + vouchRecord={vouchQuery.data} 1170 + onVouchSubmit={handleVouchSubmit} 1171 + vouchActionLoading={vouchActionLoading()} 1172 + onProfileUpdate={handleProfileUpdate} 1173 + profileUpdateLoading={profileUpdateLoading()} 1174 + reposCount={reposQuery.data?.length ?? 0} 1175 + starsCount={starsQuery.data?.length ?? 0} 1176 + mergedPRCount={mergedPRCount()} 1177 + closedPRCount={closedPRCount()} 1178 + openPRCount={openPRCount()} 1179 + openIssueCount={openIssueCount()} 1180 + closedIssueCount={closedIssueCount()} 1181 + /> 1182 + </div> 1183 + 1184 + <Switch> 1185 + {/* Overview Tab (split into two columns) */} 1186 + <Match when={activeTab() === 'overview'}> 1187 + {/* Pinned / Own Repos Column */} 1188 + <div id="all-repos" class="md:col-span-4 order-2 md:order-2"> 1189 + <div class="grid grid-cols-1 gap-4"> 1190 + <div> 1191 + <div class="text-base font-bold uppercase tracking-wider px-2 pb-4 dark:text-white flex items-center gap-2"> 1192 + <A 1193 + href="?tab=repos" 1194 + class="flex text-black dark:text-white items-center gap-2 no-underline hover:no-underline group" 1195 + > 1196 + <span>pinned repos</span> 1197 + </A> 1198 + </div> 1199 + <div id="repos" class="grid grid-cols-1 gap-4 items-stretch"> 1200 + <For 1201 + each={pinnedRepos()} 1202 + fallback={ 1203 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1204 + <span>This user does not have any pinned repos.</span> 1205 + </div> 1206 + } 1207 + > 1208 + {(repo) => ( 1209 + <RepoCard 1210 + owner={resolvedActor().handle} 1211 + name={repo.value.name || repo.rkey} 1212 + description={repo.value.description} 1213 + showOwner={false} 1214 + /> 1215 + )} 1216 + </For> 1217 + </div> 1218 + </div> 1219 + </div> 1220 + </div> 1221 + 1222 + {/* Activity Timeline Column */} 1223 + <div class="md:col-span-4 order-3 md:order-3"> 1224 + <p class="text-base font-bold uppercase tracking-wider px-2 pb-4 dark:text-white">activity</p> 1225 + <div class="flex flex-col gap-4 relative"> 1226 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1227 + <span>This user does not have any activity yet.</span> 1228 + </div> 1229 + </div> 1230 + </div> 1231 + </Match> 1232 + 1233 + {/* Repositories Tab */} 1234 + <Match when={activeTab() === 'repos'}> 1235 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 1236 + <div class="mb-4"> 1237 + <form id="search-form" class="flex relative" onSubmit={(e) => e.preventDefault()}> 1238 + <div class="flex-1 flex relative"> 1239 + <input 1240 + id="search-q" 1241 + class="flex-1 py-1 pl-2 pr-10 mr-[-1px] rounded-r-none peer font-normal text-sm" 1242 + type="text" 1243 + placeholder="Search repos..." 1244 + value={repoSearch()} 1245 + onInput={(e) => setRepoSearch(e.currentTarget.value)} 1246 + /> 1247 + <Show when={repoSearch()}> 1248 + <button 1249 + type="button" 1250 + onClick={() => setRepoSearch('')} 1251 + class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300" 1252 + > 1253 + <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12"/></svg> 1254 + </button> 1255 + </Show> 1256 + </div> 1257 + <button 1258 + type="submit" 1259 + class="p-2 text-gray-400 border rounded-r border-gray-300 dark:border-gray-600" 1260 + > 1261 + <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg> 1262 + </button> 1263 + </form> 1264 + </div> 1265 + 1266 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 1267 + <For 1268 + each={filteredRepos()} 1269 + fallback={ 1270 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1271 + <span>This user does not have any repos yet.</span> 1272 + </div> 1273 + } 1274 + > 1275 + {(repo) => ( 1276 + <RepoCard 1277 + owner={resolvedActor().handle} 1278 + name={repo.value.name || repo.rkey} 1279 + description={repo.value.description} 1280 + showOwner={false} 1281 + /> 1282 + )} 1283 + </For> 1284 + </div> 1285 + </div> 1286 + </Match> 1287 + 1288 + {/* Starred Tab */} 1289 + <Match when={activeTab() === 'starred'}> 1290 + <div id="all-repos" class="md:col-span-8 order-2 md:order-2"> 1291 + <Show 1292 + when={!starsQuery.isLoading} 1293 + fallback={<LoadingState label="Loading starred repos..." />} 1294 + > 1295 + <div id="repos" class="grid grid-cols-1 gap-4 mb-6"> 1296 + <For 1297 + each={starsQuery.data ?? []} 1298 + fallback={ 1299 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1300 + <span>This user does not have any starred repos yet.</span> 1301 + </div> 1302 + } 1303 + > 1304 + {(repo) => ( 1305 + <RepoCard 1306 + owner={repo.owner.handle} 1307 + name={repo.slug} 1308 + description={repo.record.value.description} 1309 + showOwner={true} 1310 + /> 1311 + )} 1312 + </For> 1313 + </div> 1314 + </Show> 1315 + </div> 1316 + </Match> 1317 + 1318 + {/* Followers Tab */} 1319 + <Match when={activeTab() === 'followers'}> 1320 + <div id="all-followers" class="md:col-span-8 order-2 md:order-2"> 1321 + <p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all followers</p> 1322 + <Show 1323 + when={!followersQuery.isLoading} 1324 + fallback={<LoadingState label="Loading followers..." />} 1325 + > 1326 + <div id="followers" class="grid grid-cols-1 gap-4 mb-6"> 1327 + <For 1328 + each={followersQuery.data ?? []} 1329 + 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 followers yet.</span> 1332 + </div> 1333 + } 1334 + > 1335 + {(item) => ( 1336 + <FollowCard 1337 + item={item} 1338 + loggedInUserDid={auth.currentDid() ?? undefined} 1339 + viewerFollows={viewerFollowsQuery.data} 1340 + onFollowToggle={handleItemFollowToggle} 1341 + /> 1342 + )} 1343 + </For> 1344 + </div> 1345 + </Show> 1346 + </div> 1347 + </Match> 1348 + 1349 + {/* Following Tab */} 1350 + <Match when={activeTab() === 'following'}> 1351 + <div id="all-following" class="md:col-span-8 order-2 md:order-2"> 1352 + <p class="text-base font-bold uppercase tracking-wider p-2 dark:text-white">all following</p> 1353 + <Show 1354 + when={!followingQuery.isLoading} 1355 + fallback={<LoadingState label="Loading following list..." />} 1356 + > 1357 + <div id="following" class="grid grid-cols-1 gap-4 mb-6"> 1358 + <For 1359 + each={followingQuery.data ?? []} 1360 + fallback={ 1361 + <div class="text-base text-gray-500 flex items-center justify-center italic p-12 border border-gray-200 dark:border-gray-700 rounded"> 1362 + <span>This user does not follow anyone yet.</span> 1363 + </div> 1364 + } 1365 + > 1366 + {(item) => ( 1367 + <FollowCard 1368 + item={item} 1369 + loggedInUserDid={auth.currentDid() ?? undefined} 1370 + viewerFollows={viewerFollowsQuery.data} 1371 + onFollowToggle={handleItemFollowToggle} 1372 + /> 1373 + )} 1374 + </For> 1375 + </div> 1376 + </Show> 1377 + </div> 1378 + </Match> 1379 + </Switch> 1380 + </div> 1381 + </section> 1382 + </div> 1383 + </Match> 1384 + </Switch> 1385 + )} 1386 + </Match> 1387 + </Switch> 1388 + ); 1389 + };
+3 -3
src/pages/repo/issues.tsx
··· 487 487 <StateBadge state={detail().issue.state} kind="issue" /> 488 488 <span>opened by</span> 489 489 <Avatar did={detail().issue.author.did} size="size-6" /> 490 - <span class="text-gray-700 dark:text-gray-200">{detail().issue.author.handle}</span> 490 + <a href={`/${detail().issue.author.did}`} class="text-gray-700 dark:text-gray-200">{detail().issue.author.handle}</a> 491 491 <span class="select-none">·</span> 492 492 <span>{formatRelativeTime(detail().issue.value.createdAt)}</span> 493 493 </div> ··· 516 516 </div> 517 517 <div class="min-w-0 flex-1"> 518 518 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 519 - <span class="text-gray-700 dark:text-gray-200">{thread.item.author.handle}</span> 519 + <a href={`/${thread.item.author.did}`} class="text-gray-700 dark:text-gray-200">{thread.item.author.handle}</a> 520 520 <Show when={thread.item.author.did === detail().issue.author.did}> 521 521 <span>(author)</span> 522 522 </Show> ··· 537 537 </div> 538 538 <div class="min-w-0 flex-1"> 539 539 <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400"> 540 - <span class="text-gray-700 dark:text-gray-200">{reply.author.handle}</span> 540 + <a href={`/${reply.author.did}`} class="text-gray-700 dark:text-gray-200">{reply.author.handle}</a> 541 541 <Show when={reply.author.did === detail().issue.author.did}> 542 542 <span>(author)</span> 543 543 </Show>
+1 -1
src/pages/repo/pulls.tsx
··· 1014 1014 <StateBadge state={detail().pull.state} kind="pull" /> 1015 1015 <span>opened by</span> 1016 1016 <Avatar did={detail().pull.author.did} size="size-6" /> 1017 - <span class="text-gray-700 dark:text-gray-200">{detail().pull.author.handle}</span> 1017 + <a href={`/${detail().pull.author.did}`} class="text-gray-700 dark:text-gray-200">{detail().pull.author.handle}</a> 1018 1018 <span class="select-none">·</span> 1019 1019 <span>{formatRelativeTime(detail().pull.value.createdAt)}</span> 1020 1020 <span class="select-none">·</span>
+5
src/pages/search.tsx
··· 306 306 {title()} 307 307 </RepoThreadLink> 308 308 </Match> 309 + <Match when={props.hit.nsid === 'sh.tangled.actor.profile'}> 310 + <A href={`/${props.hit.author.handle}`} class="truncate min-w-0"> 311 + {props.hit.author.handle} 312 + </A> 313 + </Match> 309 314 <Match when={props.hit.nsid === 'sh.tangled.repo.issue.comment' || props.hit.nsid === 'sh.tangled.repo.pull.comment'}> 310 315 <CommentParentLink hit={props.hit} /> 311 316 </Match>
+3
src/routes.tsx
··· 19 19 import { NotFoundPage } from './pages/not-found'; 20 20 import { OAuthCallbackPage } from './pages/oauth-callback'; 21 21 import { SearchPage } from './pages/search'; 22 + import { ProfilePage } from './pages/profile'; 22 23 23 24 export const AppRoutes: Component = () => ( 24 25 <Router root={RootShell}> ··· 39 40 <Route path="/blob/:ref/*path" component={BlobPage} /> 40 41 <Route path="/tree/:ref/*path" component={RepoCodePage} /> 41 42 </Route> 43 + <Route path="/:actor" component={ProfilePage} /> 42 44 <Route path="*404" component={NotFoundPage} /> 43 45 </Router> 44 46 ); 47 +