mirror of
https://github.com/block/buzz.git
synced 2026-11-04 00:12:02 +00:00
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:
@@ -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">
|
||||
|
||||
54
web/src/features/repos/ui/RepoRefsSection.tsx
Normal file
54
web/src/features/repos/ui/RepoRefsSection.tsx
Normal 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">·</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">·</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>
|
||||
);
|
||||
}
|
||||
68
web/src/features/repos/use-repo-refs.ts
Normal file
68
web/src/features/repos/use-repo-refs.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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") ?? "";
|
||||
|
||||
Reference in New Issue
Block a user