From b7bc761b9769d8392017308d26c8d3e7591af20e Mon Sep 17 00:00:00 2001 From: Wes Date: Tue, 5 May 2026 15:39:11 -0600 Subject: [PATCH] feat(web): repo detail page + clickable repo names (#485) Co-authored-by: Claude Opus 4.6 --- web/src/app/routeTree.gen.ts | 24 ++- web/src/app/routes.ts | 1 + web/src/app/routes/repos.$repoId.tsx | 6 + web/src/features/repos/ui/ConnectButton.tsx | 2 +- web/src/features/repos/ui/OrgSidebar.tsx | 25 +-- web/src/features/repos/ui/PubkeyAvatar.tsx | 37 ++++ web/src/features/repos/ui/RepoDetailPage.tsx | 199 +++++++++++++++++++ web/src/features/repos/ui/RepoListItem.tsx | 20 +- web/src/features/repos/ui/ReposPage.tsx | 45 +++-- web/src/features/repos/use-repos.ts | 9 + 10 files changed, 308 insertions(+), 60 deletions(-) create mode 100644 web/src/app/routes/repos.$repoId.tsx create mode 100644 web/src/features/repos/ui/PubkeyAvatar.tsx create mode 100644 web/src/features/repos/ui/RepoDetailPage.tsx diff --git a/web/src/app/routeTree.gen.ts b/web/src/app/routeTree.gen.ts index b978d6651..dad9b8f5c 100644 --- a/web/src/app/routeTree.gen.ts +++ b/web/src/app/routeTree.gen.ts @@ -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) diff --git a/web/src/app/routes.ts b/web/src/app/routes.ts index 25c385bb1..e34f6152b 100644 --- a/web/src/app/routes.ts +++ b/web/src/app/routes.ts @@ -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"), ]); diff --git a/web/src/app/routes/repos.$repoId.tsx b/web/src/app/routes/repos.$repoId.tsx new file mode 100644 index 000000000..ccabe8250 --- /dev/null +++ b/web/src/app/routes/repos.$repoId.tsx @@ -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, +}); diff --git a/web/src/features/repos/ui/ConnectButton.tsx b/web/src/features/repos/ui/ConnectButton.tsx index d4d7c6209..f77032934 100644 --- a/web/src/features/repos/ui/ConnectButton.tsx +++ b/web/src/features/repos/ui/ConnectButton.tsx @@ -10,7 +10,7 @@ export function ConnectButton({ className }: { className?: string }) { ); diff --git a/web/src/features/repos/ui/OrgSidebar.tsx b/web/src/features/repos/ui/OrgSidebar.tsx index 18b0a578c..9755ff387 100644 --- a/web/src/features/repos/ui/OrgSidebar.tsx +++ b/web/src/features/repos/ui/OrgSidebar.tsx @@ -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 ( -
- {pubkey.slice(0, 2)} -
- ); -} - export function OrgSidebar({ repos }: { repos: Repo[] }) { const uniquePubkeys = useMemo(() => { const set = new Set(); @@ -45,7 +24,7 @@ export function OrgSidebar({ repos }: { repos: Repo[] }) { return (
- {/* Connect to Relay */} + {/* Open in Sprout */} {/* People section */} diff --git a/web/src/features/repos/ui/PubkeyAvatar.tsx b/web/src/features/repos/ui/PubkeyAvatar.tsx new file mode 100644 index 000000000..eb6fa5bfb --- /dev/null +++ b/web/src/features/repos/ui/PubkeyAvatar.tsx @@ -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 ( + + +
+ {pubkey.slice(0, 2)} +
+
+ + {pubkey} + +
+ ); +} diff --git a/web/src/features/repos/ui/RepoDetailPage.tsx b/web/src/features/repos/ui/RepoDetailPage.tsx new file mode 100644 index 000000000..3b5142e60 --- /dev/null +++ b/web/src/features/repos/ui/RepoDetailPage.tsx @@ -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 ( +
+ {url} + +
+ ); +} + +function DetailSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+ ); +} + +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 ; + + if (!repo) { + return ( +
+ + + Back to repositories + +
+ +

Repository not found

+

+ This repository may have been removed or doesn't exist on this + relay. +

+
+
+ ); + } + + return ( +
+ {/* Back link */} + + + Back to repositories + + + {/* Header */} +
+
+ +

{repo.name}

+ Public +
+ {repo.description && ( +

+ {repo.description} +

+ )} +

+ Updated {relativeTime(repo.createdAt)} +

+
+ + {/* Clone URLs */} + {repo.cloneUrls.length > 0 && ( +
+

Clone

+
+ {repo.cloneUrls.map((url) => ( + + ))} +
+
+ )} + + {/* External link */} + {repo.webUrl && ( + + )} + + {/* Owner & Contributors */} +
+

+ + People +

+
+ + {repo.contributors + .filter((c) => c !== repo.owner) + .map((c) => ( + + ))} +
+
+ + {/* Open in Sprout CTA */} +
+

+ Open this relay in the Sprout desktop app to push code and + collaborate. +

+ +
+
+ ); +} diff --git a/web/src/features/repos/ui/RepoListItem.tsx b/web/src/features/repos/ui/RepoListItem.tsx index 8ee16c4bf..341b54f63 100644 --- a/web/src/features/repos/ui/RepoListItem.tsx +++ b/web/src/features/repos/ui/RepoListItem.tsx @@ -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 */}
- {repo.webUrl ? ( - - {repo.name} - - ) : ( - {repo.name} - )} + + {repo.name} + Public diff --git a/web/src/features/repos/ui/ReposPage.tsx b/web/src/features/repos/ui/ReposPage.tsx index 0b96ca95d..0cdadc108 100644 --- a/web/src/features/repos/ui/ReposPage.tsx +++ b/web/src/features/repos/ui/ReposPage.tsx @@ -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 (
-

- {hasSearch ? "No matching repositories" : "No repositories yet"} -

+

No matching repositories

- {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.

- {!hasSearch && } +
+ ); +} + +function RelayEmptyState() { + return ( +
+
+ +
+

+ This relay is empty +

+

+ Repositories pushed to this relay will show up here. Open this relay in + the Sprout desktop app to start pushing code. +

+
); } @@ -105,17 +118,7 @@ export function ReposPage() { } if (!repos || repos.length === 0) { - return ( -
-
-

- Repositories -

- -
-
- ); + return ; } return ( @@ -159,7 +162,7 @@ export function ReposPage() { ))}
) : ( - 0} /> + )}
diff --git a/web/src/features/repos/use-repos.ts b/web/src/features/repos/use-repos.ts index 79077aa5d..6a647a215 100644 --- a/web/src/features/repos/use-repos.ts +++ b/web/src/features/repos/use-repos.ts @@ -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), + }); +}