feat(web): repo detail page + clickable repo names (#485)

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Wes
2026-05-05 14:39:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent d90bd768c5
commit b7bc761b97
10 changed files with 308 additions and 60 deletions
+21 -3
View File
@@ -7,6 +7,7 @@
import { Route as rootRouteImport } from "./routes/root";
import { Route as reposRouteImport } from "./routes/repos";
import { Route as indexRouteImport } from "./routes/index";
import { Route as reposDotrepoIdRouteImport } from "./routes/repos.$repoId";
const reposRoute = reposRouteImport.update({
id: "/repos",
@@ -18,31 +19,40 @@ const indexRoute = indexRouteImport.update({
path: "/",
getParentRoute: () => rootRouteImport,
} as any);
const reposDotrepoIdRoute = reposDotrepoIdRouteImport.update({
id: "/repos/$repoId",
path: "/repos/$repoId",
getParentRoute: () => rootRouteImport,
} as any);
export interface FileRoutesByFullPath {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
}
export interface FileRoutesByTo {
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
}
export interface FileRoutesById {
__root__: typeof rootRouteImport;
"/": typeof indexRoute;
"/repos": typeof reposRoute;
"/repos/$repoId": typeof reposDotrepoIdRoute;
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath;
fullPaths: "/" | "/repos";
fullPaths: "/" | "/repos" | "/repos/$repoId";
fileRoutesByTo: FileRoutesByTo;
to: "/" | "/repos";
id: "__root__" | "/" | "/repos";
to: "/" | "/repos" | "/repos/$repoId";
id: "__root__" | "/" | "/repos" | "/repos/$repoId";
fileRoutesById: FileRoutesById;
}
export interface RootRouteChildren {
indexRoute: typeof indexRoute;
reposRoute: typeof reposRoute;
reposDotrepoIdRoute: typeof reposDotrepoIdRoute;
}
declare module "@tanstack/react-router" {
@@ -61,12 +71,20 @@ declare module "@tanstack/react-router" {
preLoaderRoute: typeof indexRouteImport;
parentRoute: typeof rootRouteImport;
};
"/repos/$repoId": {
id: "/repos/$repoId";
path: "/repos/$repoId";
fullPath: "/repos/$repoId";
preLoaderRoute: typeof reposDotrepoIdRouteImport;
parentRoute: typeof rootRouteImport;
};
}
}
const rootRouteChildren: RootRouteChildren = {
indexRoute: indexRoute,
reposRoute: reposRoute,
reposDotrepoIdRoute: reposDotrepoIdRoute,
};
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
+1
View File
@@ -3,4 +3,5 @@ import { index, route, rootRoute } from "@tanstack/virtual-file-routes";
export const routes = rootRoute("root.tsx", [
index("index.tsx"),
route("/repos", "repos.tsx"),
route("/repos/$repoId", "repos.$repoId.tsx"),
]);
+6
View File
@@ -0,0 +1,6 @@
import { createFileRoute } from "@tanstack/react-router";
import { RepoDetailPage } from "@/features/repos/ui/RepoDetailPage";
export const Route = createFileRoute("/repos/$repoId")({
component: RepoDetailPage,
});
+1 -1
View File
@@ -10,7 +10,7 @@ export function ConnectButton({ className }: { className?: string }) {
<Button asChild className={className}>
<a href={deepLink}>
<ExternalLink className="h-4 w-4" />
Connect to Relay
Open in Sprout
</a>
</Button>
);
+2 -23
View File
@@ -3,31 +3,10 @@ import { useMemo } from "react";
import type { Repo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
const MAX_AVATARS = 20;
/** Simple hash of a hex pubkey to a hue value (0-360). */
function pubkeyToHue(hex: string): number {
let hash = 0;
for (let i = 0; i < hex.length; i++) {
hash = (hash * 31 + hex.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
function PubkeyAvatar({ pubkey }: { pubkey: string }) {
const hue = pubkeyToHue(pubkey);
return (
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-xs font-medium text-white"
style={{ backgroundColor: `hsl(${hue}, 55%, 45%)` }}
title={pubkey}
>
{pubkey.slice(0, 2)}
</div>
);
}
export function OrgSidebar({ repos }: { repos: Repo[] }) {
const uniquePubkeys = useMemo(() => {
const set = new Set<string>();
@@ -45,7 +24,7 @@ export function OrgSidebar({ repos }: { repos: Repo[] }) {
return (
<div className="space-y-6">
{/* Connect to Relay */}
{/* Open in Sprout */}
<ConnectButton className="w-full" />
{/* People section */}
@@ -0,0 +1,37 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
/** Simple hash of a hex pubkey to a hue value (0-360). */
function pubkeyToHue(hex: string): number {
let hash = 0;
for (let i = 0; i < hex.length; i++) {
hash = (hash * 31 + hex.charCodeAt(i)) | 0;
}
return Math.abs(hash) % 360;
}
export function PubkeyAvatar({
pubkey,
size = "md",
}: {
pubkey: string;
size?: "sm" | "md";
}) {
const hue = pubkeyToHue(pubkey);
const sizeClasses = size === "sm" ? "h-6 w-6 text-[10px]" : "h-8 w-8 text-xs";
return (
<Tooltip>
<TooltipTrigger asChild>
<div
className={`flex items-center justify-center rounded-full font-medium text-white ${sizeClasses}`}
style={{ backgroundColor: `hsl(${hue}, 55%, 45%)` }}
>
{pubkey.slice(0, 2)}
</div>
</TooltipTrigger>
<TooltipContent>
<span className="font-mono text-xs">{pubkey}</span>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,199 @@
import {
ArrowLeft,
BookMarked,
Check,
Copy,
ExternalLink,
Users,
} from "lucide-react";
import { useEffect, useState } from "react";
import { Link, useParams } from "@tanstack/react-router";
import { toast } from "sonner";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import { useRepo } from "../use-repos";
import { ConnectButton } from "./ConnectButton";
import { PubkeyAvatar } from "./PubkeyAvatar";
function relativeTime(unix: number): string {
const now = Date.now();
const diff = now - unix * 1000;
const seconds = Math.floor(diff / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
if (days > 30) {
const months = Math.floor(days / 30);
return months === 1 ? "1 month ago" : `${months} months ago`;
}
if (days > 0) return days === 1 ? "1 day ago" : `${days} days ago`;
if (hours > 0) return hours === 1 ? "1 hour ago" : `${hours} hours ago`;
if (minutes > 0)
return minutes === 1 ? "1 minute ago" : `${minutes} minutes ago`;
return "just now";
}
function CopyableUrl({ url }: { url: string }) {
const [copied, setCopied] = useState(false);
async function handleCopy() {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
toast.success("Copied to clipboard");
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error("Failed to copy to clipboard");
}
}
return (
<div className="flex items-center gap-2 rounded-md border border-input bg-muted/50 px-3 py-2">
<code className="min-w-0 flex-1 truncate text-sm">{url}</code>
<button
type="button"
onClick={handleCopy}
className="shrink-0 text-muted-foreground hover:text-foreground"
aria-label="Copy clone URL"
>
{copied ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<Copy className="h-4 w-4" />
)}
</button>
</div>
);
}
function DetailSkeleton() {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<div className="h-5 w-24 animate-pulse rounded bg-muted" />
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 space-y-3">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-10 w-full animate-pulse rounded bg-muted" />
</div>
</div>
);
}
export function RepoDetailPage() {
const { repoId } = useParams({ from: "/repos/$repoId" });
const { data: repo, isLoading, error } = useRepo(repoId);
useEffect(() => {
if (error) {
toast.error("Failed to load repository", {
description: error.message,
});
}
}, [error]);
if (isLoading) return <DetailSkeleton />;
if (!repo) {
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
<div className="mt-12 text-center">
<BookMarked className="mx-auto h-10 w-10 text-muted-foreground" />
<h1 className="mt-4 text-xl font-semibold">Repository not found</h1>
<p className="mt-1 text-sm text-muted-foreground">
This repository may have been removed or doesn't exist on this
relay.
</p>
</div>
</div>
);
}
return (
<div className="mx-auto w-full max-w-3xl px-4 py-8">
{/* Back link */}
<Link
to="/"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="h-4 w-4" />
Back to repositories
</Link>
{/* Header */}
<div className="mt-6">
<div className="flex items-center gap-3">
<BookMarked className="h-6 w-6 shrink-0 text-muted-foreground" />
<h1 className="text-2xl font-semibold tracking-tight">{repo.name}</h1>
<Badge variant="outline">Public</Badge>
</div>
{repo.description && (
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
{repo.description}
</p>
)}
<p className="mt-2 text-xs text-muted-foreground">
Updated {relativeTime(repo.createdAt)}
</p>
</div>
{/* Clone URLs */}
{repo.cloneUrls.length > 0 && (
<div className="mt-8">
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
<div className="space-y-2">
{repo.cloneUrls.map((url) => (
<CopyableUrl key={url} url={url} />
))}
</div>
</div>
)}
{/* External link */}
{repo.webUrl && (
<div className="mt-6">
<Button variant="outline" asChild>
<a href={repo.webUrl} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
View on web
</a>
</Button>
</div>
)}
{/* Owner & Contributors */}
<div className="mt-8">
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
<Users className="h-4 w-4" />
People
</h2>
<div className="flex flex-wrap gap-2">
<PubkeyAvatar pubkey={repo.owner} />
{repo.contributors
.filter((c) => c !== repo.owner)
.map((c) => (
<PubkeyAvatar key={c} pubkey={c} />
))}
</div>
</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">
Open this relay in the Sprout desktop app to push code and
collaborate.
</p>
<ConnectButton />
</div>
</div>
);
}
+8 -12
View File
@@ -1,4 +1,5 @@
import { BookMarked } from "lucide-react";
import { Link } from "@tanstack/react-router";
import { Badge } from "@/shared/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
@@ -34,18 +35,13 @@ export function RepoListItem({ repo }: { repo: Repo }) {
{/* Row 1: Name + badge */}
<div className="flex items-center gap-2">
<BookMarked className="h-4 w-4 shrink-0 text-muted-foreground" />
{repo.webUrl ? (
<a
href={repo.webUrl}
target="_blank"
rel="noopener noreferrer"
className="text-lg font-semibold text-primary hover:underline"
>
{repo.name}
</a>
) : (
<span className="text-lg font-semibold">{repo.name}</span>
)}
<Link
to="/repos/$repoId"
params={{ repoId: repo.id }}
className="text-lg font-semibold text-primary hover:underline"
>
{repo.name}
</Link>
<Badge variant="outline" className="ml-1">
Public
</Badge>
+24 -21
View File
@@ -1,4 +1,4 @@
import { BookMarked, GitBranch } from "lucide-react";
import { BookMarked, GitBranch, Sprout } from "lucide-react";
import { toast } from "sonner";
import { useEffect, useMemo, useState } from "react";
@@ -27,21 +27,34 @@ function ListItemSkeleton() {
);
}
function EmptyState({ hasSearch }: { hasSearch: boolean }) {
function SearchEmptyState() {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-full bg-muted">
<GitBranch className="h-7 w-7 text-muted-foreground" />
</div>
<h2 className="mt-4 text-lg font-semibold">
{hasSearch ? "No matching repositories" : "No repositories yet"}
</h2>
<h2 className="mt-4 text-lg font-semibold">No matching repositories</h2>
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
{hasSearch
? "Try adjusting your search term."
: "Repositories published to this relay will appear here. Push a git repo using the Sprout desktop app to get started."}
Try adjusting your search term.
</p>
{!hasSearch && <ConnectButton className="mt-6" />}
</div>
);
}
function RelayEmptyState() {
return (
<div className="flex flex-1 flex-col items-center justify-center px-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
<Sprout className="h-8 w-8 text-primary" />
</div>
<h1 className="mt-6 text-2xl font-semibold tracking-tight">
This relay is empty
</h1>
<p className="mt-2 max-w-md text-sm leading-relaxed text-muted-foreground">
Repositories pushed to this relay will show up here. Open this relay in
the Sprout desktop app to start pushing code.
</p>
<ConnectButton className="mt-6" />
</div>
);
}
@@ -105,17 +118,7 @@ export function ReposPage() {
}
if (!repos || repos.length === 0) {
return (
<div className="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
<div className="min-w-0 flex-1">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
<BookMarked className="h-5 w-5" /> Repositories
</h2>
<EmptyState hasSearch={false} />
</div>
<aside className="hidden w-72 shrink-0 lg:block" />
</div>
);
return <RelayEmptyState />;
}
return (
@@ -159,7 +162,7 @@ export function ReposPage() {
))}
</div>
) : (
<EmptyState hasSearch={search.length > 0} />
<SearchEmptyState />
)}
</div>
+9
View File
@@ -72,3 +72,12 @@ export function useRepos() {
staleTime: 60_000,
});
}
export function useRepo(repoId: string) {
return useQuery({
queryKey: ["repos"],
queryFn: fetchRepos,
staleTime: 60_000,
select: (repos) => repos.find((r) => r.id === repoId),
});
}