Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/dashboard/src/components/layouts/dashboard-topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export function DashboardTopbar({
type="button"
className="flex size-8 items-center justify-center rounded-full"
>
<Avatar className="size-7">
<Avatar className="size-7 border border-border">
{user.image && !avatarLoadFailed ? (
<img
src={user.image}
Expand Down
181 changes: 181 additions & 0 deletions apps/dashboard/src/components/pulls/pull-request-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import {
CommentIcon,
GitMergeIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
GitPullRequestIcon,
ViewIcon,
} from "@quickhub/icons";
import { Markdown } from "@quickhub/ui/components/markdown";
import { useQuery } from "@tanstack/react-query";
import { Link } from "@tanstack/react-router";
import { useState } from "react";
import {
type GitHubQueryScope,
githubPullCommentsQueryOptions,
} from "#/lib/github.query";
import type { PullSummary } from "#/lib/github.types";

export function formatRelativeTime(dateStr: string): string {
const seconds = Math.floor((Date.now() - new Date(dateStr).getTime()) / 1000);
if (seconds < 60) return "just now";
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
const months = Math.floor(days / 30);
if (months < 12) return `${months}mo ago`;
const years = Math.floor(months / 12);
return `${years}y ago`;
}

function getPrStateProps(pr: PullSummary) {
if (pr.isDraft) {
return { icon: GitPullRequestDraftIcon, color: "text-muted-foreground" };
}
if (pr.mergedAt) {
return { icon: GitMergeIcon, color: "text-purple-500" };
}
if (pr.state === "closed") {
return { icon: GitPullRequestClosedIcon, color: "text-red-500" };
}
return { icon: GitPullRequestIcon, color: "text-green-500" };
}

export function PullRequestRow({
pr,
scope,
}: {
pr: PullSummary;
scope: GitHubQueryScope;
}) {
const { icon: Icon, color } = getPrStateProps(pr);
const href = `/${pr.repository.owner}/${pr.repository.name}/pull/${pr.number}`;
const [expanded, setExpanded] = useState(false);

const commentsQuery = useQuery({
...githubPullCommentsQueryOptions(scope, {
owner: pr.repository.owner,
repo: pr.repository.name,
pullNumber: pr.number,
}),
enabled: expanded,
});

return (
<div className="rounded-lg">
<Link
to={href}
className={`group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:[&:not(:has([data-action]:hover))]:bg-surface-1 ${expanded ? "bg-surface-1" : ""}`}
>
<div className={`mt-[3px] shrink-0 ${color}`}>
<Icon size={16} strokeWidth={2} />
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1">
<p className="truncate text-sm font-medium">{pr.title}</p>
<p className="flex items-center gap-1 truncate text-xs text-muted-foreground">
{pr.repository.fullName} #{pr.number}
{pr.author && (
<>
<span>·</span>
<img
src={pr.author.avatarUrl}
alt={pr.author.login}
className="size-3.5 rounded-full border border-border"
/>
<span>{pr.author.login}</span>
</>
)}
<span>·</span>
<span>{formatRelativeTime(pr.updatedAt)}</span>
</p>
</div>
<div className="mt-[3px] flex shrink-0 items-center gap-4">
<button
type="button"
data-action
onClick={(e) => {
e.preventDefault();
setExpanded((v) => !v);
}}
className="flex items-center gap-1 rounded-md border bg-surface-1 px-2 py-0.5 text-xs font-medium text-muted-foreground opacity-0 transition-opacity hover:bg-surface-2 hover:text-foreground group-hover:opacity-100"
>
{expanded && commentsQuery.isPending ? (
<svg
className="size-3 animate-spin"
viewBox="0 0 16 16"
fill="none"
aria-hidden="true"
>
<circle
cx="8"
cy="8"
r="6.5"
stroke="currentColor"
strokeWidth="2"
opacity="0.25"
/>
<path
d="M14.5 8a6.5 6.5 0 0 0-6.5-6.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
</svg>
) : (
<ViewIcon size={13} strokeWidth={2} />
)}
Preview
</button>
{pr.comments > 0 && (
<span className="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
<CommentIcon size={13} strokeWidth={2} />
{pr.comments}
</span>
)}
</div>
</Link>

{expanded && commentsQuery.data && (
<div className="relative ml-[31px] pb-2 pl-4 pr-3 pt-4 before:absolute before:left-0 before:top-0 before:h-full before:w-px before:bg-[linear-gradient(to_bottom,var(--color-border)_80%,transparent)]">
{commentsQuery.data.length === 0 && (
<p className="text-xs text-muted-foreground">No comments yet.</p>
)}
{commentsQuery.data.length > 0 && (
<div className="flex flex-col gap-8">
{commentsQuery.data.map((comment, i) => (
<div
key={comment.id}
className={`flex flex-col gap-1 ${i === commentsQuery.data!.length - 1 ? "pb-4" : ""}`}
>
<div className="flex items-center gap-1.5">
{comment.author && (
<img
src={comment.author.avatarUrl}
alt={comment.author.login}
className="size-4 rounded-full border border-border"
/>
)}
<span className="text-xs font-medium">
{comment.author?.login ?? "Unknown"}
</span>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(comment.createdAt)}
</span>
</div>
<div className="line-clamp-3">
<Markdown className="text-muted-foreground">
{comment.body}
</Markdown>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
);
}
43 changes: 43 additions & 0 deletions apps/dashboard/src/lib/github.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
IssueSummary,
MyIssuesResult,
MyPullsResult,
PullComment,
PullDetail,
PullSummary,
RepositoryRef,
Expand Down Expand Up @@ -866,6 +867,48 @@ export const getPullFromRepo = createServerFn({ method: "GET" })
});
});

export const getPullComments = createServerFn({ method: "GET" })
.inputValidator(identityValidator<PullFromRepoInput>)
.handler(async ({ data }): Promise<PullComment[]> => {
const context = await getGitHubContext();
if (!context) {
return [];
}

type IssueComment = Awaited<
ReturnType<GitHubClient["rest"]["issues"]["listComments"]>
>["data"][number];

return getCachedGitHubRequest<IssueComment[], PullComment[]>({
context,
resource: "pulls.comments",
params: data,
freshForMs: githubCachePolicy.detail.staleTimeMs,
request: (headers) =>
context.octokit.rest.issues.listComments({
owner: data.owner,
repo: data.repo,
issue_number: data.pullNumber,
per_page: 10,
headers,
}),
mapData: (comments) =>
comments.map((c) => ({
id: c.id,
body: c.body ?? "",
createdAt: c.created_at,
author: c.user
? {
login: c.user.login,
avatarUrl: c.user.avatar_url,
url: c.user.html_url,
type: c.user.type ?? "User",
}
: null,
})),
});
});

export const getMyIssues = createServerFn({ method: "GET" }).handler(
async (): Promise<MyIssuesResult> => {
const context = await getGitHubContext();
Expand Down
16 changes: 16 additions & 0 deletions apps/dashboard/src/lib/github.query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
getIssuesFromUser,
getMyIssues,
getMyPulls,
getPullComments,
getPullFromRepo,
getPullsFromRepo,
getPullsFromUser,
Expand Down Expand Up @@ -102,6 +103,8 @@ export const githubQueryKeys = {
["github", scope.userId, "pulls", "repo", input] as const,
detail: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) =>
["github", scope.userId, "pulls", "detail", input] as const,
comments: (scope: GitHubQueryScope, input: PullFromRepoQueryInput) =>
["github", scope.userId, "pulls", "comments", input] as const,
},
issues: {
mine: (scope: GitHubQueryScope) =>
Expand Down Expand Up @@ -184,6 +187,19 @@ export function githubPullDetailQueryOptions(
});
}

export function githubPullCommentsQueryOptions(
scope: GitHubQueryScope,
input: PullFromRepoQueryInput,
) {
return queryOptions({
queryKey: githubQueryKeys.pulls.comments(scope, input),
queryFn: () => getPullComments({ data: input }),
staleTime: githubCachePolicy.detail.staleTimeMs,
gcTime: githubCachePolicy.detail.gcTimeMs,
meta: persistedMeta,
});
}

export function githubMyIssuesQueryOptions(scope: GitHubQueryScope) {
return queryOptions({
queryKey: githubQueryKeys.issues.mine(scope),
Expand Down
7 changes: 7 additions & 0 deletions apps/dashboard/src/lib/github.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,3 +103,10 @@ export type MyIssuesResult = {
authored: IssueSummary[];
mentioned: IssueSummary[];
};

export type PullComment = {
id: number;
body: string;
createdAt: string;
author: GitHubActor | null;
};
Loading