Monorepo for Tangled
0

Configure Feed

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

web/components/profile: implement profile page

Signed-off-by: dawn <dawn@tangled.org>

dawn (Jul 13, 2026, 3:58 PM +0300) 393ab0e5 ae23e913

+1707 -19
+15
web/src/hooks.server.ts
··· 1 + import type { Handle } from '@sveltejs/kit'; 2 + 3 + export const handle: Handle = async ({ event, resolve }) => { 4 + return resolve(event, { 5 + // sveltekit blocks atcute fetch handler from reading headers 6 + // because it assumes backend APIs might return sensitive headers. 7 + // this happens when during CSR we run a fetch that was the same 8 + // as one ran during SSR, so sveltekit tries to give that fetch 9 + // the data we already had. 10 + // so we allow these headers to have atcute function properly. 11 + filterSerializedResponseHeaders(name) { 12 + return name === 'content-type' || name === 'content-length'; 13 + } 14 + }); 15 + };
+26 -12
web/src/lib/auth.svelte.ts
··· 32 32 } from './auth/accounts'; 33 33 34 34 export const AUTH_KEY = Symbol('auth'); 35 - const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; 36 35 const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; 37 36 const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; 38 37 38 + // local dev (localinfra) points these at the local pds/plc; defaults are the public network. 39 + const HANDLE_RESOLVER_URL = 40 + (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, '') ?? 41 + 'https://public.api.bsky.app'; 42 + const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( 43 + /\/+$/, 44 + '' 45 + ); 46 + 39 47 const identityResolver = new LocalActorResolver({ 40 - handleResolver: new XrpcHandleResolver({ 41 - serviceUrl: 'https://public.api.bsky.app' 42 - }), 48 + handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }), 43 49 didDocumentResolver: new CompositeDidDocumentResolver({ 44 50 methods: { 45 - plc: new PlcDidDocumentResolver(), 51 + plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}), 46 52 web: new WebDidDocumentResolver() 47 53 } 48 54 }) ··· 73 79 readonly authenticating: boolean; 74 80 readonly currentUser: CurrentUser | null; 75 81 readonly accounts: AuthAccount[]; 82 + bobbinUrl: string; 76 83 refresh(): Promise<void>; 77 84 signIn(identifier: string, returnTo?: string): Promise<void>; 78 85 addAccount(identifier: string, returnTo?: string): Promise<void>; ··· 101 108 ? DEV_REDIRECT_URI 102 109 : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); 103 110 const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; 104 - const APPVIEW_SERVICE = 105 - (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ?? 106 - DEFAULT_APPVIEW_SERVICE; 107 111 108 112 const configure = () => { 109 113 if (!browser || configured) return; ··· 126 130 : message; 127 131 }; 128 132 129 - const resolveProfile = async (identifier: string): Promise<AuthProfile | null> => { 133 + const resolveProfile = async ( 134 + identifier: string, 135 + bobbinUrl: string 136 + ): Promise<AuthProfile | null> => { 130 137 try { 131 - const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE); 138 + const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', bobbinUrl); 132 139 url.searchParams.set('identifier', identifier); 133 140 const response = await fetch(url, { headers: { accept: 'application/json' } }); 134 141 if (response.ok) { ··· 162 169 return '/'; 163 170 }; 164 171 165 - export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { 172 + export const createAuth = ( 173 + bobbinUrl: string, 174 + initial?: { did: string; handle: string } | null 175 + ): Auth => { 166 176 const seed = initial ?? null; 167 177 let agent = $state<OAuthUserAgent | null>(null); 178 + const bobbinUrlValue = bobbinUrl; 168 179 let currentDid = $state<Did | null>((seed?.did as Did | undefined) ?? null); 169 180 let profile = $state<AuthProfile | null>( 170 181 seed ? { did: seed.did as Did, handle: seed.handle } : null ··· 188 199 }; 189 200 190 201 const hydrateProfile = async (did: Did) => { 191 - const resolved = await resolveProfile(did); 202 + const resolved = await resolveProfile(did, bobbinUrl); 192 203 profile = resolved ?? { did, handle: did }; 193 204 const meta = upsertAccount(loadAccounts(), { 194 205 did, ··· 368 379 }, 369 380 get profile() { 370 381 return profile; 382 + }, 383 + get bobbinUrl() { 384 + return bobbinUrlValue; 371 385 }, 372 386 get error() { 373 387 return error;
+3
web/src/lib/components/profile/ActivityStub.svelte
··· 1 + <div class="flex min-h-32 items-center justify-center text-base text-fg-subtle italic"> 2 + Activity is not available yet. 3 + </div>
+16
web/src/lib/components/profile/EmptyState.svelte
··· 1 + <script lang="ts"> 2 + import type { Snippet } from 'svelte'; 3 + 4 + interface Props { 5 + message: string; 6 + children?: Snippet; 7 + } 8 + 9 + let { message, children }: Props = $props(); 10 + </script> 11 + 12 + <div 13 + class="flex items-center justify-center border border-border p-12 text-base text-fg-subtle italic" 14 + > 15 + <span class="flex items-center gap-2">{message}{@render children?.()}</span> 16 + </div>
+100
web/src/lib/components/profile/FollowButton.svelte
··· 1 + <script lang="ts"> 2 + import Button from '$lib/components/ui/Button.svelte'; 3 + import UserRoundPlus from '$icon/user-round-plus'; 4 + import UserRoundMinus from '$icon/user-round-minus'; 5 + import { getAuth } from '$lib/auth.svelte'; 6 + import { createFollow, deleteFollow } from '$lib/api/graph'; 7 + import { getProfileCounts } from './counts.svelte'; 8 + import type { FollowChange } from './types'; 9 + import { createOptimisticRelation } from '$lib/optimistic.svelte'; 10 + 11 + interface Props { 12 + profileDid: string; 13 + initialRkey?: string | null; 14 + size?: 'sm' | 'md'; 15 + onCommit?: (change: FollowChange) => void; 16 + } 17 + 18 + let { profileDid, initialRkey, size = 'sm', onCommit }: Props = $props(); 19 + 20 + const auth = getAuth(); 21 + const profileCounts = getProfileCounts(); 22 + const signedIn = $derived(Boolean(auth.currentDid)); 23 + const isSelf = $derived(auth.currentDid === profileDid); 24 + const relation = createOptimisticRelation({ 25 + key: () => `${auth.currentDid ?? ''}:${profileDid}`, 26 + loadedRkey: () => initialRkey 27 + }); 28 + 29 + let busy = $state(false); 30 + const following = $derived(relation.active); 31 + 32 + const commit = (change: FollowChange) => { 33 + profileCounts?.adjust(change.subjectDid, 'followers', change.delta); 34 + profileCounts?.adjust(change.viewerDid, 'following', change.delta); 35 + onCommit?.(change); 36 + }; 37 + 38 + const toggle = async () => { 39 + const agent = auth.agent; 40 + if (!agent || busy || !relation.known) return; 41 + busy = true; 42 + relation.resetFailure(); 43 + try { 44 + if (relation.active && relation.rkey) { 45 + await deleteFollow(agent, relation.rkey); 46 + relation.deleted(); 47 + commit({ 48 + viewerDid: agent.sub, 49 + subjectDid: profileDid, 50 + following: false, 51 + rkey: null, 52 + delta: -1 53 + }); 54 + } else { 55 + const rkey = await createFollow(agent, profileDid); 56 + relation.created(rkey); 57 + commit({ 58 + viewerDid: agent.sub, 59 + subjectDid: profileDid, 60 + following: true, 61 + rkey, 62 + delta: 1 63 + }); 64 + } 65 + } catch { 66 + relation.fail(); 67 + } finally { 68 + busy = false; 69 + } 70 + }; 71 + </script> 72 + 73 + {#if !isSelf} 74 + {#if !signedIn} 75 + <Button href="/login" variant="default" {size} class="w-full gap-2"> 76 + <UserRoundPlus class="size-4" aria-hidden="true" /><span>Follow</span> 77 + </Button> 78 + {:else} 79 + <div class="w-full"> 80 + <Button 81 + variant="default" 82 + {size} 83 + class="w-full gap-2" 84 + loading={busy} 85 + disabled={!relation.known} 86 + onclick={toggle} 87 + > 88 + {#if following} 89 + <UserRoundMinus class="size-4" aria-hidden="true" /> 90 + {:else} 91 + <UserRoundPlus class="size-4" aria-hidden="true" /> 92 + {/if} 93 + <span>{following ? 'Unfollow' : 'Follow'}</span> 94 + </Button> 95 + {#if relation.failed} 96 + <p class="mt-1 text-xs text-danger">Something went wrong. Try again.</p> 97 + {/if} 98 + </div> 99 + {/if} 100 + {/if}
+66
web/src/lib/components/profile/FollowCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import Avatar from '$lib/components/ui/Avatar.svelte'; 4 + import Users from '$icon/users'; 5 + import FollowButton from './FollowButton.svelte'; 6 + import type { FollowChange, PersonData } from './types'; 7 + import { createOptimisticCount } from '$lib/optimistic.svelte'; 8 + 9 + let { person }: { person: PersonData } = $props(); 10 + 11 + const followerCount = createOptimisticCount({ 12 + key: () => person.did, 13 + loaded: () => person.followers 14 + }); 15 + 16 + const followed = (change: FollowChange) => followerCount.adjust(change.delta); 17 + </script> 18 + 19 + <div class="flex flex-col gap-4 md:flex-row md:items-center"> 20 + <div class="flex h-24 w-24 shrink-0 items-center justify-center"> 21 + <Avatar src={person.avatar} handle={person.handle} size="size-24" class="p-2" /> 22 + </div> 23 + 24 + <div class="flex min-w-0 flex-1 flex-col gap-2 md:flex-row md:items-center md:justify-between"> 25 + <div class="flex min-h-0 flex-1 flex-col justify-around"> 26 + <a 27 + href={resolve(`/${person.handle}` as '/')} 28 + class="max-w-full overflow-hidden text-ellipsis whitespace-nowrap font-bold text-fg-strong hover:underline" 29 + > 30 + {person.handle} 31 + </a> 32 + {#if person.description} 33 + <p class="break-words pb-2 text-sm text-fg-muted md:pb-2">{person.description}</p> 34 + {/if} 35 + <div 36 + class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-ellipsis whitespace-nowrap text-sm text-fg-subtle" 37 + > 38 + <Users class="size-4 shrink-0" /> 39 + <span 40 + ><a 41 + href={resolve(`/${person.handle}?tab=followers` as '/')} 42 + class="hover:text-fg hover:underline">{followerCount.value} followers</a 43 + ></span 44 + > 45 + <span class="select-none after:content-['·']"></span> 46 + <span 47 + ><a 48 + href={resolve(`/${person.handle}?tab=following` as '/')} 49 + class="hover:text-fg hover:underline">{person.following} following</a 50 + ></span 51 + > 52 + </div> 53 + </div> 54 + 55 + {#if !person.isSelf} 56 + <div class="order-last w-full md:order-none md:w-auto md:max-w-32"> 57 + <FollowButton 58 + profileDid={person.did} 59 + initialRkey={person.viewerFollowRkey} 60 + size="md" 61 + onCommit={followed} 62 + /> 63 + </div> 64 + {/if} 65 + </div> 66 + </div>
+29
web/src/lib/components/profile/FollowerFollowing.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import Users from '$icon/users'; 4 + 5 + interface Props { 6 + handle: string; 7 + followers: number; 8 + following: number; 9 + } 10 + 11 + let { handle, followers, following }: Props = $props(); 12 + </script> 13 + 14 + <div 15 + class="my-2 flex max-w-full items-center gap-2 overflow-hidden text-sm text-ellipsis whitespace-nowrap" 16 + > 17 + <span class="flex-shrink-0"><Users class="size-4" aria-hidden="true" /></span> 18 + <span> 19 + <a href={resolve(`/${handle}?tab=followers` as '/')} class="no-underline hover:underline" 20 + >{followers} followers</a 21 + > 22 + </span> 23 + <span class="select-none after:content-['·']"></span> 24 + <span> 25 + <a href={resolve(`/${handle}?tab=following` as '/')} class="no-underline hover:underline" 26 + >{following} following</a 27 + > 28 + </span> 29 + </div>
+157
web/src/lib/components/profile/ProfileCard.svelte
··· 1 + <script lang="ts"> 2 + import FollowButton from './FollowButton.svelte'; 3 + import ProfileEditForm from './ProfileEditForm.svelte'; 4 + import FollowerFollowing from './FollowerFollowing.svelte'; 5 + import MapPin from '$icon/map-pin'; 6 + import Link from '$icon/link'; 7 + import Pencil from '$icon/pencil'; 8 + import Rss from '$icon/rss'; 9 + import UserRound from '$icon/user-round'; 10 + import Button from '$lib/components/ui/Button.svelte'; 11 + import { getAuth } from '$lib/auth.svelte'; 12 + import type { ProfileRecord } from '$lib/api/records'; 13 + 14 + interface Identity { 15 + did: string; 16 + handle: string; 17 + avatar?: string; 18 + } 19 + 20 + interface Props { 21 + identity: Identity; 22 + profile: ProfileRecord | null; 23 + followers: number; 24 + following: number; 25 + viewerFollowRkey?: string | null; 26 + } 27 + 28 + let { identity, profile, followers, following, viewerFollowRkey }: Props = $props(); 29 + 30 + const auth = getAuth(); 31 + const isSelf = $derived(auth.currentDid === identity.did); 32 + 33 + let editing = $state(false); 34 + let saved = $state<ProfileRecord | null>(null); 35 + const shown = $derived(saved ?? profile); 36 + 37 + const links = $derived((shown?.links ?? []).filter((l) => l.trim().length > 0)); 38 + const blueskyUrl = $derived(shown?.bluesky ? `https://bsky.app/profile/${identity.did}` : null); 39 + </script> 40 + 41 + <div class="grid grid-cols-3 items-center gap-1 md:grid-cols-1"> 42 + <div class="col-span-1 flex items-center justify-center"> 43 + <div class="relative aspect-square w-3/4"> 44 + {#if identity.avatar} 45 + <img 46 + class="absolute inset-0 h-full w-full rounded-full object-cover p-2" 47 + src={identity.avatar} 48 + alt={identity.handle} 49 + /> 50 + {:else} 51 + <span class="absolute inset-0 h-full w-full p-2" aria-hidden="true"> 52 + <span 53 + class="flex h-full w-full items-center justify-center rounded-full bg-surface-muted text-fg-faint" 54 + > 55 + <UserRound class="size-1/2" /> 56 + </span> 57 + </span> 58 + {/if} 59 + </div> 60 + </div> 61 + 62 + <div class="col-span-2"> 63 + <div class="flex flex-row flex-nowrap items-center gap-2"> 64 + <h1 65 + title={identity.handle} 66 + class="overflow-hidden text-lg font-bold text-ellipsis whitespace-nowrap" 67 + > 68 + {identity.handle} 69 + </h1> 70 + {#if shown?.pronouns} 71 + <p class="text-fg-subtle">{shown.pronouns}</p> 72 + {/if} 73 + </div> 74 + <div class="md:hidden"> 75 + <FollowerFollowing handle={identity.handle} {followers} {following} /> 76 + </div> 77 + </div> 78 + 79 + <div class="col-span-3 md:col-span-full"> 80 + {#if editing} 81 + <ProfileEditForm 82 + profile={shown} 83 + onSaved={(record) => { 84 + saved = record; 85 + editing = false; 86 + }} 87 + onCancel={() => (editing = false)} 88 + /> 89 + {:else} 90 + <div class="space-y-2 text-sm"> 91 + {#if shown?.description} 92 + <p class="pb-4 text-base md:pb-2">{shown.description}</p> 93 + {/if} 94 + 95 + <div class="hidden md:block"> 96 + <FollowerFollowing handle={identity.handle} {followers} {following} /> 97 + </div> 98 + 99 + {#if shown?.location || links.length > 0 || blueskyUrl} 100 + <div 101 + class="mb-2 flex max-w-full flex-col gap-2 overflow-hidden text-ellipsis whitespace-nowrap" 102 + > 103 + {#if shown?.location} 104 + <div class="flex items-center gap-2"> 105 + <span class="flex-shrink-0"><MapPin class="size-4" aria-hidden="true" /></span> 106 + <span>{shown.location}</span> 107 + </div> 108 + {/if} 109 + {#if blueskyUrl} 110 + <div class="flex items-center gap-2"> 111 + <span class="flex-shrink-0"><Link class="size-4" aria-hidden="true" /></span> 112 + <a 113 + href={blueskyUrl} 114 + target="_blank" 115 + rel="external noopener noreferrer" 116 + class="truncate no-underline hover:underline">{identity.handle}</a 117 + > 118 + </div> 119 + {/if} 120 + {#each links as link, index (index)} 121 + <div class="flex items-center gap-2"> 122 + <span class="flex-shrink-0"><Link class="size-4" aria-hidden="true" /></span> 123 + <a 124 + href={link} 125 + target="_blank" 126 + rel="external nofollow me noopener noreferrer" 127 + class="truncate no-underline hover:underline">{link}</a 128 + > 129 + </div> 130 + {/each} 131 + </div> 132 + {/if} 133 + 134 + <div class="my-2 flex items-center gap-2"> 135 + {#if isSelf} 136 + <Button variant="default" class="w-full gap-2" onclick={() => (editing = true)}> 137 + <Pencil class="size-4" aria-hidden="true" /> 138 + Edit 139 + </Button> 140 + {:else} 141 + <div class="w-full"> 142 + <FollowButton profileDid={identity.did} initialRkey={viewerFollowRkey} /> 143 + </div> 144 + {/if} 145 + <Button 146 + href={`/${identity.handle}/feed.atom`} 147 + variant="default" 148 + class="px-3" 149 + aria-label="RSS feed" 150 + > 151 + <Rss class="size-4" aria-hidden="true" /> 152 + </Button> 153 + </div> 154 + </div> 155 + {/if} 156 + </div> 157 + </div>
+130
web/src/lib/components/profile/ProfileEditForm.svelte
··· 1 + <script lang="ts"> 2 + import { untrack } from 'svelte'; 3 + import Check from '$icon/check'; 4 + import Link from '$icon/link'; 5 + import MapPin from '$icon/map-pin'; 6 + import X from '$icon/x'; 7 + import Button from '$lib/components/ui/Button.svelte'; 8 + import { getAuth } from '$lib/auth.svelte'; 9 + import { putProfile } from '$lib/api/profile'; 10 + import type { ProfileRecord } from '$lib/api/records'; 11 + 12 + interface Props { 13 + profile: ProfileRecord | null; 14 + onSaved: (record: ProfileRecord) => void; 15 + onCancel: () => void; 16 + } 17 + 18 + let { profile, onSaved, onCancel }: Props = $props(); 19 + 20 + const auth = getAuth(); 21 + 22 + // seed once; the profile prop can't change while the form is open. 23 + const seed = untrack(() => profile); 24 + let description = $state(seed?.description ?? ''); 25 + let pronouns = $state(seed?.pronouns ?? ''); 26 + let location = $state(seed?.location ?? ''); 27 + let bluesky = $state(seed?.bluesky ?? false); 28 + let links = $state( 29 + Array.from({ length: 5 }, (_, index) => ({ 30 + value: (seed?.links?.[index] as string | undefined) ?? '' 31 + })) 32 + ); 33 + let busy = $state(false); 34 + let error = $state<string | null>(null); 35 + 36 + const inputClass = 37 + 'w-full rounded border border-border bg-surface px-2 py-1 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong'; 38 + 39 + const save = async (event: SubmitEvent) => { 40 + event.preventDefault(); 41 + const agent = auth.agent; 42 + if (!agent || busy) return; 43 + busy = true; 44 + error = null; 45 + const cleanLinks = links.map((link) => link.value.trim()).filter(Boolean); 46 + // put replaces the whole record, so carry unedited fields (avatar, pins, stats). 47 + const record: ProfileRecord = { 48 + ...profile, 49 + $type: 'sh.tangled.actor.profile', 50 + bluesky, 51 + description: description.trim() || undefined, 52 + pronouns: pronouns.trim() || undefined, 53 + location: location.trim() || undefined, 54 + links: cleanLinks.length > 0 ? (cleanLinks as ProfileRecord['links']) : undefined 55 + }; 56 + try { 57 + await putProfile(agent, record); 58 + onSaved(record); 59 + } catch { 60 + error = 'Could not save profile. Try again.'; 61 + } finally { 62 + busy = false; 63 + } 64 + }; 65 + </script> 66 + 67 + <form class="my-2 flex max-w-full flex-col gap-4 text-sm" onsubmit={save}> 68 + <div class="flex flex-col gap-1"> 69 + <label for="profile-bio">Bio</label> 70 + <textarea 71 + id="profile-bio" 72 + class={inputClass} 73 + rows="3" 74 + placeholder="Write a bio" 75 + bind:value={description}></textarea> 76 + </div> 77 + 78 + <div class="flex flex-col gap-1"> 79 + <label for="profile-pronouns">Pronouns</label> 80 + <input 81 + id="profile-pronouns" 82 + type="text" 83 + class={inputClass} 84 + placeholder="they/them" 85 + bind:value={pronouns} 86 + /> 87 + </div> 88 + 89 + <div class="flex flex-col gap-1"> 90 + <label for="profile-location">Location</label> 91 + <div class="flex w-full items-center gap-2"> 92 + <MapPin class="size-4 shrink-0" aria-hidden="true" /> 93 + <input id="profile-location" type="text" class={inputClass} bind:value={location} /> 94 + </div> 95 + </div> 96 + 97 + <div class="flex flex-col gap-1"> 98 + <span>Social links</span> 99 + <label class="flex items-center gap-2 py-1"> 100 + <input type="checkbox" bind:checked={bluesky} /> 101 + Link to Bluesky account 102 + </label> 103 + {#each links as link, index (index)} 104 + <div class="flex w-full items-center gap-2"> 105 + <Link class="size-4 shrink-0" aria-hidden="true" /> 106 + <input 107 + type="text" 108 + class={inputClass} 109 + placeholder={`Social link ${index + 1}`} 110 + bind:value={link.value} 111 + /> 112 + </div> 113 + {/each} 114 + </div> 115 + 116 + {#if error} 117 + <p class="text-xs text-danger">{error}</p> 118 + {/if} 119 + 120 + <div class="flex items-center justify-between gap-2"> 121 + <Button type="submit" variant="default" class="w-full gap-2" loading={busy}> 122 + <Check class="size-4" aria-hidden="true" /> 123 + Save 124 + </Button> 125 + <Button variant="default" class="w-full gap-2" disabled={busy} onclick={onCancel}> 126 + <X class="size-4" aria-hidden="true" /> 127 + Cancel 128 + </Button> 129 + </div> 130 + </form>
+65
web/src/lib/components/profile/ProfileTabs.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import SquareChartGantt from '$icon/square-chart-gantt'; 4 + import BookMarked from '$icon/book-marked'; 5 + import Star from '$icon/star'; 6 + import LineSquiggle from '$icon/line-squiggle'; 7 + import Shield from '$icon/shield'; 8 + import type { Component } from 'svelte'; 9 + import type { SvelteHTMLElements } from 'svelte/elements'; 10 + import type { ProfileCounts } from './types'; 11 + 12 + interface Props { 13 + handle: string; 14 + active: string; 15 + counts: ProfileCounts; 16 + } 17 + 18 + let { handle, active, counts }: Props = $props(); 19 + 20 + interface TabDef { 21 + id: string; 22 + label: string; 23 + icon: Component<SvelteHTMLElements['svg']>; 24 + count?: number; 25 + } 26 + 27 + const tabs = $derived<TabDef[]>([ 28 + { id: 'overview', label: 'Overview', icon: SquareChartGantt }, 29 + { id: 'repos', label: 'Repositories', icon: BookMarked, count: counts.repos }, 30 + { id: 'starred', label: 'Starred', icon: Star, count: counts.stars }, 31 + { id: 'strings', label: 'Strings', icon: LineSquiggle, count: counts.strings }, 32 + { id: 'vouches', label: 'Vouches', icon: Shield, count: counts.vouches } 33 + ]); 34 + 35 + const hrefFor = (id: string) => 36 + resolve((id === 'overview' ? `/${handle}` : `/${handle}?tab=${id}`) as '/'); 37 + </script> 38 + 39 + <nav class="w-full overflow-x-auto overflow-y-hidden pl-4" aria-label="profile sections"> 40 + <div class="flex"> 41 + {#each tabs as tab (tab.id)} 42 + {@const isActive = active === tab.id} 43 + {@const Glyph = tab.icon} 44 + <a 45 + href={hrefFor(tab.id)} 46 + class="group relative -mr-px no-underline hover:no-underline" 47 + aria-current={isActive ? 'page' : undefined} 48 + > 49 + <div 50 + class={`relative mr-1 min-w-[80px] rounded-t px-4 py-1 text-center whitespace-nowrap text-fg-strong ${ 51 + isActive ? '-mb-px bg-surface font-medium' : 'group-hover:bg-surface-muted/25' 52 + }`} 53 + > 54 + <span class="flex items-center justify-center"> 55 + <Glyph class="mr-2 h-4 w-4" aria-hidden="true" /> 56 + {tab.label} 57 + {#if tab.count} 58 + <span class="ml-1 rounded bg-surface-muted px-1 text-sm">{tab.count}</span> 59 + {/if} 60 + </span> 61 + </div> 62 + </a> 63 + {/each} 64 + </div> 65 + </nav>
+34
web/src/lib/components/profile/StringCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import { compactRelativeTime } from '$lib/format'; 4 + import type { StringCardData } from './types'; 5 + 6 + let { entry }: { entry: StringCardData } = $props(); 7 + </script> 8 + 9 + <div 10 + class="flex min-h-32 flex-col gap-1 rounded border border-border bg-surface px-6 py-4 shadow-sm" 11 + > 12 + <div class="flex items-center justify-between font-medium text-fg-strong"> 13 + <div class="mr-2 flex min-w-0 flex-1 items-center"> 14 + <a 15 + href={resolve(`/${entry.ownerHandle}/strings/${entry.rkey}` as '/')} 16 + class="min-w-0 truncate font-bold text-fg-strong no-underline hover:underline" 17 + > 18 + {entry.filename} 19 + </a> 20 + </div> 21 + </div> 22 + 23 + {#if entry.description} 24 + <p class="line-clamp-2 text-sm text-fg-muted">{entry.description}</p> 25 + {/if} 26 + 27 + <div class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-fg-subtle"> 28 + <span class="shrink-0" 29 + >{entry.lines} line{entry.lines === 1 ? '' : 's'} &middot; {compactRelativeTime( 30 + entry.createdAt 31 + )}</span 32 + > 33 + </div> 34 + </div>
+37
web/src/lib/components/profile/VouchCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import Avatar from '$lib/components/ui/Avatar.svelte'; 4 + import ShieldCheck from '$icon/shield-check'; 5 + import ShieldAlert from '$icon/shield-alert'; 6 + import { relativeTime } from '$lib/format'; 7 + import type { VouchData } from './types'; 8 + 9 + let { vouch }: { vouch: VouchData } = $props(); 10 + const denounce = $derived(vouch.kind === 'denounce'); 11 + </script> 12 + 13 + <article class="rounded border border-border bg-surface px-6 py-4 shadow-sm"> 14 + <div class="flex items-center gap-4"> 15 + <Avatar src={vouch.avatar} handle={vouch.handle} size="size-12" /> 16 + <div class="min-w-0 flex-grow"> 17 + <a 18 + href={resolve(`/${vouch.handle}` as '/')} 19 + class="block truncate font-medium text-fg-strong" 20 + > 21 + {vouch.handle} 22 + </a> 23 + <span class={`flex items-center gap-1 text-sm ${denounce ? 'text-danger' : 'text-success'}`}> 24 + {#if denounce} 25 + <ShieldAlert class="size-3.5" aria-hidden="true" />denounced 26 + {:else} 27 + <ShieldCheck class="size-3.5" aria-hidden="true" />vouched 28 + {/if} 29 + <span class="text-fg-subtle">&middot; {relativeTime(vouch.createdAt)}</span> 30 + </span> 31 + </div> 32 + </div> 33 + 34 + {#if vouch.reason} 35 + <p class="mt-3 text-sm text-fg-muted">{vouch.reason}</p> 36 + {/if} 37 + </article>
+53
web/src/lib/components/profile/counts.svelte.ts
··· 1 + import { getContext } from 'svelte'; 2 + import { createOptimisticCount, type OptimisticCount } from '$lib/optimistic.svelte'; 3 + import type { ProfileCounts } from './types'; 4 + 5 + export type ProfileCountName = keyof ProfileCounts; 6 + 7 + export interface ProfileCountsContext { 8 + readonly did: string; 9 + readonly value: ProfileCounts; 10 + adjust(subjectDid: string, name: ProfileCountName, delta: 1 | -1): void; 11 + } 12 + 13 + export const PROFILE_COUNTS_KEY = Symbol('profile-counts'); 14 + 15 + export const createProfileCounts = ( 16 + did: () => string, 17 + loaded: () => ProfileCounts 18 + ): ProfileCountsContext => { 19 + const counter = (name: ProfileCountName): OptimisticCount => 20 + createOptimisticCount({ key: did, loaded: () => loaded()[name] }); 21 + 22 + const counts = { 23 + repos: counter('repos'), 24 + stars: counter('stars'), 25 + strings: counter('strings'), 26 + followers: counter('followers'), 27 + following: counter('following'), 28 + vouches: counter('vouches') 29 + }; 30 + 31 + return { 32 + get did() { 33 + return did(); 34 + }, 35 + get value() { 36 + return { 37 + repos: counts.repos.value, 38 + stars: counts.stars.value, 39 + strings: counts.strings.value, 40 + followers: counts.followers.value, 41 + following: counts.following.value, 42 + vouches: counts.vouches.value 43 + }; 44 + }, 45 + adjust(subjectDid, name, delta) { 46 + if (subjectDid !== did()) return; 47 + counts[name].adjust(delta); 48 + } 49 + }; 50 + }; 51 + 52 + export const getProfileCounts = (): ProfileCountsContext | null => 53 + getContext<ProfileCountsContext | null>(PROFILE_COUNTS_KEY);
+32
web/src/lib/components/profile/tabs/OverviewTab.svelte
··· 1 + <script lang="ts"> 2 + import RepoCard from '$lib/components/repo/RepoCard.svelte'; 3 + import EmptyState from '../EmptyState.svelte'; 4 + import ActivityStub from '../ActivityStub.svelte'; 5 + import type { RepoCardData } from '../types'; 6 + 7 + interface Props { 8 + pinned: RepoCardData[]; 9 + } 10 + 11 + let { pinned }: Props = $props(); 12 + </script> 13 + 14 + <div class="grid gap-6 md:grid-cols-8"> 15 + <section class="order-1 md:col-span-4"> 16 + <h2 class="px-2 pb-4 text-base font-medium">Pinned repositories</h2> 17 + {#if pinned.length === 0} 18 + <EmptyState message="No pinned repositories." /> 19 + {:else} 20 + <div class="flex flex-col gap-4"> 21 + {#each pinned as repo (repo.rkey)} 22 + <RepoCard {repo} starButton={false} showOwner={false} /> 23 + {/each} 24 + </div> 25 + {/if} 26 + </section> 27 + 28 + <section class="order-2 md:col-span-4"> 29 + <h2 class="px-2 pb-4 text-base font-medium">Activity</h2> 30 + <ActivityStub /> 31 + </section> 32 + </div>
+26
web/src/lib/components/profile/tabs/PeopleTab.svelte
··· 1 + <script lang="ts"> 2 + import FollowCard from '../FollowCard.svelte'; 3 + import EmptyState from '../EmptyState.svelte'; 4 + import type { PersonData } from '../types'; 5 + 6 + interface Props { 7 + people: PersonData[]; 8 + title: string; 9 + emptyMessage: string; 10 + } 11 + 12 + let { people, title, emptyMessage }: Props = $props(); 13 + </script> 14 + 15 + <section> 16 + <h2 class="px-2 pb-4 text-base font-medium">{title}</h2> 17 + {#if people.length === 0} 18 + <EmptyState message={emptyMessage} /> 19 + {:else} 20 + <div class="flex flex-col gap-8"> 21 + {#each people as person (person.did)} 22 + <FollowCard {person} /> 23 + {/each} 24 + </div> 25 + {/if} 26 + </section>
+53
web/src/lib/components/profile/tabs/RepoListTab.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { resolve } from '$app/paths'; 4 + import RepoCard from '$lib/components/repo/RepoCard.svelte'; 5 + import EmptyState from '../EmptyState.svelte'; 6 + import type { RepoCardData } from '../types'; 7 + import Search from '$icon/search'; 8 + import X from '$icon/x'; 9 + 10 + let { repos }: { repos: RepoCardData[] } = $props(); 11 + let searchQuery = $derived(page.url.searchParams.get('q') ?? ''); 12 + </script> 13 + 14 + <section> 15 + <div class="mb-4"> 16 + <form class="relative flex" method="GET"> 17 + <input type="hidden" name="tab" value="repos" /> 18 + <div class="relative flex flex-1"> 19 + <input 20 + class="peer flex-1 rounded-l border border-border bg-surface py-1.5 pl-3 pr-10 outline-none focus:border-border-strong focus:ring-1 focus:ring-border-strong" 21 + type="text" 22 + name="q" 23 + value={searchQuery} 24 + placeholder="Search repositories..." 25 + /> 26 + <a 27 + href={resolve(`${page.url.pathname}?tab=repos` as '/')} 28 + class="absolute right-3 top-1/2 hidden -translate-y-1/2 text-fg-subtle hover:text-fg peer-[&:not(:placeholder-shown)]:block" 29 + > 30 + <X class="size-4" /> 31 + </a> 32 + </div> 33 + <button 34 + type="submit" 35 + class="rounded-r border border-l-0 border-border bg-surface p-2 text-fg-subtle hover:bg-surface-hover hover:text-fg" 36 + > 37 + <Search class="size-4" /> 38 + </button> 39 + </form> 40 + </div> 41 + 42 + {#if repos.length === 0} 43 + <EmptyState 44 + message={searchQuery ? `No repositories match "${searchQuery}".` : 'No repositories found.'} 45 + /> 46 + {:else} 47 + <div class="flex flex-col gap-4"> 48 + {#each repos as repo (repo.rkey)} 49 + <RepoCard {repo} showOwner={false} /> 50 + {/each} 51 + </div> 52 + {/if} 53 + </section>
+34
web/src/lib/components/profile/tabs/StarredTab.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import RepoCard from '$lib/components/repo/RepoCard.svelte'; 4 + import EmptyState from '../EmptyState.svelte'; 5 + import type { StarData } from '../types'; 6 + 7 + let { stars }: { stars: StarData[] } = $props(); 8 + </script> 9 + 10 + <section> 11 + <h2 class="px-2 pb-4 text-base font-medium">Starred</h2> 12 + {#if stars.length === 0} 13 + <EmptyState message="No stars yet." /> 14 + {:else} 15 + <div class="flex flex-col gap-4"> 16 + {#each stars as star (star.uri)} 17 + {#if star.kind === 'repo'} 18 + <RepoCard repo={star.repo} /> 19 + {:else} 20 + <div 21 + class="flex items-center gap-2 rounded border border-border bg-surface px-6 py-4 shadow-sm" 22 + > 23 + <a 24 + href={resolve(`/${star.ownerHandle}/strings/${star.rkey}` as '/')} 25 + class="truncate font-bold text-fg-strong no-underline hover:underline" 26 + > 27 + {star.ownerHandle}/{star.rkey} 28 + </a> 29 + </div> 30 + {/if} 31 + {/each} 32 + </div> 33 + {/if} 34 + </section>
+20
web/src/lib/components/profile/tabs/StringListTab.svelte
··· 1 + <script lang="ts"> 2 + import StringCard from '../StringCard.svelte'; 3 + import EmptyState from '../EmptyState.svelte'; 4 + import type { StringCardData } from '../types'; 5 + 6 + let { strings }: { strings: StringCardData[] } = $props(); 7 + </script> 8 + 9 + <section> 10 + <h2 class="px-2 pb-4 text-base font-medium">Strings</h2> 11 + {#if strings.length === 0} 12 + <EmptyState message="No strings yet." /> 13 + {:else} 14 + <div class="flex flex-col gap-4"> 15 + {#each strings as entry (entry.rkey)} 16 + <StringCard {entry} /> 17 + {/each} 18 + </div> 19 + {/if} 20 + </section>
+20
web/src/lib/components/profile/tabs/VouchTab.svelte
··· 1 + <script lang="ts"> 2 + import VouchCard from '../VouchCard.svelte'; 3 + import EmptyState from '../EmptyState.svelte'; 4 + import type { VouchData } from '../types'; 5 + 6 + let { vouches }: { vouches: VouchData[] } = $props(); 7 + </script> 8 + 9 + <section> 10 + <h2 class="px-2 pb-4 text-base font-medium">Vouches</h2> 11 + {#if vouches.length === 0} 12 + <EmptyState message="No vouches yet." /> 13 + {:else} 14 + <div class="flex flex-col gap-4"> 15 + {#each vouches as vouch (vouch.uri)} 16 + <VouchCard {vouch} /> 17 + {/each} 18 + </div> 19 + {/if} 20 + </section>
+68
web/src/lib/components/profile/types.ts
··· 1 + // view models produced by the profile route loads and consumed by the cards. 2 + 3 + export interface ProfileCounts { 4 + repos: number; 5 + stars: number; 6 + strings: number; 7 + followers: number; 8 + following: number; 9 + vouches: number; 10 + } 11 + 12 + export interface RepoCardData { 13 + rkey: string; 14 + name: string; 15 + repoDid: string; 16 + ownerHandle: string; 17 + description?: string; 18 + knot: string; 19 + createdAt: string; 20 + language?: string; 21 + stars?: number; 22 + forks?: number; 23 + issues?: number; 24 + pulls?: number; 25 + viewerStarRkey?: string | null; 26 + } 27 + 28 + export interface StringCardData { 29 + rkey: string; 30 + ownerHandle: string; 31 + filename: string; 32 + description: string; 33 + createdAt: string; 34 + lines: number; 35 + } 36 + 37 + export interface FollowChange { 38 + viewerDid: string; 39 + subjectDid: string; 40 + following: boolean; 41 + rkey: string | null; 42 + delta: 1 | -1; 43 + } 44 + 45 + export interface PersonData { 46 + did: string; 47 + handle: string; 48 + avatar?: string; 49 + description?: string; 50 + followers?: number; 51 + following?: number; 52 + viewerFollowRkey?: string | null; 53 + isSelf?: boolean; 54 + } 55 + 56 + export interface VouchData { 57 + uri: string; 58 + did: string; 59 + handle: string; 60 + avatar?: string; 61 + kind: 'vouch' | 'denounce'; 62 + reason?: string; 63 + createdAt: string; 64 + } 65 + 66 + export type StarData = 67 + | { kind: 'repo'; uri: string; createdAt: string; repo: RepoCardData } 68 + | { kind: 'string'; uri: string; createdAt: string; ownerHandle: string; rkey: string };
+75
web/src/lib/components/repo/RepoCard.svelte
··· 1 + <script lang="ts"> 2 + import { resolve } from '$app/paths'; 3 + import BookMarked from '$icon/book-marked'; 4 + import Star from '$icon/star'; 5 + import GitFork from '$icon/git-fork'; 6 + import CircleDot from '$icon/circle-dot'; 7 + import GitPullRequest from '$icon/git-pull-request'; 8 + import StarButton from './StarButton.svelte'; 9 + import type { RepoCardData } from '$lib/components/profile/types'; 10 + let { 11 + repo, 12 + starButton = true, 13 + showOwner = true 14 + }: { repo: RepoCardData; starButton?: boolean; showOwner?: boolean } = $props(); 15 + 16 + const stats = $derived( 17 + [ 18 + { value: repo.stars, Icon: Star }, 19 + { value: repo.forks, Icon: GitFork }, 20 + { value: repo.issues, Icon: CircleDot }, 21 + { value: repo.pulls, Icon: GitPullRequest } 22 + ].filter((s) => s.value !== undefined) 23 + ); 24 + </script> 25 + 26 + <div 27 + class="flex min-h-32 flex-col gap-1 rounded border border-border bg-surface px-6 py-4 shadow-sm" 28 + > 29 + <div class="flex items-start justify-between font-medium text-fg-strong"> 30 + <div class="mr-2 flex min-w-0 flex-1 items-center mt-1"> 31 + <BookMarked class="mr-1.5 size-4 shrink-0 text-fg-subtle" aria-hidden="true" /> 32 + <a 33 + href={resolve(`/${repo.ownerHandle}/${repo.name}` as '/')} 34 + class="min-w-0 truncate text-fg-strong no-underline hover:underline" 35 + > 36 + {showOwner ? `${repo.ownerHandle}/${repo.name}` : repo.name} 37 + </a> 38 + </div> 39 + {#if starButton} 40 + <div class="ml-4 shrink-0"> 41 + <StarButton 42 + repoDid={repo.repoDid} 43 + repoOwnerHandle={repo.ownerHandle} 44 + repoName={repo.name} 45 + initialCount={repo.stars} 46 + initialRkey={repo.viewerStarRkey} 47 + /> 48 + </div> 49 + {/if} 50 + </div> 51 + 52 + {#if repo.description} 53 + <p class="line-clamp-2 text-sm text-fg-muted">{repo.description}</p> 54 + {/if} 55 + 56 + {#if repo.language !== undefined || stats.length > 0} 57 + <div class="mt-auto flex flex-wrap items-center gap-4 pt-2 font-mono text-xs text-fg-subtle"> 58 + {#if repo.language} 59 + <span class="flex items-center gap-2"> 60 + <span 61 + class="inline-block size-2.5 shrink-0 rounded-full" 62 + style="background-color: var(--color-primary);" 63 + ></span> 64 + <span>{repo.language}</span> 65 + </span> 66 + {/if} 67 + {#each stats as stat (stat.Icon)} 68 + <span class="flex items-center gap-1"> 69 + <stat.Icon class="size-3.5 shrink-0" aria-hidden="true" /> 70 + <span>{stat.value}</span> 71 + </span> 72 + {/each} 73 + </div> 74 + {/if} 75 + </div>
+92
web/src/lib/components/repo/StarButton.svelte
··· 1 + <script lang="ts"> 2 + import Star from '$icon/star'; 3 + import { getAuth } from '$lib/auth.svelte'; 4 + import { createStar, deleteStar } from '$lib/api/graph'; 5 + import Button from '$lib/components/ui/Button.svelte'; 6 + import { getProfileCounts } from '$lib/components/profile/counts.svelte'; 7 + import { createOptimisticRelation, createOptimisticCount } from '$lib/optimistic.svelte'; 8 + 9 + interface Props { 10 + repoDid: string; 11 + repoOwnerHandle: string; 12 + repoName: string; 13 + initialCount?: number; 14 + initialRkey?: string | null; 15 + } 16 + 17 + let { repoDid, repoOwnerHandle, repoName, initialCount, initialRkey }: Props = $props(); 18 + 19 + const auth = getAuth(); 20 + const profileCounts = getProfileCounts(); 21 + const signedIn = $derived(Boolean(auth.currentDid)); 22 + const relation = createOptimisticRelation({ 23 + key: () => `${auth.currentDid ?? ''}:${repoDid}`, 24 + loadedRkey: () => initialRkey 25 + }); 26 + const starCount = createOptimisticCount({ 27 + key: () => repoDid, 28 + loaded: () => initialCount 29 + }); 30 + 31 + let busy = $state(false); 32 + const starred = $derived(relation.active); 33 + const failed = $derived(relation.failed || starCount.failed); 34 + 35 + const toggle = async () => { 36 + const agent = auth.agent; 37 + if (!agent || busy || !relation.known || !repoDid) return; 38 + busy = true; 39 + relation.resetFailure(); 40 + starCount.resetFailure(); 41 + try { 42 + if (relation.active && relation.rkey) { 43 + await deleteStar(agent, relation.rkey); 44 + relation.deleted(); 45 + starCount.adjust(-1); 46 + profileCounts?.adjust(agent.sub, 'stars', -1); 47 + } else { 48 + const rkey = await createStar(agent, repoDid); 49 + relation.created(rkey); 50 + starCount.adjust(1); 51 + profileCounts?.adjust(agent.sub, 'stars', 1); 52 + } 53 + } catch { 54 + relation.fail(); 55 + starCount.fail(); 56 + } finally { 57 + busy = false; 58 + } 59 + }; 60 + </script> 61 + 62 + {#if signedIn && repoDid} 63 + <div class="flex flex-col items-end"> 64 + <div 65 + class="inline-flex max-h-8 items-stretch divide-x divide-border overflow-clip rounded border border-border" 66 + > 67 + <Button 68 + variant="default" 69 + size="sm" 70 + class="min-h-[30px] flex-1 rounded-none border-0 before:rounded-none before:rounded-l-sm" 71 + loading={busy} 72 + disabled={!relation.known} 73 + onclick={toggle} 74 + > 75 + <Star class={`size-4 shrink-0 ${starred ? 'fill-current' : ''}`} aria-hidden="true" /> 76 + <span>{starred ? 'Unstar' : 'Star'}</span> 77 + </Button> 78 + <Button 79 + href={`/${repoOwnerHandle}/${repoName}/stars`} 80 + variant="flat" 81 + size="sm" 82 + class="min-h-[30px] rounded-none" 83 + title="Starred by" 84 + > 85 + {starCount.value} 86 + </Button> 87 + </div> 88 + {#if failed} 89 + <p class="mt-1 text-xs text-danger">Something went wrong. Try again.</p> 90 + {/if} 91 + </div> 92 + {/if}
+27
web/src/lib/components/ui/Avatar.svelte
··· 1 + <script lang="ts"> 2 + import UserRound from '$icon/user-round'; 3 + 4 + interface Props { 5 + src?: string; 6 + handle?: string; 7 + size?: string; 8 + class?: string; 9 + } 10 + 11 + let { src, handle, size = 'size-10', class: className = '' }: Props = $props(); 12 + </script> 13 + 14 + {#if src} 15 + <img 16 + {src} 17 + alt={handle ? `${handle}'s avatar` : 'avatar'} 18 + class={`${size} shrink-0 rounded-full border border-border object-cover ${className}`} 19 + /> 20 + {:else} 21 + <span 22 + class={`${size} flex shrink-0 items-center justify-center rounded-full border border-border bg-surface-muted text-fg-subtle ${className}`} 23 + aria-hidden="true" 24 + > 25 + <UserRound class="size-1/2" /> 26 + </span> 27 + {/if}
+1
web/src/lib/components/ui/Button.svelte
··· 10 10 primary: 11 11 'border border-primary-border bg-primary text-primary-fg hover:bg-primary-hover focus-visible:outline-primary', 12 12 ghost: 'bg-transparent text-fg-muted hover:bg-surface-muted focus-visible:outline-ring', 13 + flat: 'bg-gray-50 text-fg hover:bg-gray-100 focus-visible:outline-ring dark:bg-gray-900 dark:hover:bg-gray-700', 13 14 danger: 14 15 'border border-danger-border bg-danger text-danger-fg hover:bg-danger-hover focus-visible:outline-danger', 15 16 success:
+50
web/src/lib/format.ts
··· 1 + const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ 2 + { amount: 60, unit: 'seconds' }, 3 + { amount: 60, unit: 'minutes' }, 4 + { amount: 24, unit: 'hours' }, 5 + { amount: 7, unit: 'days' }, 6 + { amount: 4.34524, unit: 'weeks' }, 7 + { amount: 12, unit: 'months' }, 8 + { amount: Number.POSITIVE_INFINITY, unit: 'years' } 9 + ]; 10 + 11 + const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); 12 + const dtf = new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }); 13 + 14 + // "3 days ago", "in 2 hours", etc. 15 + export const relativeTime = (input: string | Date, now: Date = new Date()): string => { 16 + const date = typeof input === 'string' ? new Date(input) : input; 17 + if (Number.isNaN(date.getTime())) return ''; 18 + let duration = (date.getTime() - now.getTime()) / 1000; 19 + for (const division of DIVISIONS) { 20 + if (Math.abs(duration) < division.amount) 21 + return rtf.format(Math.round(duration), division.unit); 22 + duration /= division.amount; 23 + } 24 + return rtf.format(Math.round(duration), 'years'); 25 + }; 26 + export const compactRelativeTime = (input: string | Date, now: Date = new Date()): string => { 27 + const date = typeof input === 'string' ? new Date(input) : input; 28 + if (Number.isNaN(date.getTime())) return ''; 29 + let duration = Math.abs((now.getTime() - date.getTime()) / 1000); 30 + const suffix = date.getTime() > now.getTime() ? 'from now' : 'ago'; 31 + 32 + if (duration < 60) return `${Math.floor(duration)}s ${suffix}`; 33 + duration /= 60; 34 + if (duration < 60) return `${Math.floor(duration)}m ${suffix}`; 35 + duration /= 60; 36 + if (duration < 24) return `${Math.floor(duration)}h ${suffix}`; 37 + duration /= 24; 38 + if (duration < 7) return `${Math.floor(duration)}d ${suffix}`; 39 + duration /= 7; 40 + if (duration < 4.34524) return `${Math.floor(duration)}w ${suffix}`; 41 + duration /= 4.34524; 42 + if (duration < 12) return `${Math.floor(duration)}mo ${suffix}`; 43 + duration /= 12; 44 + return `${Math.floor(duration)}y ${suffix}`; 45 + }; 46 + 47 + export const formatDate = (input: string | Date): string => { 48 + const date = typeof input === 'string' ? new Date(input) : input; 49 + return Number.isNaN(date.getTime()) ? '' : dtf.format(date); 50 + };
-5
web/src/lib/server/bobbin.ts
··· 1 - import { createBobbinClient, type BobbinContext } from '$lib/api/client'; 2 - import { getConfig } from './config'; 3 - 4 - export const serverBobbin = (event: { fetch: typeof globalThis.fetch }): BobbinContext => 5 - createBobbinClient({ serviceUrl: getConfig().bobbinUrl, fetch: event.fetch });
+4 -1
web/src/routes/+layout.svelte
··· 8 8 9 9 let { children, data } = $props(); 10 10 11 - const auth = createAuth(untrack(() => data.auth)); 11 + const auth = createAuth( 12 + data.publicConfig.bobbinUrl, 13 + untrack(() => data.auth) 14 + ); 12 15 setContext(AUTH_KEY, auth); 13 16 14 17 onMount(() => {
+47
web/src/routes/[handle]/+layout.svelte
··· 1 + <script lang="ts"> 2 + import { page } from '$app/state'; 3 + import { setContext } from 'svelte'; 4 + import { createProfileCounts, PROFILE_COUNTS_KEY } from '$lib/components/profile/counts.svelte'; 5 + import EmptyState from '$lib/components/profile/EmptyState.svelte'; 6 + import ProfileCard from '$lib/components/profile/ProfileCard.svelte'; 7 + import ProfileTabs from '$lib/components/profile/ProfileTabs.svelte'; 8 + 9 + let { children, data } = $props(); 10 + 11 + const profileCounts = createProfileCounts( 12 + () => data.identity.did, 13 + () => data.counts 14 + ); 15 + setContext(PROFILE_COUNTS_KEY, profileCounts); 16 + const counts = $derived(profileCounts.value); 17 + const tabParam = $derived(page.url.searchParams.get('tab') ?? 'overview'); 18 + const activeTab = $derived( 19 + ['repos', 'starred', 'strings', 'vouches'].includes(tabParam) ? tabParam : 'overview' 20 + ); 21 + </script> 22 + 23 + <svelte:head> 24 + <title>{data.identity.handle} &middot; Tangled</title> 25 + </svelte:head> 26 + 27 + <section class="mx-auto w-full max-w-screen-lg py-6"> 28 + {#if data.notJoined} 29 + <EmptyState message={`${data.identity.handle} hasn't joined Tangled yet.`} /> 30 + {:else} 31 + <ProfileTabs handle={data.identity.handle} active={activeTab} {counts} /> 32 + <div class="grid rounded bg-surface shadow-sm md:grid-cols-11 md:gap-4 md:p-6"> 33 + <aside class="p-6 md:col-span-3 md:p-0"> 34 + <ProfileCard 35 + identity={data.identity} 36 + profile={data.profile} 37 + followers={counts.followers} 38 + following={counts.following} 39 + viewerFollowRkey={data.viewerFollowRkey} 40 + /> 41 + </aside> 42 + <div class="min-w-0 border-t border-border p-6 md:col-span-8 md:border-t-0 md:p-0"> 43 + {@render children()} 44 + </div> 45 + </div> 46 + {/if} 47 + </section>
+76
web/src/routes/[handle]/+layout.ts
··· 1 + import { error, redirect } from '@sveltejs/kit'; 2 + import { createBobbinClient } from '$lib/api/client'; 3 + import { resolveMiniDoc } from '$lib/api/identity'; 4 + import { getProfile, type ProfileRecord } from '$lib/api/records'; 5 + import { count } from '$lib/api/count'; 6 + import { parallel, toHttpError, httpStatusFor } from '$lib/api/load'; 7 + import { ClientResponseError } from '$lib/api/client'; 8 + import { findFollowRkey } from '$lib/api/graph'; 9 + import type { ProfileCounts } from '$lib/components/profile/types'; 10 + import type { LayoutLoad } from './$types'; 11 + 12 + export const load: LayoutLoad = async (event) => { 13 + const parent = await event.parent(); 14 + const identifier = decodeURIComponent(event.params.handle); 15 + 16 + // actor identifiers are dids or dotted handles; reject bare words early so 17 + // unrelated paths (/settings, /signup, ...) 404 instead of resolving. 18 + if (!identifier.startsWith('did:') && !identifier.includes('.')) { 19 + error(404, 'Not found'); 20 + } 21 + 22 + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 23 + const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => 24 + toHttpError(cause, 'Could not resolve user') 25 + ); 26 + 27 + // canonical url is the handle; redirect dids and stale handles. 28 + const canonical = doc.handle && !doc.handle.endsWith('.invalid') ? doc.handle : null; 29 + if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { 30 + redirect(307, `/${canonical}${event.url.search}`); 31 + } 32 + 33 + const did = doc.did; 34 + const viewerDid = parent.auth?.did; 35 + 36 + let profile: ProfileRecord | null = null; 37 + try { 38 + profile = (await getProfile(ctx, did)).value; 39 + } catch (cause) { 40 + if (!(cause instanceof ClientResponseError && httpStatusFor(cause) === 404)) { 41 + toHttpError(cause, 'Could not load profile'); 42 + } 43 + } 44 + 45 + const raw = await parallel({ 46 + repos: count(ctx, 'sh.tangled.repo.countRepos', did), 47 + strings: count(ctx, 'sh.tangled.string.countStrings', did), 48 + stars: count(ctx, 'sh.tangled.feed.countStarsBy', did), 49 + followers: count(ctx, 'sh.tangled.graph.countFollows', did), 50 + following: count(ctx, 'sh.tangled.graph.countFollowsBy', did), 51 + vouches: count(ctx, 'sh.tangled.graph.countVouches', did), 52 + viewerFollowRkey: 53 + viewerDid && viewerDid !== did 54 + ? findFollowRkey(ctx, viewerDid, did).catch(() => null) 55 + : Promise.resolve(null) 56 + }); 57 + 58 + const counts: ProfileCounts = { 59 + repos: raw.repos.count, 60 + strings: raw.strings.count, 61 + stars: raw.stars.count, 62 + followers: raw.followers.count, 63 + following: raw.following.count, 64 + vouches: raw.vouches.count 65 + }; 66 + 67 + const notJoined = !profile && Object.values(counts).every((n) => n === 0); 68 + 69 + return { 70 + identity: { did, handle: doc.handle, avatar: doc.avatar }, 71 + profile, 72 + counts, 73 + viewerFollowRkey: raw.viewerFollowRkey, 74 + notJoined 75 + }; 76 + };
+26
web/src/routes/[handle]/+page.svelte
··· 1 + <script lang="ts"> 2 + import OverviewTab from '$lib/components/profile/tabs/OverviewTab.svelte'; 3 + import PeopleTab from '$lib/components/profile/tabs/PeopleTab.svelte'; 4 + import RepoListTab from '$lib/components/profile/tabs/RepoListTab.svelte'; 5 + import StarredTab from '$lib/components/profile/tabs/StarredTab.svelte'; 6 + import StringListTab from '$lib/components/profile/tabs/StringListTab.svelte'; 7 + import VouchTab from '$lib/components/profile/tabs/VouchTab.svelte'; 8 + 9 + let { data } = $props(); 10 + </script> 11 + 12 + {#if data.tab === 'overview'} 13 + <OverviewTab pinned={data.overview.pinned} /> 14 + {:else if data.tab === 'repos'} 15 + <RepoListTab repos={data.repos} /> 16 + {:else if data.tab === 'starred'} 17 + <StarredTab stars={data.stars} /> 18 + {:else if data.tab === 'strings'} 19 + <StringListTab strings={data.strings} /> 20 + {:else if data.tab === 'followers'} 21 + <PeopleTab people={data.people} title="Followers" emptyMessage="No followers yet." /> 22 + {:else if data.tab === 'following'} 23 + <PeopleTab people={data.people} title="Following" emptyMessage="Not following anyone yet." /> 24 + {:else if data.tab === 'vouches'} 25 + <VouchTab vouches={data.vouches} /> 26 + {/if}
+325
web/src/routes/[handle]/+page.ts
··· 1 + import type { Did } from '@atcute/lexicons/syntax'; 2 + import { createBobbinClient } from '$lib/api/client'; 3 + import { fetchPage, items } from '$lib/api/pagination'; 4 + import { count } from '$lib/api/count'; 5 + import { getRepoByRepoDid, type RepoRecord } from '$lib/api/records'; 6 + import { IdentityCache } from '$lib/api/identity'; 7 + import { didFromUri, rkeyFromUri } from '$lib/api/uri'; 8 + import { toHttpError, parallel } from '$lib/api/load'; 9 + import { search } from '$lib/api/search'; 10 + import type { BobbinContext } from '$lib/api/client'; 11 + import { listStarRkeys, type VouchRecord, type FollowRecord } from '$lib/api/graph'; 12 + import type * as ShTangledFeedStar from '$lib/api/lexicons/types/sh/tangled/feed/star'; 13 + import type * as ShTangledString from '$lib/api/lexicons/types/sh/tangled/string'; 14 + import type * as ShTangledGraphFollow from '$lib/api/lexicons/types/sh/tangled/graph/follow'; 15 + import type { 16 + RepoCardData, 17 + StringCardData, 18 + PersonData, 19 + VouchData, 20 + StarData 21 + } from '$lib/components/profile/types'; 22 + import type { PageLoad } from './$types'; 23 + 24 + const PAGE_LIMIT = 50; 25 + 26 + const TABS = [ 27 + 'overview', 28 + 'repos', 29 + 'starred', 30 + 'strings', 31 + 'followers', 32 + 'following', 33 + 'vouches' 34 + ] as const; 35 + type Tab = (typeof TABS)[number]; 36 + 37 + const normalizeTab = (raw: string | null): Tab => 38 + TABS.includes(raw as Tab) ? (raw as Tab) : 'overview'; 39 + 40 + interface ListItem { 41 + uri: string; 42 + value: unknown; 43 + } 44 + 45 + const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { 46 + const value = item.value as RepoRecord; 47 + return { 48 + rkey: rkeyFromUri(item.uri), 49 + name: value.name ?? rkeyFromUri(item.uri), 50 + repoDid: value.repoDid ?? '', 51 + ownerHandle, 52 + description: value.description, 53 + knot: value.knot, 54 + createdAt: value.createdAt 55 + }; 56 + }; 57 + 58 + interface ResolveRepoCardOptions { 59 + viewerStarRkeys?: ReadonlyMap<string, string>; 60 + } 61 + 62 + const resolveRepoCard = async ( 63 + ctx: BobbinContext, 64 + item: ListItem, 65 + ownerHandle: string, 66 + options: ResolveRepoCardOptions = {} 67 + ): Promise<RepoCardData> => { 68 + const repo = toRepoCard(item, ownerHandle); 69 + if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; 70 + // TODO(bobbin): instead of doing this, listing repos should return star counts 71 + // and most likely other stats as well. 72 + const stars = await count(ctx, 'sh.tangled.feed.countStars', repo.repoDid); 73 + return { 74 + ...repo, 75 + stars: stars.count, 76 + viewerStarRkey: options.viewerStarRkeys 77 + ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) 78 + : undefined 79 + }; 80 + }; 81 + 82 + const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { 83 + const value = item.value as ShTangledString.Main; 84 + return { 85 + rkey: rkeyFromUri(item.uri), 86 + ownerHandle, 87 + filename: value.filename, 88 + description: value.description, 89 + createdAt: value.createdAt, 90 + lines: value.contents?.split('\n').length ?? 1 91 + }; 92 + }; 93 + 94 + // resolve dids -> handle/avatar, deduped, preserving input order. 95 + const resolvePeople = async ( 96 + ctx: BobbinContext, 97 + dids: string[], 98 + viewerDid?: string 99 + ): Promise<PersonData[]> => { 100 + const cache = new IdentityCache(ctx); 101 + const unique = [...new Set(dids)]; 102 + 103 + const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); 104 + // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. 105 + const counts = await parallel( 106 + unique.reduce( 107 + (acc, did) => { 108 + acc[`${did}-followers`] = count(ctx, 'sh.tangled.graph.countFollows', did) 109 + .then((result) => result.count) 110 + .catch(() => 0); 111 + acc[`${did}-following`] = count(ctx, 'sh.tangled.graph.countFollowsBy', did) 112 + .then((result) => result.count) 113 + .catch(() => 0); 114 + return acc; 115 + }, 116 + {} as Record<string, Promise<number>> 117 + ) 118 + ); 119 + 120 + const viewerFollowRkeys = new Map<string, string>(); 121 + if (viewerDid) { 122 + for await (const item of items( 123 + ctx, 124 + 'sh.tangled.graph.listFollowsBy', 125 + { subject: viewerDid as Did }, 126 + { maxPages: 10 } 127 + )) { 128 + const value = item.value as FollowRecord; 129 + viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); 130 + } 131 + } 132 + 133 + const byDid = new Map<string, PersonData>(); 134 + unique.forEach((did, index) => { 135 + const doc = docs[index]; 136 + const followers = counts[`${did}-followers`]; 137 + const following = counts[`${did}-following`]; 138 + const isSelf = viewerDid === did; 139 + const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; 140 + byDid.set( 141 + did, 142 + doc 143 + ? { 144 + did: doc.did, 145 + handle: doc.handle, 146 + avatar: doc.avatar, 147 + followers, 148 + following, 149 + isSelf, 150 + viewerFollowRkey 151 + } 152 + : { did, handle: did, followers, following, isSelf, viewerFollowRkey } 153 + ); 154 + }); 155 + return unique.map((did) => byDid.get(did) as PersonData); 156 + }; 157 + 158 + const resolveVouches = async (ctx: BobbinContext, items: ListItem[]): Promise<VouchData[]> => { 159 + const cache = new IdentityCache(ctx); 160 + return Promise.all( 161 + items.map(async (item): Promise<VouchData> => { 162 + const value = item.value as VouchRecord; 163 + const voucher = didFromUri(item.uri); 164 + const doc = await cache.resolve(voucher).catch(() => null); 165 + return { 166 + uri: item.uri, 167 + did: voucher, 168 + handle: doc?.handle ?? voucher, 169 + avatar: doc?.avatar, 170 + kind: value.kind === 'denounce' ? 'denounce' : 'vouch', 171 + reason: value.reason, 172 + createdAt: value.createdAt 173 + }; 174 + }) 175 + ); 176 + }; 177 + 178 + const resolveStars = async ( 179 + ctx: BobbinContext, 180 + items: ListItem[], 181 + options: ResolveRepoCardOptions 182 + ): Promise<StarData[]> => { 183 + const cache = new IdentityCache(ctx); 184 + const resolved = await Promise.all( 185 + items.map(async (item): Promise<StarData | null> => { 186 + const value = item.value as ShTangledFeedStar.Main; 187 + const subject = value.subject; 188 + if (subject && 'did' in subject && subject.did) { 189 + try { 190 + const repo = await getRepoByRepoDid(ctx, subject.did); 191 + const ownerDid = didFromUri(repo.uri); 192 + const owner = await cache.resolve(ownerDid).catch(() => null); 193 + return { 194 + kind: 'repo', 195 + uri: item.uri, 196 + createdAt: value.createdAt, 197 + repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) 198 + }; 199 + } catch { 200 + return null; 201 + } 202 + } 203 + if (subject && 'uri' in subject && subject.uri) { 204 + const ownerDid = didFromUri(subject.uri); 205 + const owner = await cache.resolve(ownerDid).catch(() => null); 206 + return { 207 + kind: 'string', 208 + uri: item.uri, 209 + createdAt: value.createdAt, 210 + ownerHandle: owner?.handle ?? ownerDid, 211 + rkey: rkeyFromUri(subject.uri) 212 + }; 213 + } 214 + return null; 215 + }) 216 + ); 217 + return resolved.filter((star): star is StarData => star !== null); 218 + }; 219 + 220 + export const load: PageLoad = async (event) => { 221 + const parent = await event.parent(); 222 + const tab = normalizeTab(event.url.searchParams.get('tab')); 223 + 224 + if (parent.notJoined) return { tab: 'overview' as const, overview: { pinned: [] } }; 225 + 226 + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); 227 + const did = parent.identity.did as Did; 228 + const handle = parent.identity.handle; 229 + 230 + try { 231 + switch (tab) { 232 + case 'repos': { 233 + const q = event.url.searchParams.get('q')?.trim(); 234 + const [found, viewerStarRkeys] = await Promise.all([ 235 + q 236 + ? search(ctx, { q, nsid: 'sh.tangled.repo', author: did, limit: PAGE_LIMIT }).then( 237 + (page) => page.hits 238 + ) 239 + : fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }).then( 240 + (page) => page.items 241 + ), 242 + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 243 + ]); 244 + return { 245 + tab, 246 + repos: await Promise.all( 247 + found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 248 + ) 249 + }; 250 + } 251 + case 'strings': { 252 + const page = await fetchPage(ctx, 'sh.tangled.string.listStrings', { 253 + subject: did, 254 + limit: PAGE_LIMIT 255 + }); 256 + return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; 257 + } 258 + case 'followers': { 259 + const page = await fetchPage(ctx, 'sh.tangled.graph.listFollows', { 260 + subject: did, 261 + limit: PAGE_LIMIT 262 + }); 263 + const dids = page.items.map((item) => didFromUri(item.uri)); 264 + return { 265 + tab, 266 + people: await resolvePeople(ctx, dids, parent.auth?.did) 267 + }; 268 + } 269 + case 'following': { 270 + const page = await fetchPage(ctx, 'sh.tangled.graph.listFollowsBy', { 271 + subject: did, 272 + limit: PAGE_LIMIT 273 + }); 274 + const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); 275 + return { 276 + tab, 277 + people: await resolvePeople(ctx, dids, parent.auth?.did) 278 + }; 279 + } 280 + case 'vouches': { 281 + const page = await fetchPage(ctx, 'sh.tangled.graph.listVouches', { 282 + subject: did, 283 + limit: PAGE_LIMIT 284 + }); 285 + return { tab, vouches: await resolveVouches(ctx, page.items) }; 286 + } 287 + case 'starred': { 288 + const [page, viewerStarRkeys] = await Promise.all([ 289 + fetchPage(ctx, 'sh.tangled.feed.listStarsBy', { subject: did, limit: PAGE_LIMIT }), 290 + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 291 + ]); 292 + return { 293 + tab, 294 + stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) 295 + }; 296 + } 297 + case 'overview': 298 + default: { 299 + const [page, viewerStarRkeys] = await Promise.all([ 300 + fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }), 301 + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined 302 + ]); 303 + 304 + const pinnedKeys = parent.profile?.pinnedRepositories ?? []; 305 + const byKey = new Map<string, ListItem>(); 306 + for (const item of page.items) { 307 + const value = item.value as RepoRecord; 308 + if (value.repoDid) byKey.set(value.repoDid, item); 309 + byKey.set(item.uri, item); 310 + } 311 + const pinnedItems = pinnedKeys 312 + .map((key) => byKey.get(key)) 313 + .filter((item): item is ListItem => item !== undefined); 314 + const pinned = await Promise.all( 315 + pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) 316 + ); 317 + 318 + return { tab: 'overview' as const, overview: { pinned } }; 319 + } 320 + } 321 + } catch (cause) { 322 + console.error('Page load error:', cause); 323 + toHttpError(cause, 'Could not load profile data'); 324 + } 325 + };
-1
web/vite.config.ts
··· 19 19 process.env.VITE_OAUTH_REDIRECT_URI ??= 20 20 command === 'serve' ? devRedirectUri : oauthMetadata.redirect_uris[0]; 21 21 process.env.VITE_OAUTH_SCOPE ??= oauthMetadata.scope; 22 - process.env.VITE_TANGLED_APPVIEW_SERVICE ??= 'https://bobbin.klbr.net'; 23 22 } 24 23 }, 25 24 tailwindcss(),