From 98fdc4b6cb0e84ee25da499ca54c7705e2f859af Mon Sep 17 00:00:00 2001 From: dawn Date: Thu, 9 Jul 2026 21:10:00 +0300 Subject: [PATCH] web/components/profile: implement profile page Signed-off-by: dawn --- web/src/hooks.server.ts | 15 + web/src/lib/auth.svelte.ts | 38 +- .../components/profile/ActivityStub.svelte | 3 + .../lib/components/profile/EmptyState.svelte | 16 + .../components/profile/FollowButton.svelte | 100 ++++++ .../lib/components/profile/FollowCard.svelte | 66 ++++ .../profile/FollowerFollowing.svelte | 29 ++ .../lib/components/profile/ProfileCard.svelte | 157 +++++++++ .../components/profile/ProfileEditForm.svelte | 130 +++++++ .../lib/components/profile/ProfileTabs.svelte | 65 ++++ .../lib/components/profile/StringCard.svelte | 34 ++ .../lib/components/profile/VouchCard.svelte | 37 ++ .../lib/components/profile/counts.svelte.ts | 53 +++ .../profile/tabs/OverviewTab.svelte | 32 ++ .../components/profile/tabs/PeopleTab.svelte | 26 ++ .../profile/tabs/RepoListTab.svelte | 53 +++ .../components/profile/tabs/StarredTab.svelte | 34 ++ .../profile/tabs/StringListTab.svelte | 20 ++ .../components/profile/tabs/VouchTab.svelte | 20 ++ web/src/lib/components/profile/types.ts | 68 ++++ web/src/lib/components/repo/RepoCard.svelte | 75 ++++ web/src/lib/components/repo/StarButton.svelte | 92 +++++ web/src/lib/components/ui/Avatar.svelte | 27 ++ web/src/lib/components/ui/Button.svelte | 1 + web/src/lib/format.ts | 50 +++ web/src/lib/server/bobbin.ts | 5 - web/src/routes/+layout.svelte | 5 +- web/src/routes/[handle]/+layout.svelte | 47 +++ web/src/routes/[handle]/+layout.ts | 76 ++++ web/src/routes/[handle]/+page.svelte | 26 ++ web/src/routes/[handle]/+page.ts | 325 ++++++++++++++++++ web/vite.config.ts | 1 - 32 files changed, 1707 insertions(+), 19 deletions(-) create mode 100644 web/src/hooks.server.ts create mode 100644 web/src/lib/components/profile/ActivityStub.svelte create mode 100644 web/src/lib/components/profile/EmptyState.svelte create mode 100644 web/src/lib/components/profile/FollowButton.svelte create mode 100644 web/src/lib/components/profile/FollowCard.svelte create mode 100644 web/src/lib/components/profile/FollowerFollowing.svelte create mode 100644 web/src/lib/components/profile/ProfileCard.svelte create mode 100644 web/src/lib/components/profile/ProfileEditForm.svelte create mode 100644 web/src/lib/components/profile/ProfileTabs.svelte create mode 100644 web/src/lib/components/profile/StringCard.svelte create mode 100644 web/src/lib/components/profile/VouchCard.svelte create mode 100644 web/src/lib/components/profile/counts.svelte.ts create mode 100644 web/src/lib/components/profile/tabs/OverviewTab.svelte create mode 100644 web/src/lib/components/profile/tabs/PeopleTab.svelte create mode 100644 web/src/lib/components/profile/tabs/RepoListTab.svelte create mode 100644 web/src/lib/components/profile/tabs/StarredTab.svelte create mode 100644 web/src/lib/components/profile/tabs/StringListTab.svelte create mode 100644 web/src/lib/components/profile/tabs/VouchTab.svelte create mode 100644 web/src/lib/components/profile/types.ts create mode 100644 web/src/lib/components/repo/RepoCard.svelte create mode 100644 web/src/lib/components/repo/StarButton.svelte create mode 100644 web/src/lib/components/ui/Avatar.svelte create mode 100644 web/src/lib/format.ts delete mode 100644 web/src/lib/server/bobbin.ts create mode 100644 web/src/routes/[handle]/+layout.svelte create mode 100644 web/src/routes/[handle]/+layout.ts create mode 100644 web/src/routes/[handle]/+page.svelte create mode 100644 web/src/routes/[handle]/+page.ts diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts new file mode 100644 index 00000000..9accbff7 --- /dev/null +++ b/web/src/hooks.server.ts @@ -0,0 +1,15 @@ +import type { Handle } from '@sveltejs/kit'; + +export const handle: Handle = async ({ event, resolve }) => { + return resolve(event, { + // sveltekit blocks atcute fetch handler from reading headers + // because it assumes backend APIs might return sensitive headers. + // this happens when during CSR we run a fetch that was the same + // as one ran during SSR, so sveltekit tries to give that fetch + // the data we already had. + // so we allow these headers to have atcute function properly. + filterSerializedResponseHeaders(name) { + return name === 'content-type' || name === 'content-length'; + } + }); +}; diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts index 5f5c869a..99a0056e 100644 --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -32,17 +32,23 @@ import { } from './auth/accounts'; export const AUTH_KEY = Symbol('auth'); -const DEFAULT_APPVIEW_SERVICE = 'https://bobbin.klbr.net'; const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; +// local dev (localinfra) points these at the local pds/plc; defaults are the public network. +const HANDLE_RESOLVER_URL = + (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, '') ?? + 'https://public.api.bsky.app'; +const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( + /\/+$/, + '' +); + const identityResolver = new LocalActorResolver({ - handleResolver: new XrpcHandleResolver({ - serviceUrl: 'https://public.api.bsky.app' - }), + handleResolver: new XrpcHandleResolver({ serviceUrl: HANDLE_RESOLVER_URL }), didDocumentResolver: new CompositeDidDocumentResolver({ methods: { - plc: new PlcDidDocumentResolver(), + plc: new PlcDidDocumentResolver(PLC_DIRECTORY_URL ? { apiUrl: PLC_DIRECTORY_URL } : {}), web: new WebDidDocumentResolver() } }) @@ -73,6 +79,7 @@ export interface Auth { readonly authenticating: boolean; readonly currentUser: CurrentUser | null; readonly accounts: AuthAccount[]; + bobbinUrl: string; refresh(): Promise; signIn(identifier: string, returnTo?: string): Promise; addAccount(identifier: string, returnTo?: string): Promise; @@ -101,9 +108,6 @@ const OAUTH_REDIRECT_URI = HAS_LOCALHOST_REDIRECT ? DEV_REDIRECT_URI : (ENV_OAUTH_REDIRECT_URI ?? DEV_REDIRECT_URI); const OAUTH_SCOPE = (import.meta.env.VITE_OAUTH_SCOPE as string | undefined) ?? oauthMetadata.scope; -const APPVIEW_SERVICE = - (import.meta.env.VITE_TANGLED_APPVIEW_SERVICE as string | undefined)?.replace(/\/+$/, '') ?? - DEFAULT_APPVIEW_SERVICE; const configure = () => { if (!browser || configured) return; @@ -126,9 +130,12 @@ const errorMessage = (cause: unknown) => { : message; }; -const resolveProfile = async (identifier: string): Promise => { +const resolveProfile = async ( + identifier: string, + bobbinUrl: string +): Promise => { try { - const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', APPVIEW_SERVICE); + const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', bobbinUrl); url.searchParams.set('identifier', identifier); const response = await fetch(url, { headers: { accept: 'application/json' } }); if (response.ok) { @@ -162,9 +169,13 @@ const returnToFromState = (state: object | null): string => { return '/'; }; -export const createAuth = (initial?: { did: string; handle: string } | null): Auth => { +export const createAuth = ( + bobbinUrl: string, + initial?: { did: string; handle: string } | null +): Auth => { const seed = initial ?? null; let agent = $state(null); + const bobbinUrlValue = bobbinUrl; let currentDid = $state((seed?.did as Did | undefined) ?? null); let profile = $state( seed ? { did: seed.did as Did, handle: seed.handle } : null @@ -188,7 +199,7 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au }; const hydrateProfile = async (did: Did) => { - const resolved = await resolveProfile(did); + const resolved = await resolveProfile(did, bobbinUrl); profile = resolved ?? { did, handle: did }; const meta = upsertAccount(loadAccounts(), { did, @@ -369,6 +380,9 @@ export const createAuth = (initial?: { did: string; handle: string } | null): Au get profile() { return profile; }, + get bobbinUrl() { + return bobbinUrlValue; + }, get error() { return error; }, diff --git a/web/src/lib/components/profile/ActivityStub.svelte b/web/src/lib/components/profile/ActivityStub.svelte new file mode 100644 index 00000000..390681e5 --- /dev/null +++ b/web/src/lib/components/profile/ActivityStub.svelte @@ -0,0 +1,3 @@ +
+ Activity is not available yet. +
diff --git a/web/src/lib/components/profile/EmptyState.svelte b/web/src/lib/components/profile/EmptyState.svelte new file mode 100644 index 00000000..2fd584a5 --- /dev/null +++ b/web/src/lib/components/profile/EmptyState.svelte @@ -0,0 +1,16 @@ + + +
+ {message}{@render children?.()} +
diff --git a/web/src/lib/components/profile/FollowButton.svelte b/web/src/lib/components/profile/FollowButton.svelte new file mode 100644 index 00000000..4c2497b4 --- /dev/null +++ b/web/src/lib/components/profile/FollowButton.svelte @@ -0,0 +1,100 @@ + + +{#if !isSelf} + {#if !signedIn} + + {:else} +
+ + {#if relation.failed} +

Something went wrong. Try again.

+ {/if} +
+ {/if} +{/if} diff --git a/web/src/lib/components/profile/FollowCard.svelte b/web/src/lib/components/profile/FollowCard.svelte new file mode 100644 index 00000000..e9684b75 --- /dev/null +++ b/web/src/lib/components/profile/FollowCard.svelte @@ -0,0 +1,66 @@ + + +
+
+ +
+ +
+
+ + {person.handle} + + {#if person.description} +

{person.description}

+ {/if} + +
+ + {#if !person.isSelf} +
+ +
+ {/if} +
+
diff --git a/web/src/lib/components/profile/FollowerFollowing.svelte b/web/src/lib/components/profile/FollowerFollowing.svelte new file mode 100644 index 00000000..638a7121 --- /dev/null +++ b/web/src/lib/components/profile/FollowerFollowing.svelte @@ -0,0 +1,29 @@ + + + diff --git a/web/src/lib/components/profile/ProfileCard.svelte b/web/src/lib/components/profile/ProfileCard.svelte new file mode 100644 index 00000000..41cb6055 --- /dev/null +++ b/web/src/lib/components/profile/ProfileCard.svelte @@ -0,0 +1,157 @@ + + +
+
+
+ {#if identity.avatar} + {identity.handle} + {:else} + + {/if} +
+
+ +
+
+

+ {identity.handle} +

+ {#if shown?.pronouns} +

{shown.pronouns}

+ {/if} +
+
+ +
+
+ +
+ {#if editing} + { + saved = record; + editing = false; + }} + onCancel={() => (editing = false)} + /> + {:else} +
+ {#if shown?.description} +

{shown.description}

+ {/if} + + + + {#if shown?.location || links.length > 0 || blueskyUrl} +
+ {#if shown?.location} +
+ + {shown.location} +
+ {/if} + {#if blueskyUrl} + + {/if} + {#each links as link, index (index)} +
+ + {link} +
+ {/each} +
+ {/if} + +
+ {#if isSelf} + + {:else} +
+ +
+ {/if} + +
+
+ {/if} +
+
diff --git a/web/src/lib/components/profile/ProfileEditForm.svelte b/web/src/lib/components/profile/ProfileEditForm.svelte new file mode 100644 index 00000000..a79fb2c5 --- /dev/null +++ b/web/src/lib/components/profile/ProfileEditForm.svelte @@ -0,0 +1,130 @@ + + +
+
+ + +
+ +
+ + +
+ +
+ +
+
+
+ +
+ Social links + + {#each links as link, index (index)} +
+ + +
+ {/each} +
+ + {#if error} +

{error}

+ {/if} + +
+ + +
+
diff --git a/web/src/lib/components/profile/ProfileTabs.svelte b/web/src/lib/components/profile/ProfileTabs.svelte new file mode 100644 index 00000000..08aacb9b --- /dev/null +++ b/web/src/lib/components/profile/ProfileTabs.svelte @@ -0,0 +1,65 @@ + + + diff --git a/web/src/lib/components/profile/StringCard.svelte b/web/src/lib/components/profile/StringCard.svelte new file mode 100644 index 00000000..ed3ffaf4 --- /dev/null +++ b/web/src/lib/components/profile/StringCard.svelte @@ -0,0 +1,34 @@ + + +
+ + + {#if entry.description} +

{entry.description}

+ {/if} + +
+ {entry.lines} line{entry.lines === 1 ? '' : 's'} · {compactRelativeTime( + entry.createdAt + )} +
+
diff --git a/web/src/lib/components/profile/VouchCard.svelte b/web/src/lib/components/profile/VouchCard.svelte new file mode 100644 index 00000000..5a620ca7 --- /dev/null +++ b/web/src/lib/components/profile/VouchCard.svelte @@ -0,0 +1,37 @@ + + +
+
+ +
+ + {vouch.handle} + + + {#if denounce} + +
+
+ + {#if vouch.reason} +

{vouch.reason}

+ {/if} +
diff --git a/web/src/lib/components/profile/counts.svelte.ts b/web/src/lib/components/profile/counts.svelte.ts new file mode 100644 index 00000000..9a0c268d --- /dev/null +++ b/web/src/lib/components/profile/counts.svelte.ts @@ -0,0 +1,53 @@ +import { getContext } from 'svelte'; +import { createOptimisticCount, type OptimisticCount } from '$lib/optimistic.svelte'; +import type { ProfileCounts } from './types'; + +export type ProfileCountName = keyof ProfileCounts; + +export interface ProfileCountsContext { + readonly did: string; + readonly value: ProfileCounts; + adjust(subjectDid: string, name: ProfileCountName, delta: 1 | -1): void; +} + +export const PROFILE_COUNTS_KEY = Symbol('profile-counts'); + +export const createProfileCounts = ( + did: () => string, + loaded: () => ProfileCounts +): ProfileCountsContext => { + const counter = (name: ProfileCountName): OptimisticCount => + createOptimisticCount({ key: did, loaded: () => loaded()[name] }); + + const counts = { + repos: counter('repos'), + stars: counter('stars'), + strings: counter('strings'), + followers: counter('followers'), + following: counter('following'), + vouches: counter('vouches') + }; + + return { + get did() { + return did(); + }, + get value() { + return { + repos: counts.repos.value, + stars: counts.stars.value, + strings: counts.strings.value, + followers: counts.followers.value, + following: counts.following.value, + vouches: counts.vouches.value + }; + }, + adjust(subjectDid, name, delta) { + if (subjectDid !== did()) return; + counts[name].adjust(delta); + } + }; +}; + +export const getProfileCounts = (): ProfileCountsContext | null => + getContext(PROFILE_COUNTS_KEY); diff --git a/web/src/lib/components/profile/tabs/OverviewTab.svelte b/web/src/lib/components/profile/tabs/OverviewTab.svelte new file mode 100644 index 00000000..e5b3b131 --- /dev/null +++ b/web/src/lib/components/profile/tabs/OverviewTab.svelte @@ -0,0 +1,32 @@ + + +
+
+

Pinned repositories

+ {#if pinned.length === 0} + + {:else} +
+ {#each pinned as repo (repo.rkey)} + + {/each} +
+ {/if} +
+ +
+

Activity

+ +
+
diff --git a/web/src/lib/components/profile/tabs/PeopleTab.svelte b/web/src/lib/components/profile/tabs/PeopleTab.svelte new file mode 100644 index 00000000..eb8e152e --- /dev/null +++ b/web/src/lib/components/profile/tabs/PeopleTab.svelte @@ -0,0 +1,26 @@ + + +
+

{title}

+ {#if people.length === 0} + + {:else} +
+ {#each people as person (person.did)} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/profile/tabs/RepoListTab.svelte b/web/src/lib/components/profile/tabs/RepoListTab.svelte new file mode 100644 index 00000000..35fa36c2 --- /dev/null +++ b/web/src/lib/components/profile/tabs/RepoListTab.svelte @@ -0,0 +1,53 @@ + + +
+
+
+ +
+ + +
+ +
+
+ + {#if repos.length === 0} + + {:else} +
+ {#each repos as repo (repo.rkey)} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/profile/tabs/StarredTab.svelte b/web/src/lib/components/profile/tabs/StarredTab.svelte new file mode 100644 index 00000000..ba8b8175 --- /dev/null +++ b/web/src/lib/components/profile/tabs/StarredTab.svelte @@ -0,0 +1,34 @@ + + +
+

Starred

+ {#if stars.length === 0} + + {:else} +
+ {#each stars as star (star.uri)} + {#if star.kind === 'repo'} + + {:else} + + {/if} + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/profile/tabs/StringListTab.svelte b/web/src/lib/components/profile/tabs/StringListTab.svelte new file mode 100644 index 00000000..1db28d9c --- /dev/null +++ b/web/src/lib/components/profile/tabs/StringListTab.svelte @@ -0,0 +1,20 @@ + + +
+

Strings

+ {#if strings.length === 0} + + {:else} +
+ {#each strings as entry (entry.rkey)} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/profile/tabs/VouchTab.svelte b/web/src/lib/components/profile/tabs/VouchTab.svelte new file mode 100644 index 00000000..c24051d2 --- /dev/null +++ b/web/src/lib/components/profile/tabs/VouchTab.svelte @@ -0,0 +1,20 @@ + + +
+

Vouches

+ {#if vouches.length === 0} + + {:else} +
+ {#each vouches as vouch (vouch.uri)} + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/profile/types.ts b/web/src/lib/components/profile/types.ts new file mode 100644 index 00000000..4be138e5 --- /dev/null +++ b/web/src/lib/components/profile/types.ts @@ -0,0 +1,68 @@ +// view models produced by the profile route loads and consumed by the cards. + +export interface ProfileCounts { + repos: number; + stars: number; + strings: number; + followers: number; + following: number; + vouches: number; +} + +export interface RepoCardData { + rkey: string; + name: string; + repoDid: string; + ownerHandle: string; + description?: string; + knot: string; + createdAt: string; + language?: string; + stars?: number; + forks?: number; + issues?: number; + pulls?: number; + viewerStarRkey?: string | null; +} + +export interface StringCardData { + rkey: string; + ownerHandle: string; + filename: string; + description: string; + createdAt: string; + lines: number; +} + +export interface FollowChange { + viewerDid: string; + subjectDid: string; + following: boolean; + rkey: string | null; + delta: 1 | -1; +} + +export interface PersonData { + did: string; + handle: string; + avatar?: string; + description?: string; + followers?: number; + following?: number; + viewerFollowRkey?: string | null; + isSelf?: boolean; +} + +export interface VouchData { + uri: string; + did: string; + handle: string; + avatar?: string; + kind: 'vouch' | 'denounce'; + reason?: string; + createdAt: string; +} + +export type StarData = + | { kind: 'repo'; uri: string; createdAt: string; repo: RepoCardData } + | { kind: 'string'; uri: string; createdAt: string; ownerHandle: string; rkey: string }; diff --git a/web/src/lib/components/repo/RepoCard.svelte b/web/src/lib/components/repo/RepoCard.svelte new file mode 100644 index 00000000..e29a1fae --- /dev/null +++ b/web/src/lib/components/repo/RepoCard.svelte @@ -0,0 +1,75 @@ + + +
+ + + {#if repo.description} +

{repo.description}

+ {/if} + + {#if repo.language !== undefined || stats.length > 0} +
+ {#if repo.language} + + + {repo.language} + + {/if} + {#each stats as stat (stat.Icon)} + + + {/each} +
+ {/if} +
diff --git a/web/src/lib/components/repo/StarButton.svelte b/web/src/lib/components/repo/StarButton.svelte new file mode 100644 index 00000000..1938ae82 --- /dev/null +++ b/web/src/lib/components/repo/StarButton.svelte @@ -0,0 +1,92 @@ + + +{#if signedIn && repoDid} +
+
+ + +
+ {#if failed} +

Something went wrong. Try again.

+ {/if} +
+{/if} diff --git a/web/src/lib/components/ui/Avatar.svelte b/web/src/lib/components/ui/Avatar.svelte new file mode 100644 index 00000000..2bb3188a --- /dev/null +++ b/web/src/lib/components/ui/Avatar.svelte @@ -0,0 +1,27 @@ + + +{#if src} + {handle +{:else} + +{/if} diff --git a/web/src/lib/components/ui/Button.svelte b/web/src/lib/components/ui/Button.svelte index 43e56ee5..7c0c9cce 100644 --- a/web/src/lib/components/ui/Button.svelte +++ b/web/src/lib/components/ui/Button.svelte @@ -10,6 +10,7 @@ primary: 'border border-primary-border bg-primary text-primary-fg hover:bg-primary-hover focus-visible:outline-primary', ghost: 'bg-transparent text-fg-muted hover:bg-surface-muted focus-visible:outline-ring', + flat: 'bg-gray-50 text-fg hover:bg-gray-100 focus-visible:outline-ring dark:bg-gray-900 dark:hover:bg-gray-700', danger: 'border border-danger-border bg-danger text-danger-fg hover:bg-danger-hover focus-visible:outline-danger', success: diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts new file mode 100644 index 00000000..a4f2ee4f --- /dev/null +++ b/web/src/lib/format.ts @@ -0,0 +1,50 @@ +const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ + { amount: 60, unit: 'seconds' }, + { amount: 60, unit: 'minutes' }, + { amount: 24, unit: 'hours' }, + { amount: 7, unit: 'days' }, + { amount: 4.34524, unit: 'weeks' }, + { amount: 12, unit: 'months' }, + { amount: Number.POSITIVE_INFINITY, unit: 'years' } +]; + +const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); +const dtf = new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }); + +// "3 days ago", "in 2 hours", etc. +export const relativeTime = (input: string | Date, now: Date = new Date()): string => { + const date = typeof input === 'string' ? new Date(input) : input; + if (Number.isNaN(date.getTime())) return ''; + let duration = (date.getTime() - now.getTime()) / 1000; + for (const division of DIVISIONS) { + if (Math.abs(duration) < division.amount) + return rtf.format(Math.round(duration), division.unit); + duration /= division.amount; + } + return rtf.format(Math.round(duration), 'years'); +}; +export const compactRelativeTime = (input: string | Date, now: Date = new Date()): string => { + const date = typeof input === 'string' ? new Date(input) : input; + if (Number.isNaN(date.getTime())) return ''; + let duration = Math.abs((now.getTime() - date.getTime()) / 1000); + const suffix = date.getTime() > now.getTime() ? 'from now' : 'ago'; + + if (duration < 60) return `${Math.floor(duration)}s ${suffix}`; + duration /= 60; + if (duration < 60) return `${Math.floor(duration)}m ${suffix}`; + duration /= 60; + if (duration < 24) return `${Math.floor(duration)}h ${suffix}`; + duration /= 24; + if (duration < 7) return `${Math.floor(duration)}d ${suffix}`; + duration /= 7; + if (duration < 4.34524) return `${Math.floor(duration)}w ${suffix}`; + duration /= 4.34524; + if (duration < 12) return `${Math.floor(duration)}mo ${suffix}`; + duration /= 12; + return `${Math.floor(duration)}y ${suffix}`; +}; + +export const formatDate = (input: string | Date): string => { + const date = typeof input === 'string' ? new Date(input) : input; + return Number.isNaN(date.getTime()) ? '' : dtf.format(date); +}; diff --git a/web/src/lib/server/bobbin.ts b/web/src/lib/server/bobbin.ts deleted file mode 100644 index b5f504ad..00000000 --- a/web/src/lib/server/bobbin.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { createBobbinClient, type BobbinContext } from '$lib/api/client'; -import { getConfig } from './config'; - -export const serverBobbin = (event: { fetch: typeof globalThis.fetch }): BobbinContext => - createBobbinClient({ serviceUrl: getConfig().bobbinUrl, fetch: event.fetch }); diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 102dedd6..539975d4 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -8,7 +8,10 @@ let { children, data } = $props(); - const auth = createAuth(untrack(() => data.auth)); + const auth = createAuth( + data.publicConfig.bobbinUrl, + untrack(() => data.auth) + ); setContext(AUTH_KEY, auth); onMount(() => { diff --git a/web/src/routes/[handle]/+layout.svelte b/web/src/routes/[handle]/+layout.svelte new file mode 100644 index 00000000..fa6d1153 --- /dev/null +++ b/web/src/routes/[handle]/+layout.svelte @@ -0,0 +1,47 @@ + + + + {data.identity.handle} · Tangled + + +
+ {#if data.notJoined} + + {:else} + +
+ +
+ {@render children()} +
+
+ {/if} +
diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts new file mode 100644 index 00000000..f9ab5591 --- /dev/null +++ b/web/src/routes/[handle]/+layout.ts @@ -0,0 +1,76 @@ +import { error, redirect } from '@sveltejs/kit'; +import { createBobbinClient } from '$lib/api/client'; +import { resolveMiniDoc } from '$lib/api/identity'; +import { getProfile, type ProfileRecord } from '$lib/api/records'; +import { count } from '$lib/api/count'; +import { parallel, toHttpError, httpStatusFor } from '$lib/api/load'; +import { ClientResponseError } from '$lib/api/client'; +import { findFollowRkey } from '$lib/api/graph'; +import type { ProfileCounts } from '$lib/components/profile/types'; +import type { LayoutLoad } from './$types'; + +export const load: LayoutLoad = async (event) => { + const parent = await event.parent(); + const identifier = decodeURIComponent(event.params.handle); + + // actor identifiers are dids or dotted handles; reject bare words early so + // unrelated paths (/settings, /signup, ...) 404 instead of resolving. + if (!identifier.startsWith('did:') && !identifier.includes('.')) { + error(404, 'Not found'); + } + + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); + const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => + toHttpError(cause, 'Could not resolve user') + ); + + // canonical url is the handle; redirect dids and stale handles. + const canonical = doc.handle && !doc.handle.endsWith('.invalid') ? doc.handle : null; + if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { + redirect(307, `/${canonical}${event.url.search}`); + } + + const did = doc.did; + const viewerDid = parent.auth?.did; + + let profile: ProfileRecord | null = null; + try { + profile = (await getProfile(ctx, did)).value; + } catch (cause) { + if (!(cause instanceof ClientResponseError && httpStatusFor(cause) === 404)) { + toHttpError(cause, 'Could not load profile'); + } + } + + const raw = await parallel({ + repos: count(ctx, 'sh.tangled.repo.countRepos', did), + strings: count(ctx, 'sh.tangled.string.countStrings', did), + stars: count(ctx, 'sh.tangled.feed.countStarsBy', did), + followers: count(ctx, 'sh.tangled.graph.countFollows', did), + following: count(ctx, 'sh.tangled.graph.countFollowsBy', did), + vouches: count(ctx, 'sh.tangled.graph.countVouches', did), + viewerFollowRkey: + viewerDid && viewerDid !== did + ? findFollowRkey(ctx, viewerDid, did).catch(() => null) + : Promise.resolve(null) + }); + + const counts: ProfileCounts = { + repos: raw.repos.count, + strings: raw.strings.count, + stars: raw.stars.count, + followers: raw.followers.count, + following: raw.following.count, + vouches: raw.vouches.count + }; + + const notJoined = !profile && Object.values(counts).every((n) => n === 0); + + return { + identity: { did, handle: doc.handle, avatar: doc.avatar }, + profile, + counts, + viewerFollowRkey: raw.viewerFollowRkey, + notJoined + }; +}; diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte new file mode 100644 index 00000000..6ee72e38 --- /dev/null +++ b/web/src/routes/[handle]/+page.svelte @@ -0,0 +1,26 @@ + + +{#if data.tab === 'overview'} + +{:else if data.tab === 'repos'} + +{:else if data.tab === 'starred'} + +{:else if data.tab === 'strings'} + +{:else if data.tab === 'followers'} + +{:else if data.tab === 'following'} + +{:else if data.tab === 'vouches'} + +{/if} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts new file mode 100644 index 00000000..4e1ce377 --- /dev/null +++ b/web/src/routes/[handle]/+page.ts @@ -0,0 +1,325 @@ +import type { Did } from '@atcute/lexicons/syntax'; +import { createBobbinClient } from '$lib/api/client'; +import { fetchPage, items } from '$lib/api/pagination'; +import { count } from '$lib/api/count'; +import { getRepoByRepoDid, type RepoRecord } from '$lib/api/records'; +import { IdentityCache } from '$lib/api/identity'; +import { didFromUri, rkeyFromUri } from '$lib/api/uri'; +import { toHttpError, parallel } from '$lib/api/load'; +import { search } from '$lib/api/search'; +import type { BobbinContext } from '$lib/api/client'; +import { listStarRkeys, type VouchRecord, type FollowRecord } from '$lib/api/graph'; +import type * as ShTangledFeedStar from '$lib/api/lexicons/types/sh/tangled/feed/star'; +import type * as ShTangledString from '$lib/api/lexicons/types/sh/tangled/string'; +import type * as ShTangledGraphFollow from '$lib/api/lexicons/types/sh/tangled/graph/follow'; +import type { + RepoCardData, + StringCardData, + PersonData, + VouchData, + StarData +} from '$lib/components/profile/types'; +import type { PageLoad } from './$types'; + +const PAGE_LIMIT = 50; + +const TABS = [ + 'overview', + 'repos', + 'starred', + 'strings', + 'followers', + 'following', + 'vouches' +] as const; +type Tab = (typeof TABS)[number]; + +const normalizeTab = (raw: string | null): Tab => + TABS.includes(raw as Tab) ? (raw as Tab) : 'overview'; + +interface ListItem { + uri: string; + value: unknown; +} + +const toRepoCard = (item: ListItem, ownerHandle: string): RepoCardData => { + const value = item.value as RepoRecord; + return { + rkey: rkeyFromUri(item.uri), + name: value.name ?? rkeyFromUri(item.uri), + repoDid: value.repoDid ?? '', + ownerHandle, + description: value.description, + knot: value.knot, + createdAt: value.createdAt + }; +}; + +interface ResolveRepoCardOptions { + viewerStarRkeys?: ReadonlyMap; +} + +const resolveRepoCard = async ( + ctx: BobbinContext, + item: ListItem, + ownerHandle: string, + options: ResolveRepoCardOptions = {} +): Promise => { + const repo = toRepoCard(item, ownerHandle); + if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; + // TODO(bobbin): instead of doing this, listing repos should return star counts + // and most likely other stats as well. + const stars = await count(ctx, 'sh.tangled.feed.countStars', repo.repoDid); + return { + ...repo, + stars: stars.count, + viewerStarRkey: options.viewerStarRkeys + ? (options.viewerStarRkeys.get(repo.repoDid) ?? null) + : undefined + }; +}; + +const toStringCard = (item: ListItem, ownerHandle: string): StringCardData => { + const value = item.value as ShTangledString.Main; + return { + rkey: rkeyFromUri(item.uri), + ownerHandle, + filename: value.filename, + description: value.description, + createdAt: value.createdAt, + lines: value.contents?.split('\n').length ?? 1 + }; +}; + +// resolve dids -> handle/avatar, deduped, preserving input order. +const resolvePeople = async ( + ctx: BobbinContext, + dids: string[], + viewerDid?: string +): Promise => { + const cache = new IdentityCache(ctx); + const unique = [...new Set(dids)]; + + const docs = await Promise.all(unique.map((did) => cache.resolve(did).catch(() => null))); + // TODO(bobbin): need bobbin to return follower / following stats when listing follows.. + const counts = await parallel( + unique.reduce( + (acc, did) => { + acc[`${did}-followers`] = count(ctx, 'sh.tangled.graph.countFollows', did) + .then((result) => result.count) + .catch(() => 0); + acc[`${did}-following`] = count(ctx, 'sh.tangled.graph.countFollowsBy', did) + .then((result) => result.count) + .catch(() => 0); + return acc; + }, + {} as Record> + ) + ); + + const viewerFollowRkeys = new Map(); + if (viewerDid) { + for await (const item of items( + ctx, + 'sh.tangled.graph.listFollowsBy', + { subject: viewerDid as Did }, + { maxPages: 10 } + )) { + const value = item.value as FollowRecord; + viewerFollowRkeys.set(value.subject, rkeyFromUri(item.uri)); + } + } + + const byDid = new Map(); + unique.forEach((did, index) => { + const doc = docs[index]; + const followers = counts[`${did}-followers`]; + const following = counts[`${did}-following`]; + const isSelf = viewerDid === did; + const viewerFollowRkey = viewerDid ? (viewerFollowRkeys.get(did) ?? null) : undefined; + byDid.set( + did, + doc + ? { + did: doc.did, + handle: doc.handle, + avatar: doc.avatar, + followers, + following, + isSelf, + viewerFollowRkey + } + : { did, handle: did, followers, following, isSelf, viewerFollowRkey } + ); + }); + return unique.map((did) => byDid.get(did) as PersonData); +}; + +const resolveVouches = async (ctx: BobbinContext, items: ListItem[]): Promise => { + const cache = new IdentityCache(ctx); + return Promise.all( + items.map(async (item): Promise => { + const value = item.value as VouchRecord; + const voucher = didFromUri(item.uri); + const doc = await cache.resolve(voucher).catch(() => null); + return { + uri: item.uri, + did: voucher, + handle: doc?.handle ?? voucher, + avatar: doc?.avatar, + kind: value.kind === 'denounce' ? 'denounce' : 'vouch', + reason: value.reason, + createdAt: value.createdAt + }; + }) + ); +}; + +const resolveStars = async ( + ctx: BobbinContext, + items: ListItem[], + options: ResolveRepoCardOptions +): Promise => { + const cache = new IdentityCache(ctx); + const resolved = await Promise.all( + items.map(async (item): Promise => { + const value = item.value as ShTangledFeedStar.Main; + const subject = value.subject; + if (subject && 'did' in subject && subject.did) { + try { + const repo = await getRepoByRepoDid(ctx, subject.did); + const ownerDid = didFromUri(repo.uri); + const owner = await cache.resolve(ownerDid).catch(() => null); + return { + kind: 'repo', + uri: item.uri, + createdAt: value.createdAt, + repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) + }; + } catch { + return null; + } + } + if (subject && 'uri' in subject && subject.uri) { + const ownerDid = didFromUri(subject.uri); + const owner = await cache.resolve(ownerDid).catch(() => null); + return { + kind: 'string', + uri: item.uri, + createdAt: value.createdAt, + ownerHandle: owner?.handle ?? ownerDid, + rkey: rkeyFromUri(subject.uri) + }; + } + return null; + }) + ); + return resolved.filter((star): star is StarData => star !== null); +}; + +export const load: PageLoad = async (event) => { + const parent = await event.parent(); + const tab = normalizeTab(event.url.searchParams.get('tab')); + + if (parent.notJoined) return { tab: 'overview' as const, overview: { pinned: [] } }; + + const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); + const did = parent.identity.did as Did; + const handle = parent.identity.handle; + + try { + switch (tab) { + case 'repos': { + const q = event.url.searchParams.get('q')?.trim(); + const [found, viewerStarRkeys] = await Promise.all([ + q + ? search(ctx, { q, nsid: 'sh.tangled.repo', author: did, limit: PAGE_LIMIT }).then( + (page) => page.hits + ) + : fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }).then( + (page) => page.items + ), + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined + ]); + return { + tab, + repos: await Promise.all( + found.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) + ) + }; + } + case 'strings': { + const page = await fetchPage(ctx, 'sh.tangled.string.listStrings', { + subject: did, + limit: PAGE_LIMIT + }); + return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; + } + case 'followers': { + const page = await fetchPage(ctx, 'sh.tangled.graph.listFollows', { + subject: did, + limit: PAGE_LIMIT + }); + const dids = page.items.map((item) => didFromUri(item.uri)); + return { + tab, + people: await resolvePeople(ctx, dids, parent.auth?.did) + }; + } + case 'following': { + const page = await fetchPage(ctx, 'sh.tangled.graph.listFollowsBy', { + subject: did, + limit: PAGE_LIMIT + }); + const dids = page.items.map((item) => (item.value as ShTangledGraphFollow.Main).subject); + return { + tab, + people: await resolvePeople(ctx, dids, parent.auth?.did) + }; + } + case 'vouches': { + const page = await fetchPage(ctx, 'sh.tangled.graph.listVouches', { + subject: did, + limit: PAGE_LIMIT + }); + return { tab, vouches: await resolveVouches(ctx, page.items) }; + } + case 'starred': { + const [page, viewerStarRkeys] = await Promise.all([ + fetchPage(ctx, 'sh.tangled.feed.listStarsBy', { subject: did, limit: PAGE_LIMIT }), + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined + ]); + return { + tab, + stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) + }; + } + case 'overview': + default: { + const [page, viewerStarRkeys] = await Promise.all([ + fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }), + parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined + ]); + + const pinnedKeys = parent.profile?.pinnedRepositories ?? []; + const byKey = new Map(); + for (const item of page.items) { + const value = item.value as RepoRecord; + if (value.repoDid) byKey.set(value.repoDid, item); + byKey.set(item.uri, item); + } + const pinnedItems = pinnedKeys + .map((key) => byKey.get(key)) + .filter((item): item is ListItem => item !== undefined); + const pinned = await Promise.all( + pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) + ); + + return { tab: 'overview' as const, overview: { pinned } }; + } + } + } catch (cause) { + console.error('Page load error:', cause); + toHttpError(cause, 'Could not load profile data'); + } +}; diff --git a/web/vite.config.ts b/web/vite.config.ts index 13681876..a64dc567 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -19,7 +19,6 @@ export default defineConfig({ process.env.VITE_OAUTH_REDIRECT_URI ??= command === 'serve' ? devRedirectUri : oauthMetadata.redirect_uris[0]; process.env.VITE_OAUTH_SCOPE ??= oauthMetadata.scope; - process.env.VITE_TANGLED_APPVIEW_SERVICE ??= 'https://bobbin.klbr.net'; } }, tailwindcss(), -- 2.51.2