mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Refresh invite and repository web pages (#1808)
Signed-off-by: Fizz <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5 <8a675edd33677aa0389f6650d467b2041fb0df4ca820eacb009babb95e3715d4@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub13fn4ahfnvaa2qwylvegdgeajqs0mph6v4qsw4jcqnw4mjh3hzh2quuucm5
parent
a6cfd65511
commit
a227bb45f6
@@ -1,5 +1,4 @@
|
||||
import { Link, Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
import { ThemeToggle } from "@/shared/theme/ThemeToggle";
|
||||
import { Outlet, createRootRoute } from "@tanstack/react-router";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: RootLayout,
|
||||
@@ -8,15 +7,6 @@ export const Route = createRootRoute({
|
||||
function RootLayout() {
|
||||
return (
|
||||
<div className="flex min-h-dvh flex-col">
|
||||
<header className="flex h-12 items-center justify-between border-b px-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-sm font-semibold tracking-tight text-foreground"
|
||||
>
|
||||
Buzz
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</header>
|
||||
<main className="flex flex-1 flex-col">
|
||||
<Outlet />
|
||||
</main>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -1,5 +1,4 @@
|
||||
import { Download, ExternalLink, Hexagon } from "lucide-react";
|
||||
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
|
||||
@@ -19,39 +18,52 @@ export function InvitePage({ code }: { code: string }) {
|
||||
const deepLink = `buzz://join?relay=${encodeURIComponent(relay)}&code=${encodeURIComponent(code)}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10">
|
||||
<Hexagon className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight">
|
||||
You're invited to join
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-lg text-foreground">{host}</p>
|
||||
<p className="mt-3 max-w-md text-sm leading-relaxed text-muted-foreground">
|
||||
Buzz is a messaging platform for human–agent collaboration. Accept this
|
||||
invite in the desktop app to join the workspace on this relay.
|
||||
</p>
|
||||
<div
|
||||
className="flex flex-1 flex-col items-center justify-center px-4 py-16 text-center"
|
||||
style={{
|
||||
backgroundImage: "linear-gradient(180deg, #D7D72E 0%, #D7E7F6 100%)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex w-full max-w-xl flex-col items-center rounded-3xl bg-white px-6 py-10 sm:px-12 sm:py-12"
|
||||
style={{
|
||||
boxShadow:
|
||||
"0 0 0 1px rgba(0, 0, 0, 0.04), 0 8px 24px rgba(0, 0, 0, 0.04)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="h-16 w-16 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black">
|
||||
You're invited to join
|
||||
</h1>
|
||||
<p className="mt-1 font-mono text-lg text-black/70">{host}</p>
|
||||
|
||||
<div className="mt-8 flex flex-col items-center gap-3 sm:flex-row">
|
||||
<Button asChild size="lg">
|
||||
<a href={deepLink}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in Buzz
|
||||
</a>
|
||||
</Button>
|
||||
<Button asChild variant="outline" size="lg">
|
||||
<a href={DOWNLOAD_URL} target="_blank" rel="noreferrer">
|
||||
<Download className="h-4 w-4" />
|
||||
Download Buzz
|
||||
</a>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-8">
|
||||
<Button
|
||||
asChild
|
||||
className="bg-black text-white hover:bg-black/90 focus-visible:ring-black"
|
||||
size="lg"
|
||||
>
|
||||
<a href={deepLink}>Accept invite in Buzz</a>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 max-w-sm text-xs leading-relaxed text-muted-foreground">
|
||||
Already have Buzz installed? “Open in Buzz” will launch it
|
||||
and accept the invite. Otherwise download the app first, then come back
|
||||
and use this link again.
|
||||
</p>
|
||||
<p className="mt-6 text-sm text-black/60">
|
||||
Don't have the app?{" "}
|
||||
<a
|
||||
className="font-medium text-black underline-offset-4 hover:text-black/70 hover:decoration-current hover:underline focus-visible:underline"
|
||||
href={DOWNLOAD_URL}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
Download it now
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import type { Repo } from "./use-repos";
|
||||
import type {
|
||||
BlobView,
|
||||
CommitInfo,
|
||||
ReadmeResult,
|
||||
TreeEntry,
|
||||
} from "./git-client";
|
||||
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const people = {
|
||||
ada: "1".repeat(64),
|
||||
grace: "2".repeat(64),
|
||||
linus: "3".repeat(64),
|
||||
margaret: "4".repeat(64),
|
||||
};
|
||||
|
||||
/** Local-only data for previewing the populated repositories state. */
|
||||
export const mockRepos: Repo[] = [
|
||||
{
|
||||
id: "buzz-desktop",
|
||||
name: "buzz-desktop",
|
||||
description:
|
||||
"The desktop client for collaborating with people and agents across Buzz workspaces.",
|
||||
cloneUrls: ["https://example.com/buzz-desktop.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.ada,
|
||||
contributors: [people.grace, people.linus],
|
||||
createdAt: now - 60 * 24,
|
||||
},
|
||||
{
|
||||
id: "agent-harness",
|
||||
name: "agent-harness",
|
||||
description:
|
||||
"Tools and shared workflows for launching and coordinating coding agents.",
|
||||
cloneUrls: ["https://example.com/agent-harness.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.grace,
|
||||
contributors: [people.ada, people.margaret],
|
||||
createdAt: now - 60 * 60 * 3,
|
||||
},
|
||||
{
|
||||
id: "relay-infrastructure",
|
||||
name: "relay-infrastructure",
|
||||
description:
|
||||
"Infrastructure and deployment configuration for community relays.",
|
||||
cloneUrls: ["https://example.com/relay-infrastructure.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.linus,
|
||||
contributors: [people.margaret],
|
||||
createdAt: now - 60 * 60 * 24 * 2,
|
||||
},
|
||||
{
|
||||
id: "design-system",
|
||||
name: "design-system",
|
||||
description: "Shared foundations, components, and interaction patterns.",
|
||||
cloneUrls: ["https://example.com/design-system.git"],
|
||||
webUrl: null,
|
||||
channelId: null,
|
||||
owner: people.margaret,
|
||||
contributors: [people.ada, people.grace, people.linus],
|
||||
createdAt: now - 60 * 60 * 24 * 8,
|
||||
},
|
||||
];
|
||||
|
||||
export function getMockRepo(repoId: string): Repo | undefined {
|
||||
if (!import.meta.env.DEV) return undefined;
|
||||
return mockRepos.find((repo) => repo.id === repoId);
|
||||
}
|
||||
|
||||
export const mockRepoTree: TreeEntry[] = [
|
||||
{
|
||||
name: "src",
|
||||
type: "tree",
|
||||
mode: "040000",
|
||||
oid: "1".repeat(40),
|
||||
},
|
||||
{
|
||||
name: "README.md",
|
||||
type: "blob",
|
||||
mode: "100644",
|
||||
oid: "2".repeat(40),
|
||||
},
|
||||
{
|
||||
name: "package.json",
|
||||
type: "blob",
|
||||
mode: "100644",
|
||||
oid: "3".repeat(40),
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRepoCommits: CommitInfo[] = [
|
||||
{
|
||||
oid: "a".repeat(40),
|
||||
message: "Polish repository browsing styles",
|
||||
author: {
|
||||
name: "Ada",
|
||||
email: "ada@example.com",
|
||||
timestamp: now - 60 * 24,
|
||||
},
|
||||
},
|
||||
{
|
||||
oid: "b".repeat(40),
|
||||
message: "Add repository empty states",
|
||||
author: {
|
||||
name: "Grace",
|
||||
email: "grace@example.com",
|
||||
timestamp: now - 60 * 60 * 5,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const mockRepoReadme: ReadmeResult = {
|
||||
filename: "README.md",
|
||||
content:
|
||||
"# Buzz Desktop\n\nA focused workspace for people and agents to collaborate.\n\n## Getting started\n\nInstall dependencies, then start the development app.",
|
||||
};
|
||||
|
||||
export function getMockBlob(
|
||||
repoId: string,
|
||||
filepath: string,
|
||||
): BlobView | undefined {
|
||||
if (!getMockRepo(repoId)) return undefined;
|
||||
|
||||
if (filepath === "README.md") {
|
||||
return {
|
||||
kind: "markdown",
|
||||
content: mockRepoReadme.content,
|
||||
sizeBytes: mockRepoReadme.content.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (filepath === "package.json") {
|
||||
const content = `${JSON.stringify(
|
||||
{
|
||||
name: repoId,
|
||||
private: true,
|
||||
scripts: { dev: "vite", build: "tsc && vite build" },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`;
|
||||
return { kind: "text", content, sizeBytes: content.length };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -7,7 +7,10 @@ export function ConnectButton({ className }: { className?: string }) {
|
||||
const deepLink = `buzz://connect?relay=${encodeURIComponent(relayWsUrl())}`;
|
||||
|
||||
return (
|
||||
<Button asChild className={className}>
|
||||
<Button
|
||||
asChild
|
||||
className={`bg-black text-white hover:bg-black/90 focus-visible:ring-black dark:bg-white dark:text-black dark:hover:bg-white/90 dark:focus-visible:ring-white ${className ?? ""}`}
|
||||
>
|
||||
<a href={deepLink}>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Open in Buzz
|
||||
|
||||
@@ -30,7 +30,7 @@ export function OrgSidebar({ repos }: { repos: Repo[] }) {
|
||||
{/* People section */}
|
||||
{uniquePubkeys.length > 0 && (
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-sidebar-foreground">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
People
|
||||
</h3>
|
||||
@@ -40,7 +40,7 @@ export function OrgSidebar({ repos }: { repos: Repo[] }) {
|
||||
))}
|
||||
</div>
|
||||
{overflowCount > 0 && (
|
||||
<span className="mt-2 block text-xs text-muted-foreground">
|
||||
<span className="mt-2 block text-xs text-black/50 dark:text-white/50">
|
||||
{uniquePubkeys.length} people
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Button } from "@/shared/ui/button";
|
||||
import type { BlobView } from "../git-client";
|
||||
import { useGitBlob, useGitHtmlDoc } from "../use-git-browse";
|
||||
import { useRepoContext } from "../use-repo-context";
|
||||
import { getMockBlob } from "../mock-repos";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
@@ -64,6 +65,7 @@ function CopyTextButton({ content }: { content: string }) {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(content);
|
||||
@@ -93,7 +95,12 @@ function DownloadButton({
|
||||
const url = useObjectUrl(bytes, contentType);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<Button asChild variant="outline" size="sm">
|
||||
<Button
|
||||
asChild
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
>
|
||||
<a href={url} download={filename}>
|
||||
<Download className="h-4 w-4" />
|
||||
<span className="ml-2">Download</span>
|
||||
@@ -107,7 +114,7 @@ function TextView({ content }: { content: string }) {
|
||||
// keyed children for an immutable text dump; not worth the linter dance for
|
||||
// v1. The browser handles wrapping/scrolling via `<pre>`.
|
||||
return (
|
||||
<pre className="overflow-auto whitespace-pre rounded-lg border border-border bg-muted/30 p-4 font-mono text-sm leading-6">
|
||||
<pre className="overflow-auto whitespace-pre rounded-lg border border-black/10 bg-white/50 p-4 font-mono text-sm leading-6 text-black dark:border-white/10 dark:bg-white/5 dark:text-white">
|
||||
{content}
|
||||
</pre>
|
||||
);
|
||||
@@ -125,7 +132,7 @@ function ImageView({
|
||||
const url = useObjectUrl(bytes, contentType);
|
||||
if (!url) return null;
|
||||
return (
|
||||
<div className="flex justify-center rounded-lg border border-border bg-muted/30 p-4">
|
||||
<div className="flex justify-center rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
@@ -155,7 +162,7 @@ function HtmlRunView({ doc }: { doc: string }) {
|
||||
title="Repository page (sandboxed)"
|
||||
srcDoc={doc}
|
||||
sandbox={RUN_SANDBOX}
|
||||
className="h-[80vh] w-full rounded-lg border border-border bg-white"
|
||||
className="h-[80vh] w-full rounded-lg border border-black/10 bg-white dark:border-white/10"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -174,7 +181,7 @@ function ViewerBody({
|
||||
return <TextView content={view.content} />;
|
||||
case "markdown":
|
||||
return (
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border p-4">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{view.content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
@@ -196,14 +203,14 @@ function ViewerBody({
|
||||
);
|
||||
case "binary":
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
|
||||
Binary file — {formatBytes(view.sizeBytes)}. Use the Download button
|
||||
above to save it.
|
||||
</div>
|
||||
);
|
||||
case "too-large":
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||
<div className="rounded-lg border border-black/10 bg-white/50 p-6 text-sm text-black/60 dark:border-white/10 dark:bg-white/5 dark:text-white/60">
|
||||
File is {formatBytes(view.sizeBytes)}, over the{" "}
|
||||
{formatBytes(view.limitBytes)} preview limit. Use the Download button
|
||||
above to save it.
|
||||
@@ -215,19 +222,29 @@ function ViewerBody({
|
||||
export function RepoBlobPage() {
|
||||
const { repoId, _splat } = useParams({ from: "/repos/$repoId/blob/$" });
|
||||
const filepath = _splat ?? "";
|
||||
const preview =
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") ===
|
||||
"repositories";
|
||||
const mockView = preview ? getMockBlob(repoId, filepath) : undefined;
|
||||
const showMockBlob = Boolean(mockView);
|
||||
const {
|
||||
owner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
isLoading: ctxLoading,
|
||||
error: ctxError,
|
||||
} = useRepoContext(repoId);
|
||||
} = useRepoContext(repoId, { preview: showMockBlob });
|
||||
|
||||
const browseOwner = showMockBlob ? "" : owner;
|
||||
|
||||
const {
|
||||
data: view,
|
||||
isLoading,
|
||||
data: fetchedView,
|
||||
isLoading: isViewLoading,
|
||||
error,
|
||||
} = useGitBlob(owner, repoName, defaultRef, filepath);
|
||||
} = useGitBlob(browseOwner, repoName, defaultRef, filepath);
|
||||
const view = mockView ?? fetchedView;
|
||||
const isLoading = showMockBlob ? false : isViewLoading;
|
||||
|
||||
const [running, setRunning] = useState(false);
|
||||
const isHtml = view?.kind === "html";
|
||||
@@ -244,8 +261,8 @@ export function RepoBlobPage() {
|
||||
|
||||
if (ctxError) {
|
||||
return (
|
||||
<div className="px-4 py-8">
|
||||
<BackLink repoId={repoId} />
|
||||
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<BackLink repoId={repoId} preview={showMockBlob} />
|
||||
<p className="mt-4 text-sm text-destructive">
|
||||
Failed to load repository: {ctxError.message}
|
||||
</p>
|
||||
@@ -254,11 +271,11 @@ export function RepoBlobPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8">
|
||||
<BackLink repoId={repoId} />
|
||||
<div className="flex-1 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<BackLink repoId={repoId} preview={showMockBlob} />
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<FileText className="h-4 w-4 text-black/50 dark:text-white/50" />
|
||||
<h1 className="min-w-0 truncate font-mono text-sm">{filepath}</h1>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{view &&
|
||||
@@ -271,6 +288,7 @@ export function RepoBlobPage() {
|
||||
<Button
|
||||
variant={running ? "secondary" : "default"}
|
||||
size="sm"
|
||||
className="bg-black text-white hover:bg-black/90 dark:bg-white dark:text-black dark:hover:bg-white/90"
|
||||
onClick={() => setRunning((r) => !r)}
|
||||
>
|
||||
<Play className="h-4 w-4" />
|
||||
@@ -296,7 +314,7 @@ export function RepoBlobPage() {
|
||||
|
||||
<div className="mt-6">
|
||||
{ctxLoading || isLoading ? (
|
||||
<div className="h-32 animate-pulse rounded-lg bg-muted" />
|
||||
<div className="h-32 animate-pulse rounded-lg bg-black/10 dark:bg-white/10" />
|
||||
) : error ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load file: {(error as Error).message}
|
||||
@@ -313,12 +331,13 @@ export function RepoBlobPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackLink({ repoId }: { repoId: string }) {
|
||||
function BackLink({ repoId, preview }: { repoId: string; preview: boolean }) {
|
||||
return (
|
||||
<Link
|
||||
to="/repos/$repoId"
|
||||
params={{ repoId }}
|
||||
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repository
|
||||
|
||||
@@ -5,15 +5,15 @@ import type { CommitInfo } from "../git-client";
|
||||
function CommitRow({ commit }: { commit: CommitInfo }) {
|
||||
const firstLine = commit.message.split("\n")[0];
|
||||
return (
|
||||
<div className="flex items-start gap-3 border-b border-border px-3 py-2.5 text-sm last:border-b-0">
|
||||
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex items-start gap-3 border-b border-black/10 px-3 py-2.5 text-sm text-black last:border-b-0 dark:border-white/10 dark:text-white">
|
||||
<GitCommit className="mt-0.5 h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate font-medium">{firstLine}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
<p className="mt-0.5 text-xs text-black/50 dark:text-white/50">
|
||||
{commit.author.name} committed {relativeTime(commit.author.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
<code className="shrink-0 self-center rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-muted-foreground">
|
||||
<code className="shrink-0 self-center rounded bg-black/5 px-1.5 py-0.5 font-mono text-xs text-black/50 dark:bg-white/10 dark:text-white/50">
|
||||
{commit.oid.slice(0, 7)}
|
||||
</code>
|
||||
</div>
|
||||
@@ -30,20 +30,20 @@ export function RepoCommitsSection({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
Recent commits
|
||||
</h2>
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="rounded-lg border border-black/10 dark:border-white/10">
|
||||
{["sk-1", "sk-2", "sk-3"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-3 border-b border-border px-3 py-2.5 last:border-b-0"
|
||||
className="flex items-center gap-3 border-b border-black/10 px-3 py-2.5 last:border-b-0 dark:border-white/10"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="flex-1 space-y-1">
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-3 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -56,11 +56,11 @@ export function RepoCommitsSection({
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<GitCommit className="h-4 w-4" />
|
||||
Recent commits
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
|
||||
{commits.map((commit) => (
|
||||
<CommitRow key={commit.oid} commit={commit} />
|
||||
))}
|
||||
|
||||
@@ -16,6 +16,12 @@ import { Button } from "@/shared/ui/button";
|
||||
import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { useRepoRefs } from "../use-repo-refs";
|
||||
import { useRepo } from "../use-repos";
|
||||
import {
|
||||
getMockRepo,
|
||||
mockRepoCommits,
|
||||
mockRepoReadme,
|
||||
mockRepoTree,
|
||||
} from "../mock-repos";
|
||||
import type { CommitInfo, ReadmeResult, TreeEntry } from "../git-client";
|
||||
import { useGitTree, useGitLog, useGitReadme } from "../use-git-browse";
|
||||
import { ConnectButton } from "./ConnectButton";
|
||||
@@ -40,12 +46,14 @@ function CopyableUrl({ url }: { url: string }) {
|
||||
}
|
||||
|
||||
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>
|
||||
<div className="flex items-center gap-2 rounded-md border border-black/10 bg-white px-3 py-2 dark:border-white/10 dark:bg-white/5">
|
||||
<code className="min-w-0 flex-1 truncate text-sm text-black dark:text-white">
|
||||
{url}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground"
|
||||
className="shrink-0 text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
aria-label="Copy clone URL"
|
||||
>
|
||||
{copied ? (
|
||||
@@ -60,14 +68,14 @@ function CopyableUrl({ url }: { url: string }) {
|
||||
|
||||
function DetailSkeleton() {
|
||||
return (
|
||||
<div className="flex w-full gap-8 px-4 py-8">
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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="h-5 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-6 h-8 w-64 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-3 h-5 w-96 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<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 className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-10 w-full animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
<aside className="hidden w-72 shrink-0 lg:block" />
|
||||
@@ -75,6 +83,31 @@ function DetailSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
function BackToRepositories({
|
||||
mockPreview = false,
|
||||
}: {
|
||||
mockPreview?: boolean;
|
||||
}) {
|
||||
const className =
|
||||
"inline-flex items-center gap-1 text-sm text-black/60 hover:text-black dark:text-white/60 dark:hover:text-white";
|
||||
const content = (
|
||||
<>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to repositories
|
||||
</>
|
||||
);
|
||||
|
||||
return mockPreview ? (
|
||||
<a href="/?preview=repositories" className={className}>
|
||||
{content}
|
||||
</a>
|
||||
) : (
|
||||
<Link to="/" className={className}>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
type Tab = "code" | "commits";
|
||||
|
||||
function RepoTabs({
|
||||
@@ -85,6 +118,7 @@ function RepoTabs({
|
||||
commitsLoading,
|
||||
readme,
|
||||
readmeLoading,
|
||||
preview,
|
||||
}: {
|
||||
repoId: string;
|
||||
treeEntries: TreeEntry[] | undefined;
|
||||
@@ -93,20 +127,21 @@ function RepoTabs({
|
||||
commitsLoading: boolean;
|
||||
readme: ReadmeResult | null | undefined;
|
||||
readmeLoading: boolean;
|
||||
preview: boolean;
|
||||
}) {
|
||||
const [tab, setTab] = useState<Tab>("code");
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<div className="flex gap-1 border-b border-black/10 dark:border-white/10">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab("code")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === "code"
|
||||
? "border-b-2 border-foreground text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Code
|
||||
@@ -116,8 +151,8 @@ function RepoTabs({
|
||||
onClick={() => setTab("commits")}
|
||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
||||
tab === "commits"
|
||||
? "border-b-2 border-foreground text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
? "border-b-2 border-black text-black dark:border-white dark:text-white"
|
||||
: "text-black/50 hover:text-black dark:text-white/50 dark:hover:text-white"
|
||||
}`}
|
||||
>
|
||||
Commits
|
||||
@@ -131,6 +166,7 @@ function RepoTabs({
|
||||
entries={treeEntries}
|
||||
isLoading={treeLoading}
|
||||
repoId={repoId}
|
||||
preview={preview}
|
||||
/>
|
||||
<RepoReadmeSection readme={readme} isLoading={readmeLoading} />
|
||||
</>
|
||||
@@ -144,28 +180,49 @@ function RepoTabs({
|
||||
|
||||
export function RepoDetailPage() {
|
||||
const { repoId } = useParams({ from: "/repos/$repoId" });
|
||||
const { data: repo, isLoading, error } = useRepo(repoId);
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
|
||||
const preview =
|
||||
import.meta.env.DEV &&
|
||||
new URLSearchParams(window.location.search).get("preview") ===
|
||||
"repositories";
|
||||
const mockRepo = preview ? getMockRepo(repoId) : undefined;
|
||||
const showMockRepo = Boolean(mockRepo);
|
||||
const {
|
||||
data: repo,
|
||||
isLoading,
|
||||
error,
|
||||
} = useRepo(repoId, {
|
||||
preview: showMockRepo,
|
||||
});
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
|
||||
preview: showMockRepo,
|
||||
});
|
||||
|
||||
const defaultRef = refs?.head?.ref ?? "main";
|
||||
const owner = repo?.owner ?? "";
|
||||
const repoName = repo?.id ?? "";
|
||||
const browseOwner = showMockRepo ? "" : owner;
|
||||
|
||||
const {
|
||||
data: treeEntries,
|
||||
isLoading: treeLoading,
|
||||
data: fetchedTreeEntries,
|
||||
isLoading: isTreeLoading,
|
||||
error: treeError,
|
||||
} = useGitTree(owner, repoName, defaultRef);
|
||||
} = useGitTree(browseOwner, repoName, defaultRef);
|
||||
const {
|
||||
data: commits,
|
||||
isLoading: commitsLoading,
|
||||
data: fetchedCommits,
|
||||
isLoading: areCommitsLoading,
|
||||
error: commitsError,
|
||||
} = useGitLog(owner, repoName, defaultRef);
|
||||
const { data: readme, isLoading: readmeLoading } = useGitReadme(
|
||||
owner,
|
||||
} = useGitLog(browseOwner, repoName, defaultRef);
|
||||
const { data: fetchedReadme, isLoading: isReadmeLoading } = useGitReadme(
|
||||
browseOwner,
|
||||
repoName,
|
||||
defaultRef,
|
||||
);
|
||||
const treeEntries = showMockRepo ? mockRepoTree : fetchedTreeEntries;
|
||||
const commits = showMockRepo ? mockRepoCommits : fetchedCommits;
|
||||
const readme = showMockRepo ? mockRepoReadme : fetchedReadme;
|
||||
const treeLoading = showMockRepo ? false : isTreeLoading;
|
||||
const commitsLoading = showMockRepo ? false : areCommitsLoading;
|
||||
const readmeLoading = showMockRepo ? false : isReadmeLoading;
|
||||
|
||||
// Surface clone/browse errors — these are otherwise silent
|
||||
const browseError = treeError || commitsError;
|
||||
@@ -187,19 +244,15 @@ export function RepoDetailPage() {
|
||||
|
||||
if (!repo) {
|
||||
return (
|
||||
<div className="flex w-full gap-8 px-4 py-8">
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
<div className="min-w-0 flex-1">
|
||||
<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>
|
||||
<BackToRepositories />
|
||||
<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">
|
||||
<BookMarked className="mx-auto h-10 w-10 text-black/50 dark:text-white/50" />
|
||||
<h1 className="mt-4 text-xl font-semibold text-black dark:text-white">
|
||||
Repository not found
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-black/60 dark:text-white/60">
|
||||
This repository may have been removed or doesn't exist on this
|
||||
relay.
|
||||
</p>
|
||||
@@ -211,17 +264,11 @@ export function RepoDetailPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-8 px-4 py-8">
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 text-black dark:bg-[#171717] dark:text-white">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* 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>
|
||||
<BackToRepositories mockPreview={preview} />
|
||||
|
||||
{/* Mobile-only connect button */}
|
||||
<div className="mt-4 lg:hidden">
|
||||
@@ -231,18 +278,23 @@ export function RepoDetailPage() {
|
||||
{/* 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">
|
||||
<BookMarked className="h-6 w-6 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight text-black dark:text-white">
|
||||
{repo.name}
|
||||
</h1>
|
||||
<Badge variant="outline">Public</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
Public
|
||||
</Badge>
|
||||
</div>
|
||||
{repo.description && (
|
||||
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
|
||||
<p className="mt-2 text-sm leading-relaxed text-black/60 dark:text-white/60">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
<p className="mt-2 text-xs text-black/50 dark:text-white/50">
|
||||
Updated {relativeTime(repo.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -269,12 +321,15 @@ export function RepoDetailPage() {
|
||||
commitsLoading={commitsLoading}
|
||||
readme={readme}
|
||||
readmeLoading={readmeLoading}
|
||||
preview={showMockRepo}
|
||||
/>
|
||||
|
||||
{/* Clone URLs */}
|
||||
{repo.cloneUrls.length > 0 && (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 text-sm font-semibold">Clone</h2>
|
||||
<h2 className="mb-3 text-sm font-semibold text-black dark:text-white">
|
||||
Clone
|
||||
</h2>
|
||||
<div className="space-y-2">
|
||||
{repo.cloneUrls.map((url) => (
|
||||
<CopyableUrl key={url} url={url} />
|
||||
@@ -297,7 +352,11 @@ export function RepoDetailPage() {
|
||||
if (!safe) return null;
|
||||
return (
|
||||
<div className="mt-6">
|
||||
<Button variant="outline" asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<a href={safe} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
View on web
|
||||
@@ -310,7 +369,11 @@ export function RepoDetailPage() {
|
||||
{/* Channel link */}
|
||||
{repo.channelId && (
|
||||
<div className="mt-8">
|
||||
<Button variant="outline" asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-black/10 bg-white text-black hover:bg-black/5 dark:border-white/10 dark:bg-white/5 dark:text-white dark:hover:bg-white/10"
|
||||
asChild
|
||||
>
|
||||
<a href={`/channels/${repo.channelId}`}>
|
||||
<MessageSquare className="h-4 w-4" />
|
||||
View channel
|
||||
@@ -321,14 +384,14 @@ export function RepoDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
|
||||
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
|
||||
<div className="space-y-6">
|
||||
{/* Open in Buzz */}
|
||||
<ConnectButton className="w-full" />
|
||||
|
||||
{/* People */}
|
||||
<div>
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<h3 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<Users className="h-4 w-4" />
|
||||
People
|
||||
</h3>
|
||||
|
||||
@@ -7,33 +7,43 @@ import { relativeTime } from "@/shared/lib/relative-time";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import type { Repo } from "../use-repos";
|
||||
|
||||
export function RepoListItem({ repo }: { repo: Repo }) {
|
||||
export function RepoListItem({
|
||||
repo,
|
||||
preview = false,
|
||||
}: {
|
||||
repo: Repo;
|
||||
preview?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="py-6 text-black dark:text-white">
|
||||
{/* Row 1: Name + badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<BookMarked className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<BookMarked className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<Link
|
||||
to="/repos/$repoId"
|
||||
params={{ repoId: repo.id }}
|
||||
className="text-lg font-semibold text-primary hover:underline"
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="text-lg font-semibold text-black underline-offset-4 hover:text-black/70 hover:underline dark:text-white dark:hover:text-white/70"
|
||||
>
|
||||
{repo.name}
|
||||
</Link>
|
||||
<Badge variant="outline" className="ml-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="ml-1 border-black/15 text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
Public
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Description */}
|
||||
{repo.description && (
|
||||
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
|
||||
<p className="mt-1 line-clamp-2 text-sm text-black/60 dark:text-white/60">
|
||||
{repo.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Row 3: Metadata */}
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<div className="mt-2 flex items-center gap-4 text-xs text-black/50 dark:text-white/50">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="cursor-default font-mono">
|
||||
|
||||
@@ -13,14 +13,14 @@ export function RepoReadmeSection({
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
README
|
||||
</h2>
|
||||
<div className="space-y-2 rounded-lg border border-border p-4">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-muted" />
|
||||
<div className="space-y-2 rounded-lg border border-black/10 p-4 dark:border-white/10">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-1/2 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-5/6 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -30,11 +30,11 @@ export function RepoReadmeSection({
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-semibold text-black dark:text-white">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
{readme.filename}
|
||||
</h2>
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-border p-4">
|
||||
<div className="prose prose-sm dark:prose-invert max-w-none rounded-lg border border-black/10 bg-white/50 p-4 dark:border-white/10 dark:bg-white/5">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{readme.content}</Markdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,22 +17,28 @@ export function RepoRefsSection({
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{hasRefs ? (
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm text-black/60 dark:text-white/60">
|
||||
{refs.head && (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Badge variant="secondary">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-black/5 text-black dark:bg-white/10 dark:text-white"
|
||||
>
|
||||
<GitBranch className="mr-1 h-4 w-4" />
|
||||
{refs.head.ref}
|
||||
</Badge>
|
||||
{refs.head.sha && (
|
||||
<Badge variant="outline" className="font-mono text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-black/15 font-mono text-xs text-black/60 dark:border-white/15 dark:text-white/60"
|
||||
>
|
||||
<Hash className="mr-0.5 h-4 w-4" />
|
||||
{refs.head.sha.slice(0, 7)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="text-black/30 dark:text-white/30">·</span>
|
||||
</>
|
||||
)}
|
||||
<span className="flex items-center gap-1">
|
||||
@@ -40,14 +46,16 @@ export function RepoRefsSection({
|
||||
{refs.branches.length}{" "}
|
||||
{refs.branches.length === 1 ? "branch" : "branches"}
|
||||
</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="text-black/30 dark:text-white/30">·</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Tag className="h-4 w-4" />
|
||||
{refs.tags.length} {refs.tags.length === 1 ? "tag" : "tags"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">No commits yet</p>
|
||||
<p className="text-sm text-black/60 dark:text-white/60">
|
||||
No commits yet
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -2,16 +2,24 @@ import { File, Folder } from "lucide-react";
|
||||
import { Link } from "@tanstack/react-router";
|
||||
import type { TreeEntry } from "../git-client";
|
||||
|
||||
function TreeRow({ entry, repoId }: { entry: TreeEntry; repoId: string }) {
|
||||
function TreeRow({
|
||||
entry,
|
||||
repoId,
|
||||
preview,
|
||||
}: {
|
||||
entry: TreeEntry;
|
||||
repoId: string;
|
||||
preview: boolean;
|
||||
}) {
|
||||
if (entry.type === "tree") {
|
||||
// Sub-tree navigation is deferred — show folders as visibly non-clickable
|
||||
// so the affordance matches the behaviour.
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm text-muted-foreground last:border-b-0"
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black/60 last:border-b-0 dark:border-white/10 dark:text-white/60"
|
||||
aria-disabled="true"
|
||||
>
|
||||
<Folder className="h-4 w-4 shrink-0 text-blue-400" />
|
||||
<Folder className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<span className="font-medium">{entry.name}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -21,9 +29,10 @@ function TreeRow({ entry, repoId }: { entry: TreeEntry; repoId: string }) {
|
||||
<Link
|
||||
to="/repos/$repoId/blob/$"
|
||||
params={{ repoId, _splat: entry.name }}
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 text-sm last:border-b-0 hover:bg-muted/50"
|
||||
search={preview ? { preview: "repositories" } : undefined}
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 text-sm text-black last:border-b-0 hover:bg-black/5 dark:border-white/10 dark:text-white dark:hover:bg-white/5"
|
||||
>
|
||||
<File className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<File className="h-4 w-4 shrink-0 text-black/50 dark:text-white/50" />
|
||||
<span>{entry.name}</span>
|
||||
</Link>
|
||||
);
|
||||
@@ -33,22 +42,24 @@ export function RepoTreeSection({
|
||||
entries,
|
||||
isLoading,
|
||||
repoId,
|
||||
preview = false,
|
||||
}: {
|
||||
entries: TreeEntry[] | undefined;
|
||||
isLoading: boolean;
|
||||
repoId: string;
|
||||
preview?: boolean;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="rounded-lg border border-border">
|
||||
<div className="rounded-lg border border-black/10 dark:border-white/10">
|
||||
{["sk-1", "sk-2", "sk-3", "sk-4", "sk-5"].map((key) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center gap-2 border-b border-border px-3 py-2 last:border-b-0"
|
||||
className="flex items-center gap-2 border-b border-black/10 px-3 py-2 last:border-b-0 dark:border-white/10"
|
||||
>
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-4 w-32 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -60,9 +71,14 @@ export function RepoTreeSection({
|
||||
|
||||
return (
|
||||
<div className="mt-8">
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<div className="overflow-hidden rounded-lg border border-black/10 bg-white/50 dark:border-white/10 dark:bg-white/5">
|
||||
{entries.map((entry) => (
|
||||
<TreeRow key={entry.name} entry={entry} repoId={repoId} />
|
||||
<TreeRow
|
||||
key={entry.name}
|
||||
entry={entry}
|
||||
repoId={repoId}
|
||||
preview={preview}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { BookMarked, GitBranch, Hexagon } from "lucide-react";
|
||||
import { BookMarked, GitBranch } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import buzzAppIcon from "@/assets/app-icon@3x.png";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { mockRepos } from "../mock-repos";
|
||||
import { useRepos } from "../use-repos";
|
||||
import { ConnectButton } from "./ConnectButton";
|
||||
import { OrgSidebar } from "./OrgSidebar";
|
||||
@@ -14,14 +16,14 @@ function ListItemSkeleton() {
|
||||
return (
|
||||
<div className="py-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-muted" />
|
||||
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
|
||||
<div className="h-5 w-14 animate-pulse rounded bg-muted" />
|
||||
<div className="h-4 w-4 shrink-0 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-5 w-48 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-5 w-14 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
<div className="mt-2 h-4 w-3/4 animate-pulse rounded bg-muted" />
|
||||
<div className="mt-2 h-4 w-3/4 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="mt-2 flex gap-4">
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
|
||||
<div className="h-3 w-24 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
<div className="h-3 w-20 animate-pulse rounded bg-black/10 dark:bg-white/10" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -30,11 +32,13 @@ function ListItemSkeleton() {
|
||||
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 className="flex h-14 w-14 items-center justify-center rounded-full bg-black/5 dark:bg-white/10">
|
||||
<GitBranch className="h-7 w-7 text-black/50 dark:text-white/50" />
|
||||
</div>
|
||||
<h2 className="mt-4 text-lg font-semibold">No matching repositories</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-muted-foreground">
|
||||
<h2 className="mt-4 text-lg font-semibold text-black dark:text-white">
|
||||
No matching repositories
|
||||
</h2>
|
||||
<p className="mt-1 max-w-sm text-sm text-black/60 dark:text-white/60">
|
||||
Try adjusting your search term.
|
||||
</p>
|
||||
</div>
|
||||
@@ -43,24 +47,44 @@ function SearchEmptyState() {
|
||||
|
||||
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">
|
||||
<Hexagon className="h-8 w-8 text-primary" />
|
||||
<div className="flex flex-1 items-center justify-center bg-[#F3F3F3] px-4 py-16 text-center dark:bg-[#171717]">
|
||||
<div className="flex w-full max-w-xl flex-col items-center px-6 py-10 sm:px-12 sm:py-12">
|
||||
<div
|
||||
className="h-16 w-16 overflow-hidden bg-black"
|
||||
style={{ borderRadius: "22.37%" }}
|
||||
>
|
||||
<img alt="Buzz" className="h-full w-full" src={buzzAppIcon} />
|
||||
</div>
|
||||
<h1 className="mt-6 text-2xl font-semibold tracking-tight text-black dark:text-white">
|
||||
This relay is empty
|
||||
</h1>
|
||||
<p className="mt-2 max-w-md text-sm leading-relaxed text-black/60 dark:text-white/60">
|
||||
Repositories pushed to this relay will show up here. Open this relay
|
||||
in the Buzz desktop app to start pushing code.
|
||||
</p>
|
||||
<ConnectButton className="mt-6" />
|
||||
</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 Buzz desktop app to start pushing code.
|
||||
</p>
|
||||
<ConnectButton className="mt-6" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReposPage() {
|
||||
const { data: repos, isLoading, error } = useRepos();
|
||||
const preview = import.meta.env.DEV
|
||||
? new URLSearchParams(window.location.search).get("preview")
|
||||
: null;
|
||||
const showMockRepos = preview === "repositories";
|
||||
const showMockEmptyState = preview === "empty";
|
||||
const {
|
||||
data: fetchedRepos,
|
||||
isLoading: isLoadingRepos,
|
||||
error,
|
||||
} = useRepos({ enabled: !showMockRepos && !showMockEmptyState });
|
||||
const repos = showMockRepos
|
||||
? mockRepos
|
||||
: showMockEmptyState
|
||||
? []
|
||||
: fetchedRepos;
|
||||
const isLoading = preview ? false : isLoadingRepos;
|
||||
const [search, setSearch] = useState("");
|
||||
const [sort, setSort] = useState<SortOrder>("newest");
|
||||
|
||||
@@ -101,9 +125,9 @@ export function ReposPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex w-full gap-8 px-4 py-8">
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
|
||||
<BookMarked className="h-4 w-4" /> Repositories
|
||||
</h2>
|
||||
<div className="divide-y">
|
||||
@@ -122,7 +146,7 @@ export function ReposPage() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full gap-8 px-4 py-8">
|
||||
<div className="flex w-full flex-1 gap-8 bg-[#F3F3F3] px-4 py-8 dark:bg-[#171717]">
|
||||
{/* Main content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
{/* Mobile-only connect button */}
|
||||
@@ -130,7 +154,7 @@ export function ReposPage() {
|
||||
<ConnectButton className="w-full" />
|
||||
</div>
|
||||
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold">
|
||||
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-black dark:text-white">
|
||||
<BookMarked className="h-4 w-4" /> Repositories
|
||||
</h2>
|
||||
|
||||
@@ -140,13 +164,13 @@ export function ReposPage() {
|
||||
placeholder="Find a repository..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
className="flex-1 border-black/10 bg-white text-black placeholder:text-black/40 dark:border-white/10 dark:bg-white/5 dark:text-white dark:placeholder:text-white/40"
|
||||
/>
|
||||
<select
|
||||
value={sort}
|
||||
onChange={(e) => setSort(e.target.value as SortOrder)}
|
||||
aria-label="Sort repositories"
|
||||
className="rounded-md border border-input bg-background px-3 py-1 text-sm text-foreground shadow-xs focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring"
|
||||
className="rounded-md border border-black/10 bg-white px-3 py-1 text-sm text-black shadow-xs focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-black dark:border-white/10 dark:bg-white/5 dark:text-white dark:focus-visible:ring-white"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
@@ -156,9 +180,9 @@ export function ReposPage() {
|
||||
|
||||
{/* Repo list */}
|
||||
{filteredRepos.length > 0 ? (
|
||||
<div className="divide-y">
|
||||
<div className="divide-y divide-black/10 dark:divide-white/10">
|
||||
{filteredRepos.map((repo) => (
|
||||
<RepoListItem key={repo.id} repo={repo} />
|
||||
<RepoListItem key={repo.id} repo={repo} preview={showMockRepos} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
@@ -167,7 +191,7 @@ export function ReposPage() {
|
||||
</div>
|
||||
|
||||
{/* Sidebar */}
|
||||
<aside className="hidden w-72 shrink-0 border-l border-border pl-8 lg:block">
|
||||
<aside className="hidden w-72 shrink-0 border-l border-black/10 pl-8 dark:border-white/10 lg:block">
|
||||
<OrgSidebar repos={repos} />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -17,13 +17,18 @@ export interface RepoContext {
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
export function useRepoContext(repoId: string): RepoContext {
|
||||
export function useRepoContext(
|
||||
repoId: string,
|
||||
{ preview = false } = {},
|
||||
): RepoContext {
|
||||
const {
|
||||
data: repo,
|
||||
isLoading: repoLoading,
|
||||
error: repoError,
|
||||
} = useRepo(repoId);
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId);
|
||||
} = useRepo(repoId, { preview });
|
||||
const { data: refs, isLoading: refsLoading } = useRepoRefs(repoId, {
|
||||
preview,
|
||||
});
|
||||
|
||||
return {
|
||||
owner: repo?.owner ?? "",
|
||||
|
||||
@@ -59,10 +59,17 @@ async function fetchRepoRefs(repoId: string): Promise<RepoRefs> {
|
||||
return parseRefs(events);
|
||||
}
|
||||
|
||||
export function useRepoRefs(repoId: string) {
|
||||
export function useRepoRefs(repoId: string, { preview = false } = {}) {
|
||||
const mockRefs: RepoRefs = {
|
||||
branches: ["main"],
|
||||
tags: ["v0.1.0"],
|
||||
head: { ref: "main", sha: "a".repeat(40) },
|
||||
};
|
||||
|
||||
return useQuery({
|
||||
queryKey: ["repo-refs", repoId],
|
||||
queryFn: () => fetchRepoRefs(repoId),
|
||||
queryKey: preview ? ["repo-refs", "mock", repoId] : ["repo-refs", repoId],
|
||||
queryFn: preview ? async () => mockRefs : () => fetchRepoRefs(repoId),
|
||||
initialData: preview ? mockRefs : undefined,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { queryEvents, type NostrEvent } from "@/shared/lib/nostr-client";
|
||||
import { relayWsUrl } from "@/shared/lib/relay-url";
|
||||
import { mockRepos } from "./mock-repos";
|
||||
|
||||
export interface Repo {
|
||||
id: string;
|
||||
@@ -68,18 +69,20 @@ async function fetchRepos(): Promise<Repo[]> {
|
||||
.sort((a, b) => b.createdAt - a.createdAt);
|
||||
}
|
||||
|
||||
export function useRepos() {
|
||||
export function useRepos({ enabled = true }: { enabled?: boolean } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: fetchRepos,
|
||||
enabled,
|
||||
staleTime: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useRepo(repoId: string) {
|
||||
export function useRepo(repoId: string, { preview = false } = {}) {
|
||||
return useQuery({
|
||||
queryKey: ["repos"],
|
||||
queryFn: fetchRepos,
|
||||
queryKey: preview ? ["repos", "mock"] : ["repos"],
|
||||
queryFn: preview ? async () => mockRepos : fetchRepos,
|
||||
initialData: preview ? mockRepos : undefined,
|
||||
staleTime: 60_000,
|
||||
select: (repos) => repos.find((r) => r.id === repoId),
|
||||
});
|
||||
|
||||
@@ -15,33 +15,35 @@ type ThemeContextValue = {
|
||||
setTheme: (theme: Theme) => void;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "buzz-web-theme";
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||
|
||||
function getSystemDark(): boolean {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
}
|
||||
|
||||
function getInitialTheme(): Theme {
|
||||
if (!import.meta.env.DEV) return "system";
|
||||
|
||||
const previewTheme = new URLSearchParams(window.location.search).get(
|
||||
"previewTheme",
|
||||
);
|
||||
return previewTheme === "light" || previewTheme === "dark"
|
||||
? previewTheme
|
||||
: "system";
|
||||
}
|
||||
|
||||
function applyClass(isDark: boolean) {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
root.classList.add(isDark ? "dark" : "light");
|
||||
}
|
||||
|
||||
function readStoredTheme(): Theme {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
return stored;
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setThemeState] = useState<Theme>(readStoredTheme);
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
|
||||
const [isDark, setIsDark] = useState<boolean>(() => {
|
||||
const t = readStoredTheme();
|
||||
const dark = t === "system" ? getSystemDark() : t === "dark";
|
||||
const initialTheme = getInitialTheme();
|
||||
const dark =
|
||||
initialTheme === "system" ? getSystemDark() : initialTheme === "dark";
|
||||
applyClass(dark);
|
||||
return dark;
|
||||
});
|
||||
@@ -65,7 +67,6 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
}, [theme]);
|
||||
|
||||
const setTheme = useCallback((t: Theme) => {
|
||||
window.localStorage.setItem(STORAGE_KEY, t);
|
||||
setThemeState(t);
|
||||
}, []);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user