Skip to content

g1t/apps/web/app/components/star-button.tsx

76 lines3,229 bytesCodeBlame
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 { Hint } from "./ui/hint";
15
16const BOX = "inline-flex h-8 shrink-0 items-center text-[0.8125rem] transition-colors outline-none focus-visible:ring-2 focus-visible:ring-accent";
17
18export function StarButton({ base, name, stars, signedIn }: { base: string; name: string; stars: Stars; signedIn: boolean }) {
19 const fetcher = useFetcher<{ error: string | null; stars: Stars | null }>({ key: `star:${base}` });
20 const { pathname, search } = useLocation();
21 const asked = fetcher.formData?.get("intent");
22 const done = fetcher.state === "idle" ? fetcher.data?.stars : null;
23 const starred = asked === "star" ? true : asked === "unstar" ? false : (done?.starred ?? stars.starred);
24 const shownCount = asked
25 ? stars.stars + (asked === "star" && !stars.starred ? 1 : asked === "unstar" && stars.starred ? -1 : 0)
26 : (done?.stars ?? stars.stars);
27 const error = fetcher.state === "idle" ? fetcher.data?.error : null;
28 const label = starred ? `Unstar ${name}` : `Star ${name}`;
29 const face = (
30 <>
31 <Star size={14} className={starred ? "fill-current text-warn" : undefined} />
32 <span className="hidden sm:inline">{starred ? "Starred" : "Star"}</span>
33 </>
34 );
35 const counter = (
36 <Hint label={`${shownCount.toLocaleString("en-US")} ${shownCount === 1 ? "star" : "stars"}`}>
37 <Link
38 to={`${base}/stargazers`}
39 aria-label={`${shownCount.toLocaleString("en-US")} ${shownCount === 1 ? "star" : "stars"}: who starred it`}
40 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"
41 >
42 {compact(shownCount)}
43 </Link>
44 </Hint>
45 );
46 if (!signedIn) {
47 return (
48 <span className={cn(BOX, "overflow-hidden rounded-md border border-line")}>
49 <Link
50 to={`/login?next=${encodeURIComponent(pathname + search)}`}
51 aria-label={`Sign in to star ${name}`}
52 className="inline-flex h-full items-center gap-1.5 px-2.5 text-fg/80 hover:bg-surface hover:text-fg"
53 >
54 {face}
55 </Link>
56 {counter}
57 </span>
58 );
59 }
60 return (
61 <fetcher.Form method="post" action={`${base}/star`} className={cn(BOX, "overflow-hidden rounded-md border border-line", error && "border-danger/60")}>
62 <input type="hidden" name="intent" value={starred ? "unstar" : "star"} />
63 <Hint label={error ?? null}>
64 <button
65 type="submit"
66 aria-label={label}
67 aria-pressed={starred}
68 className="inline-flex h-full items-center gap-1.5 px-2.5 text-fg/80 hover:bg-surface hover:text-fg"
69 >
70 {face}
71 </button>
72 </Hint>
73 {counter}
74 </fetcher.Form>
75 );
76}