feat(web): add repo detail page with refs, channel link, and loading states

Display branch/tag refs from kind:30618 events, HEAD commit SHA,
channel link from sprout-channel tag, and proper loading/empty states.
No new backend APIs — leverages existing Nostr event types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-12 09:19:08 -07:00
parent 6b4f9f05d4
commit f8d04456b9
4 changed files with 146 additions and 2 deletions

View File

@@ -4,6 +4,7 @@ import {
Check,
Copy,
ExternalLink,
MessageSquare,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
@@ -12,9 +13,11 @@ import { toast } from "sonner";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { useRepoRefs } from "../use-repo-refs";
import { useRepo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
import { RepoRefsSection } from "./RepoRefsSection";
function relativeTime(unix: number): string {
const now = Date.now();
@@ -85,6 +88,7 @@ function DetailSkeleton() {
export function RepoDetailPage() {
const { repoId } = useParams({ from: "/repos/$repoId" });
const { data: repo, isLoading, error } = useRepo(repoId);
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
useEffect(() => {
if (error) {
@@ -146,6 +150,9 @@ export function RepoDetailPage() {
</p>
</div>
{/* Refs & HEAD */}
<RepoRefsSection refs={refs} isLoading={refsLoading} />
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
@@ -186,6 +193,18 @@ export function RepoDetailPage() {
</div>
</div>
{/* Channel link */}
{repo.channelId && (
<div className="mt-8">
<Button variant="outline" asChild>
<a href={`/channels/${repo.channelId}`}>
<MessageSquare className="h-4 w-4" />
View channel
</a>
</Button>
</div>
)}
{/* Open in Sprout CTA */}
<div className="mt-8 rounded-lg border border-border bg-muted/30 p-6 text-center">
<p className="mb-3 text-sm text-muted-foreground">

View File

@@ -0,0 +1,54 @@
import { GitBranch, Hash, Tag } from "lucide-react";
import { Badge } from "@/shared/ui/badge";
import type { RepoRefs } from "../use-repo-refs";
export function RepoRefsSection({
refs,
isLoading,
}: {
refs: RepoRefs | undefined;
isLoading: boolean;
}) {
if (isLoading) return null;
const hasRefs = refs && (refs.branches.length > 0 || refs.tags.length > 0);
return (
<div className="mt-6">
{hasRefs ? (
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
{refs.head && (
<>
<div className="flex items-center gap-1.5">
<Badge variant="secondary">
<GitBranch className="mr-1 h-3 w-3" />
{refs.head.ref}
</Badge>
{refs.head.sha && (
<Badge variant="outline" className="font-mono text-xs">
<Hash className="mr-0.5 h-3 w-3" />
{refs.head.sha.slice(0, 7)}
</Badge>
)}
</div>
<span className="text-muted-foreground/60">&middot;</span>
</>
)}
<span className="flex items-center gap-1">
<GitBranch className="h-3.5 w-3.5" />
{refs.branches.length}{" "}
{refs.branches.length === 1 ? "branch" : "branches"}
</span>
<span className="text-muted-foreground/60">&middot;</span>
<span className="flex items-center gap-1">
<Tag className="h-3.5 w-3.5" />
{refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"}
</span>
</div>
) : (
<p className="text-sm text-muted-foreground">No commits yet</p>
)}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { useQuery } from "@tanstack/react-query";
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
import { relayWsUrl } from "@/shared/lib/relay-url";
import { dedup } from "./use-repos";
export interface RepoRefs {
branches: string[];
tags: string[];
head: { ref: string; sha: string } | null;
}
function parseRefs(events: NostrEvent[]): RepoRefs {
const latest = dedup(events);
const branches: string[] = [];
const tags: string[] = [];
let head: RepoRefs["head"] = null;
for (const event of latest) {
for (const tag of event.tags) {
const [name, value] = tag;
if (!name || !value) continue;
if (name === "HEAD" && value.startsWith("ref: refs/heads/")) {
// HEAD points to a branch ref — find its SHA from a matching branch tag
const branchName = value.replace("ref: refs/heads/", "");
head = { ref: branchName, sha: "" };
} else if (name.startsWith("refs/heads/")) {
branches.push(name.replace("refs/heads/", ""));
} else if (name.startsWith("refs/tags/")) {
tags.push(name.replace("refs/tags/", ""));
}
}
}
// Resolve HEAD SHA from the matching branch
if (head) {
for (const event of latest) {
for (const tag of event.tags) {
if (tag[0] === `refs/heads/${head.ref}` && tag[1]) {
head = { ref: head.ref, sha: tag[1] };
break;
}
}
if (head.sha) break;
}
}
return { branches, tags, head };
}
async function fetchRepoRefs(repoId: string): Promise<RepoRefs> {
// TODO: Filter by `authors: [relayPubkey]` once the relay's own pubkey is
// exposed to the client. Without this, a user with ReposWrite permission
// could publish fake kind:30618 events with spoofed refs.
const events = await queryEvents(relayWsUrl(), {
kinds: [30618],
"#d": [repoId],
});
return parseRefs(events);
}
export function useRepoRefs(repoId: string) {
return useQuery({
queryKey: ["repo-refs", repoId],
queryFn: () => fetchRepoRefs(repoId),
staleTime: 60_000,
});
}

View File

@@ -8,13 +8,14 @@ export interface Repo {
description: string;
cloneUrls: string[];
webUrl: string | null;
channelId: string | null;
owner: string;
contributors: string[];
createdAt: number;
}
/** Extract the first value for a given tag name from a Nostr event. */
function getTag(event: NostrEvent, name: string): string | undefined {
export function getTag(event: NostrEvent, name: string): string | undefined {
return event.tags.find((t) => t[0] === name)?.[1];
}
@@ -29,6 +30,7 @@ function eventToRepo(event: NostrEvent): Repo {
const description = getTag(event, "description") || event.content || "";
const cloneUrls = getAllTags(event, "clone");
const webUrl = getTag(event, "web") ?? null;
const channelId = getTag(event, "sprout-channel") ?? null;
const contributors = getAllTags(event, "p");
const owner = event.pubkey;
@@ -38,6 +40,7 @@ function eventToRepo(event: NostrEvent): Repo {
description,
cloneUrls,
webUrl,
channelId,
owner,
contributors,
createdAt: event.created_at,
@@ -45,7 +48,7 @@ function eventToRepo(event: NostrEvent): Repo {
}
/** Deduplicate NIP-33 parameterized replaceable events, keeping the latest per (pubkey, kind, d-tag). */
function dedup(events: NostrEvent[]): NostrEvent[] {
export function dedup(events: NostrEvent[]): NostrEvent[] {
const best = new Map<string, NostrEvent>();
for (const e of events) {
const d = getTag(e, "d") ?? "";