Detailed changes
@@ -6,14 +6,14 @@
// In external mode, shows a "Sign in" button when logged out and a sign-out
// action when logged in.
-import { Link, useMatchRoute, useParams } from "@tanstack/react-router";
+import { Link, useParams } from "@tanstack/react-router";
import { Bug, Plus, Sun, Moon, LogIn, LogOut } from "lucide-react";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
+import { ButtonLink, NavLink } from "@/components/ui/button-link";
import { useAuth } from "@/lib/auth";
import { useTheme } from "@/lib/theme";
-import { cn } from "@/lib/utils";
// SignOutButton sends a POST to /auth/logout and reloads the page.
// A full reload is the simplest way to reset all Apollo cache + React state.
@@ -41,14 +41,6 @@ export function Header() {
// Don't show repo nav on the /auth/* pages.
const effectiveRepo = repo === "auth" ? null : repo;
- const matchRoute = useMatchRoute();
- const isCodeActive = effectiveRepo
- ? !!matchRoute({ to: "/$repo", params: { repo: effectiveRepo }, fuzzy: false })
- : false;
- const isIssuesActive = effectiveRepo
- ? !!matchRoute({ to: "/$repo/issues", params: { repo: effectiveRepo }, fuzzy: true })
- : false;
-
return (
<header className="border-border bg-background/95 sticky top-0 z-50 border-b backdrop-blur">
<div className="mx-auto flex h-14 max-w-screen-xl items-center gap-6 px-4">
@@ -61,31 +53,17 @@ export function Header() {
{/* Repo-scoped nav links β only shown when inside a repo */}
{effectiveRepo && (
<nav className="flex items-center gap-1">
- <Link
+ <NavLink
to="/$repo"
params={{ repo: effectiveRepo }}
search={{ ref: "", path: "", type: "tree" as const }}
- className={cn(
- "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- isCodeActive
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
- )}
+ activeOptions={{ exact: true }}
>
Code
- </Link>
- <Link
- to="/$repo/issues"
- params={{ repo: effectiveRepo }}
- className={cn(
- "rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
- isIssuesActive
- ? "bg-accent text-accent-foreground"
- : "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
- )}
- >
+ </NavLink>
+ <NavLink to="/$repo/issues" params={{ repo: effectiveRepo }}>
Issues
- </Link>
+ </NavLink>
</nav>
)}
@@ -110,12 +88,10 @@ export function Header() {
{user && effectiveRepo && (
<>
- <Button asChild size="sm">
- <Link to="/$repo/issues/new" params={{ repo: effectiveRepo }}>
- <Plus className="size-4" />
- New issue
- </Link>
- </Button>
+ <ButtonLink to="/$repo/issues/new" params={{ repo: effectiveRepo }} size="sm">
+ <Plus className="size-4" />
+ New issue
+ </ButtonLink>
<Link to="/$repo/user/$id" params={{ repo: effectiveRepo, id: user.humanId }}>
<Avatar className="size-7">
<AvatarImage src={user.avatarUrl ?? undefined} alt={user.displayName} />
@@ -0,0 +1,63 @@
+import { createLink, type LinkComponent } from "@tanstack/react-router";
+import type { VariantProps } from "class-variance-authority";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+import { buttonVariants } from "./button";
+
+// A proper TanStack Router link that looks like a Button.
+// Replaces the `<Button asChild><Link β¦/></Button>` pattern,
+// giving us preloading, typed routes, and active link support.
+interface ButtonLinkProps extends VariantProps<typeof buttonVariants> {
+ className?: string;
+ children?: React.ReactNode;
+}
+
+const ButtonLinkComponent = React.forwardRef<
+ HTMLAnchorElement,
+ ButtonLinkProps & React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ className, variant, size, children, ...props }, ref) => {
+ return (
+ <a ref={ref} className={cn(buttonVariants({ variant, size, className }))} {...props}>
+ {children}
+ </a>
+ );
+});
+ButtonLinkComponent.displayName = "ButtonLinkComponent";
+
+const CreatedButtonLink = createLink(ButtonLinkComponent);
+
+export const ButtonLink: LinkComponent<typeof ButtonLinkComponent> = (props) => {
+ return <CreatedButtonLink preload="intent" {...props} />;
+};
+
+// A nav link that uses activeProps/inactiveProps for styling.
+// Replaces the manual useMatchRoute() pattern in the header.
+const NavLinkComponent = React.forwardRef<
+ HTMLAnchorElement,
+ React.AnchorHTMLAttributes<HTMLAnchorElement>
+>(({ children, ...props }, ref) => {
+ return (
+ <a ref={ref} {...props}>
+ {children}
+ </a>
+ );
+});
+NavLinkComponent.displayName = "NavLinkComponent";
+
+const CreatedNavLink = createLink(NavLinkComponent);
+
+export const NavLink: LinkComponent<typeof NavLinkComponent> = (props) => {
+ return (
+ <CreatedNavLink
+ preload="intent"
+ className="text-muted-foreground hover:bg-accent hover:text-accent-foreground rounded-md px-3 py-1.5 text-sm font-medium transition-colors"
+ activeProps={{ className: "bg-accent text-accent-foreground" }}
+ inactiveProps={{
+ className: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
+ }}
+ {...props}
+ />
+ );
+};
@@ -39,13 +39,11 @@ export function BugDetailPage() {
return <div className="text-muted-foreground py-16 text-center text-sm">Issue not found.</div>;
}
- const issuesHref = repo ? `/${repo}/issues` : "/issues";
- const authorHref = repo ? `/${repo}/user/${bug.author.humanId}` : `/user/${bug.author.humanId}`;
-
return (
<div>
<Link
- to={issuesHref}
+ to="/$repo/issues"
+ params={{ repo: repo! }}
className="text-muted-foreground hover:text-foreground mb-4 flex items-center gap-1.5 text-sm"
>
<ArrowLeft className="size-3.5" />
@@ -60,7 +58,11 @@ export function BugDetailPage() {
<div className="text-muted-foreground mb-6 flex flex-wrap items-center gap-3 text-sm">
<StatusBadge status={bug.status} />
<span>
- <Link to={authorHref} className="text-foreground font-medium hover:underline">
+ <Link
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: bug.author.humanId }}
+ className="text-foreground font-medium hover:underline"
+ >
{bug.author.displayName}
</Link>{" "}
opened this issue {formatDistanceToNow(new Date(bug.createdAt), { addSuffix: true })}
@@ -88,9 +90,13 @@ export function BugDetailPage() {
</h3>
<div className="flex flex-wrap gap-1.5">
{bug.participants.nodes.map((p) => {
- const participantHref = repo ? `/${repo}/user/${p.humanId}` : `/user/${p.humanId}`;
return (
- <Link key={p.id} to={participantHref} title={p.displayName}>
+ <Link
+ key={p.id}
+ to="/$repo/user/$id"
+ params={{ repo: repo!, id: p.humanId }}
+ title={p.displayName}
+ >
<Avatar className="size-6">
<AvatarImage src={p.avatarUrl ?? undefined} alt={p.displayName} />
<AvatarFallback className="text-[10px]">
@@ -15,7 +15,7 @@ import type { TreeEntryWithCommit } from "@/components/code/FileTree";
import { FileViewer } from "@/components/code/FileViewer";
import { RefSelector } from "@/components/code/RefSelector";
import { Markdown } from "@/components/content/Markdown";
-import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
import { Skeleton } from "@/components/ui/skeleton";
import { useRepo } from "@/lib/repo";
@@ -222,14 +222,20 @@ export function CodePage() {
)}
<div className="flex items-center gap-2">
{!refsLoading && (
- <Button
+ <ButtonLink
+ to="/$repo"
+ params={{ repo: repo! }}
+ search={{
+ ref: currentRef,
+ path: currentPath,
+ type: viewMode === "commits" ? "tree" : "commits",
+ }}
variant={viewMode === "commits" ? "secondary" : "outline"}
size="sm"
- onClick={() => navigateTo(currentPath, viewMode === "commits" ? "tree" : "commits")}
>
<GitCommit className="size-3.5" />
History
- </Button>
+ </ButtonLink>
)}
{refsLoading ? (
<Skeleton className="h-8 w-28" />
@@ -1,9 +1,10 @@
// Global error boundary page. Rendered by TanStack Router when a route throws.
-import { Link, useRouter } from "@tanstack/react-router";
+import { useRouter } from "@tanstack/react-router";
import { AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
export function ErrorPage({ error }: { error?: Error }) {
const router = useRouter();
@@ -24,9 +25,9 @@ export function ErrorPage({ error }: { error?: Error }) {
>
Try again
</Button>
- <Button variant="outline" size="sm" asChild>
- <Link to="/">Go home</Link>
- </Button>
+ <ButtonLink to="/" variant="outline" size="sm">
+ Go home
+ </ButtonLink>
</div>
</div>
);
@@ -1,10 +1,11 @@
-import { useNavigate, Link } from "@tanstack/react-router";
+import { Link, useNavigate } from "@tanstack/react-router";
import { ArrowLeft } from "lucide-react";
import { useState } from "react";
import { useBugCreateMutation } from "@/__generated__/graphql";
import { Markdown } from "@/components/content/Markdown";
import { Button } from "@/components/ui/button";
+import { ButtonLink } from "@/components/ui/button-link";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { useRepo } from "@/lib/repo";
@@ -16,27 +17,27 @@ export function NewBugPage() {
const [title, setTitle] = useState("");
const [message, setMessage] = useState("");
const [preview, setPreview] = useState(false);
-
const [createBug, { loading, error }] = useBugCreateMutation();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
- if (!title.trim()) return;
const result = await createBug({
variables: { input: { title: title.trim(), message: message.trim() } },
});
const humanId = result.data?.bugCreate.bug.humanId;
if (humanId) {
- void navigate({ to: repo ? `/${repo}/issues/${humanId}` : `/issues/${humanId}` });
+ void navigate({
+ to: "/$repo/issues/$id",
+ params: { repo: repo!, id: humanId },
+ });
}
}
- const issuesHref = repo ? `/${repo}/issues` : "/issues";
-
return (
<div className="mx-auto max-w-3xl">
<Link
- to={issuesHref}
+ to="/$repo/issues"
+ params={{ repo: repo! }}
className="text-muted-foreground hover:text-foreground mb-6 flex items-center gap-1.5 text-sm"
>
<ArrowLeft className="size-3.5" />
@@ -51,24 +52,17 @@ export function NewBugPage() {
}}
className="space-y-4"
>
- <div>
- <label htmlFor="title" className="mb-1.5 block text-sm font-medium">
- Title
- </label>
- <Input
- id="title"
- placeholder="Brief description of the issue"
- value={title}
- onChange={(e) => setTitle(e.target.value)}
- required
- disabled={loading}
- />
- </div>
+ <Input
+ placeholder="Title"
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ disabled={loading}
+ autoFocus
+ />
<div>
- <div className="mb-1.5 flex items-center justify-between">
- <label className="text-sm font-medium">Description</label>
- <div className="flex gap-1 text-sm">
+ <div className="mb-2">
+ <div className="flex gap-2">
<button
type="button"
onClick={() => setPreview(false)}
@@ -111,16 +105,9 @@ export function NewBugPage() {
)}
<div className="flex justify-end gap-2">
- <Button
- type="button"
- variant="ghost"
- onClick={() => {
- void navigate({ to: issuesHref });
- }}
- disabled={loading}
- >
+ <ButtonLink to="/$repo/issues" params={{ repo: repo! }} variant="ghost">
Cancel
- </Button>
+ </ButtonLink>
<Button type="submit" disabled={!title.trim() || loading}>
{loading ? "Creatingβ¦" : "Submit new issue"}
</Button>