Skip to content
79 linesCodeBlameRaw
1/**
2 * Starring a repository: a button in the project's header beside Watch,
3 * with how many have starred it, which leads to who did. Posts to the
4 * repository's star route (routes/repo/star.ts); while it is on its way it
5 * shows as done. Someone signed out is sent to sign in.
6 */
7import { Star } from "lucide-react";
8import { Link, useFetcher, useLocation } from "react-router";
9
10import type { Stars } from "@g1t/contracts";
11
12import { compact } from "../lib/about";
13import { cn } from "../lib/cn";
14import { Button } from "./ui/button";
15import { Hint } from "./ui/hint";
16
17const BOX = "inline-flex h-8 shrink-0 items-center text-[0.8125rem] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent";
18
19export function StarButton({ base, name, stars, signedIn }: { base: string; name: string; stars: Stars; signedIn: boolean }) {
20 const fetcher = useFetcher<{ error: string | null; stars: Stars | null }>({ key: `star:${base}` });
21 const { pathname, search } = useLocation();
22 const asked = fetcher.formData?.get("intent");
23 const done = fetcher.state === "idle" ? fetcher.data?.stars : null;
24 const starred = asked === "star" ? true : asked === "unstar" ? false : (done?.starred ?? stars.starred);
25 const shownCount = asked
26 ? stars.stars + (asked === "star" && !stars.starred ? 1 : asked === "unstar" && stars.starred ? -1 : 0)
27 : (done?.stars ?? stars.stars);
28 const error = fetcher.state === "idle" ? fetcher.data?.error : null;
29 const label = starred ? `Unstar ${name}` : `Star ${name}`;
30 const face = (
31 <>
32 <Star size={14} className={starred ? "fill-current text-warn" : undefined} />
33 <span className="hidden sm:inline">{starred ? "Starred" : "Star"}</span>
34 </>
35 );
36 const counter = (
37 <Hint label={`${shownCount.toLocaleString("en-US")} ${shownCount === 1 ? "star" : "stars"}`}>
38 <Link
39 to={`${base}/stargazers`}
40 aria-label={`${shownCount.toLocaleString("en-US")} ${shownCount === 1 ? "star" : "stars"}: who starred it`}
41 className="inline-flex h-full items-center border-l border-line px-2 font-medium tabular-nums text-fg/80 hover:bg-surface hover:text-accent"
42 >
43 {compact(shownCount)}
44 </Link>
45 </Hint>
46 );
47 if (!signedIn) {
48 return (
49 <span className={cn(BOX, "overflow-hidden rounded-md border border-line")}>
50 <Link
51 to={`/login?next=${encodeURIComponent(pathname + search)}`}
52 aria-label={`Sign in to star ${name}`}
53 className="inline-flex h-full items-center gap-1.5 px-2.5 text-fg/80 hover:bg-surface hover:text-fg"
54 >
55 {face}
56 </Link>
57 {counter}
58 </span>
59 );
60 }
61 return (
62 <fetcher.Form method="post" action={`${base}/star`} className={cn(BOX, "overflow-hidden rounded-md border border-line", error && "border-danger/60")}>
63 <input type="hidden" name="intent" value={starred ? "unstar" : "star"} />
64 <Hint label={error ?? null}>
65 <Button
66 type="submit"
67 aria-label={label}
68 aria-pressed={starred}
69 variant="ghost"
70 size="inline"
71 className="h-full rounded-none px-2.5 font-normal text-fg/80 hover:bg-surface aria-pressed:bg-transparent aria-pressed:text-fg/80"
72 >
73 {face}
74 </Button>
75 </Hint>
76 {counter}
77 </fetcher.Form>
78 );
79}