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 af6fdf00d7eeca79b9582d889ff8f79968b2c60c
Parent: 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-cospan
39 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>
cospan · schematic version control on atproto built on AT Protocol