refactor: layout consolidation — shared breadcrumbs, tabs, profile header Repo layout ([did]/[repo]/+layout.svelte): - Renders Breadcrumb + RepoTabBar + KeyboardShortcuts once - Auto-detects activeTab from $page.route.id - Hides tabs on form pages (/new, /fork) - Fetches full Repo object in layout server (available to all sub-pages) - Extra breadcrumb segments via Svelte context (setExtraCrumbs) - isTangled propagated to all sub-pages automatically Profile layout ([did]/(profile)/+layout.svelte): - Group route separates profile pages from repo pages - Renders ProfileHeader once with shared profile data - Layout server fetches Bluesky identity + Cospan follow counts + repo count - Profile sub-pages only fetch their unique data (repos, followers, etc.) Landing page: - Fixed $derived vs $derived.by for activeProtocols multiselect
Author: Aaron Steven White
Commit
af6fdf00d7eeca79b9582d889ff8f79968b2c60cParent: 20f35a7216
Structural diff unavailable
These commits were pushed via plain git push, so no pre-parsed
schemas are available. Install git-remote-cospan and re-push via panproto:// to
see scope-level changes, breaking change detection, and semantic diffs.
brew install panproto/tap/git-remote-cospan39 files changed +1643 -1667
@@ -10,7 +10,7 @@
1010 let showDropdown = $state(false); 1111 1212 // Multi-select protocols from URL 13- let activeProtocols = $derived<string[]>(() => { 13+ let activeProtocols = $derived.by(() => { 1414 const p = $page.url.searchParams.get('protocol'); 1515 return p ? p.split(',').filter(Boolean) : []; 1616 });
@@ -25,7 +25,7 @@
2525 ); 2626 2727 function toggleProtocol(value: string) { 28- const current = activeProtocols(); 28+ const current = activeProtocols; 2929 const next = current.includes(value) 3030 ? current.filter((p) => p !== value) 3131 : [...current, value];
@@ -143,7 +143,7 @@
143143 144144 <!-- Language filter (multiselect) --> 145145 <div class="flex items-center gap-2"> 146- {#if activeProtocols().length > 1} 146+ {#if activeProtocols.length > 1} 147147 <button 148148 type="button" 149149 onclick={clearProtocols}
@@ -152,7 +152,7 @@
152152 Clear all 153153 </button> 154154 {/if} 155- {#each activeProtocols() as proto} 155+ {#each activeProtocols as proto} 156156 <button 157157 type="button" 158158 onclick={() => toggleProtocol(proto)}
@@ -190,9 +190,9 @@
190190 type="button" 191191 onmousedown={() => toggleProtocol(lang.value)} 192192 class="flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-elevated 193- {activeProtocols().includes(lang.value) ? 'text-focus-bright' : 'text-caption'}" 193+ {activeProtocols.includes(lang.value) ? 'text-focus-bright' : 'text-caption'}" 194194 > 195- {#if activeProtocols().includes(lang.value)} 195+ {#if activeProtocols.includes(lang.value)} 196196 <svg class="h-3 w-3 text-focus" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="3"> 197197 <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> 198198 </svg>
@@ -220,7 +220,7 @@
220220 </svg> 221221 </div> 222222 <p class="text-sm text-caption"> 223- {activeProtocols().length > 0 ? `No repositories found for selected languages.` : 'No repositories yet.'} 223+ {activeProtocols.length > 0 ? `No repositories found for selected languages.` : 'No repositories yet.'} 224224 </p> 225225 <p class="mt-1 text-xs text-ghost">Repositories from Cospan and Tangled will appear here.</p> 226226 </div>
@@ -0,0 +1,75 @@
1+import type { LayoutServerLoad } from './$types'; 2+import { getProfile } from '$lib/api/actor.js'; 3+import { listRepos } from '$lib/api/repo.js'; 4+import { xrpcQuery } from '$lib/api/client.js'; 5+ 6+async function fetchBlueskyIdentity(did: string) { 7+ try { 8+ const resp = await fetch( 9+ `https://public.api.bsky.app/xrpc/app.bsky.actor.getProfile?actor=${encodeURIComponent(did)}` 10+ ); 11+ if (resp.ok) { 12+ const data = await resp.json(); 13+ return { 14+ displayName: data.displayName ?? null, 15+ handle: data.handle ?? did, 16+ description: data.description ?? null, 17+ avatarUrl: data.avatar ?? null, 18+ }; 19+ } 20+ } catch {} 21+ return null; 22+} 23+ 24+async function fetchFollowCounts(did: string) { 25+ try { 26+ const [followersFull, followingFull] = await Promise.all([ 27+ xrpcQuery<{ follows: any[] }>('dev.cospan.graph.follow.list', { 28+ did, 29+ direction: 'followers', 30+ limit: 1000, 31+ }), 32+ xrpcQuery<{ follows: any[] }>('dev.cospan.graph.follow.list', { 33+ did, 34+ direction: 'following', 35+ limit: 1000, 36+ }), 37+ ]); 38+ return { 39+ followerCount: followersFull.follows?.length ?? 0, 40+ followingCount: followingFull.follows?.length ?? 0, 41+ }; 42+ } catch { 43+ return { followerCount: 0, followingCount: 0 }; 44+ } 45+} 46+ 47+export const load: LayoutServerLoad = async ({ params }) => { 48+ const bskyIdentity = await fetchBlueskyIdentity(params.did); 49+ 50+ let cospanProfile = null; 51+ try { 52+ cospanProfile = await getProfile({ did: params.did }); 53+ } catch {} 54+ 55+ const followCounts = await fetchFollowCounts(params.did); 56+ 57+ let repoCount = 0; 58+ try { 59+ const repos = await listRepos({ did: params.did, limit: 100 }); 60+ repoCount = repos.items.length; 61+ } catch {} 62+ 63+ const profile = { 64+ did: params.did, 65+ displayName: cospanProfile?.displayName ?? bskyIdentity?.displayName ?? null, 66+ handle: bskyIdentity?.handle ?? params.did, 67+ description: cospanProfile?.description ?? bskyIdentity?.description ?? null, 68+ avatarUrl: bskyIdentity?.avatarUrl ?? null, 69+ followerCount: followCounts.followerCount, 70+ followingCount: followCounts.followingCount, 71+ repoCount, 72+ }; 73+ 74+ return { profile, did: params.did }; 75+};
@@ -0,0 +1,8 @@
1+<script lang="ts"> 2+ import ProfileHeader from '$lib/components/shared/ProfileHeader.svelte'; 3+ 4+ let { data, children } = $props(); 5+</script> 6+ 7+<ProfileHeader profile={data.profile} did={data.did} showFollow={true} /> 8+{@render children()}
@@ -0,0 +1,11 @@
1+import type { PageServerLoad } from './$types'; 2+import { listRepos } from '$lib/api/repo.js'; 3+ 4+export const load: PageServerLoad = async ({ params }) => { 5+ let repos = { items: [] as any[], cursor: null as string | null }; 6+ try { 7+ repos = await listRepos({ did: params.did, limit: 30 }); 8+ } catch {} 9+ 10+ return { repos }; 11+};
@@ -0,0 +1,22 @@
1+<script lang="ts"> 2+ import RepoCard from '$lib/components/repo/RepoCard.svelte'; 3+ import EmptyState from '$lib/components/shared/EmptyState.svelte'; 4+ 5+ let { data } = $props(); 6+</script> 7+ 8+<svelte:head> 9+ <title>{data.profile?.displayName ?? data.profile?.handle ?? data.did} · Cospan</title> 10+</svelte:head> 11+ 12+ <!-- Repositories tab content (default) --> 13+ {#if data.repos.items.length === 0} 14+ <EmptyState icon="folder" message="No repositories yet." /> 15+ {:else} 16+ <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> 17+ {#each data.repos.items as repo (repo.did + '/' + repo.name)} 18+ <RepoCard {repo} /> 19+ {/each} 20+ </div> 21+ {/if} 22+</section>