mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(web): repo detail page + clickable repo names (#485)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"),
|
||||
]);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user