pr_01m47d24b0e6n91zwymwxg0vpx/apps/web/app/components/work.tsx

198 lines5,882 bytesCodeBlame
1import {
2 CircleCheck,
3 CircleDot,
4 CircleSlash,
5 GitMerge,
6 GitPullRequest,
7 GitPullRequestClosed,
8 GitPullRequestDraft,
9} from "lucide-react";
10import type { CSSProperties, ReactNode } from "react";
11import { Form, Link } from "react-router";
12
13import type { Comment, Issue, Pull, State } from "@g1t/contracts";
14
15import { Markdown } from "./markdown";
16import { Avatar, Button, Textarea, TimeAgo } from "./ui";
17
18/** Hues for the labels every repository starts with. */
19const LABEL_HUES: Record<string, number> = {
20 bug: 25,
21 feature: 150,
22 docs: 240,
23 chore: 80,
24 question: 310,
25};
26
27/** A stable hue for any other label, from its name. */
28function hueFor(name: string): number {
29 let hash = 0;
30 for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) % 360;
31 return hash;
32}
33
34export function Label({ name }: { name: string }) {
35 const hue = LABEL_HUES[name] ?? hueFor(name);
36 const style: CSSProperties = {
37 color: `oklch(0.84 0.11 ${hue})`,
38 borderColor: `oklch(0.84 0.11 ${hue} / 0.35)`,
39 backgroundColor: `oklch(0.84 0.11 ${hue} / 0.1)`,
40 };
41 return (
42 <span
43 style={style}
44 className="inline-flex shrink-0 items-center rounded-full border px-2 py-px text-xs font-medium"
45 >
46 {name}
47 </span>
48 );
49}
50
51export function IssueIcon({
52 issue,
53 size = 16,
54}: {
55 issue: Pick<Issue, "state" | "reason">;
56 size?: number;
57}) {
58 if (issue.state === "open") return <CircleDot size={size} className="shrink-0 text-accent" />;
59 if (issue.reason === "not_planned") {
60 return <CircleSlash size={size} className="shrink-0 text-faint" />;
61 }
62 return <CircleCheck size={size} className="shrink-0 text-merged" />;
63}
64
65export function PullIcon({ status, size = 16 }: { status: Pull["status"]; size?: number }) {
66 if (status === "merged") return <GitMerge size={size} className="shrink-0 text-merged" />;
67 if (status === "closed") {
68 return <GitPullRequestClosed size={size} className="shrink-0 text-danger" />;
69 }
70 if (status === "draft") {
71 return <GitPullRequestDraft size={size} className="shrink-0 text-faint" />;
72 }
73 return <GitPullRequest size={size} className="shrink-0 text-accent" />;
74}
75
76const PILL = "inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium";
77
78/** The large state badge at the top of an issue. */
79export function IssueState({ issue }: { issue: Pick<Issue, "state" | "reason"> }) {
80 const style =
81 issue.state === "open"
82 ? "border-accent/30 bg-accent/10 text-accent"
83 : issue.reason === "not_planned"
84 ? "border-line bg-surface text-muted"
85 : "border-merged/30 bg-merged/10 text-merged";
86 return (
87 <span className={`${PILL} ${style}`}>
88 <IssueIcon issue={issue} size={14} />
89 {issue.state === "open"
90 ? "Open"
91 : issue.reason === "not_planned"
92 ? "Closed as not planned"
93 : "Closed"}
94 </span>
95 );
96}
97
98const PULL_STYLES: Record<Pull["status"], string> = {
99 draft: "border-line bg-surface text-muted",
100 open: "border-accent/30 bg-accent/10 text-accent",
101 merged: "border-merged/30 bg-merged/10 text-merged",
102 closed: "border-danger/30 bg-danger/10 text-danger",
103};
104
105const PULL_NAMES: Record<Pull["status"], string> = {
106 draft: "Draft",
107 open: "Open",
108 merged: "Merged",
109 closed: "Closed",
110};
111
112export function PullState({ status }: { status: Pull["status"] }) {
113 return (
114 <span className={`${PILL} ${PULL_STYLES[status]}`}>
115 <PullIcon status={status} size={14} />
116 {PULL_NAMES[status]}
117 </span>
118 );
119}
120
121/** The open and closed switch above a list. `to` is the list's own path. */
122export function StateTabs({
123 to,
124 state,
125 query = "",
126 action,
127}: {
128 to: string;
129 state: State;
130 /** Other query parameters to keep, e.g. `label=bug`. */
131 query?: string;
132 action?: ReactNode;
133}) {
134 const link = (value: State) =>
135 `${to}?${[value === "open" ? "" : "state=closed", query].filter(Boolean).join("&")}`;
136 const tab = (value: State, label: string) => (
137 <Link
138 to={link(value)}
139 className={
140 "rounded-md px-3 py-1.5 text-sm transition-colors " +
141 (state === value ? "bg-raised font-medium text-fg" : "text-muted hover:text-fg")
142 }
143 >
144 {label}
145 </Link>
146 );
147 return (
148 <div className="flex flex-wrap items-center justify-between gap-3">
149 <div className="flex gap-1 rounded-lg border border-line p-1">
150 {tab("open", "Open")}
151 {tab("closed", "Closed")}
152 </div>
153 {action}
154 </div>
155 );
156}
157
158/** Comments in order, then the box to add one. */
159export function Comments({
160 comments,
161 canComment,
162}: {
163 comments: Comment[];
164 canComment: boolean;
165}) {
166 return (
167 <div className="space-y-4">
168 {comments.map((comment) => (
169 <article key={comment.id} className="rounded-xl border border-line bg-surface">
170 <header className="flex items-center gap-2 border-b border-line px-4 py-2 text-sm text-muted">
171 <Avatar name={comment.author.username} size={18} />
172 <span className="font-medium text-fg">{comment.author.username}</span>
173 <span>
174 commented <TimeAgo at={comment.createdAt} />
175 </span>
176 </header>
177 <div className="px-4 py-3">
178 <Markdown source={comment.body} />
179 </div>
180 </article>
181 ))}
182 {canComment ? (
183 <Form method="post" className="space-y-2" key={comments.length}>
184 <input type="hidden" name="action" value="comment" />
185 <Textarea name="body" rows={3} required placeholder="Leave a comment. Markdown works." />
186 <Button type="submit">Comment</Button>
187 </Form>
188 ) : (
189 <p className="text-sm text-muted">
190 <Link to="/login" className="text-fg underline underline-offset-4">
191 Sign in
192 </Link>{" "}
193 to comment.
194 </p>
195 )}
196 </div>
197 );
198}