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
58 changes: 58 additions & 0 deletions apps/dashboard/src/components/issues/issue-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { CommentIcon, IssuesIcon } from "@quickhub/icons";
import { cn } from "@quickhub/ui/lib/utils";
import { formatRelativeTime } from "#/components/pulls/pull-request-row";
import type { IssueSummary } from "#/lib/github.types";

function getIssueStateProps(issue: IssueSummary) {
if (issue.state === "closed") {
if (issue.stateReason === "not_planned") {
return { color: "text-muted-foreground" };
}
return { color: "text-purple-500" };
}
return { color: "text-green-500" };
}

export function IssueRow({ issue }: { issue: IssueSummary }) {
const { color } = getIssueStateProps(issue);

return (
<a
href={issue.url}
target="_blank"
rel="noopener noreferrer"
className="group flex items-start gap-3 rounded-lg px-3 py-2.5 transition-colors hover:bg-surface-1"
>
<div className={cn("mt-[3px] shrink-0", color)}>
<IssuesIcon size={16} strokeWidth={2} />
</div>
<div className="min-w-0 flex-1 flex flex-col gap-1">
<p className="truncate text-sm font-medium">{issue.title}</p>
<p className="flex items-center gap-1 truncate text-xs text-muted-foreground">
{issue.repository.fullName} #{issue.number}
{issue.author && (
<>
<span>·</span>
<img
src={issue.author.avatarUrl}
alt={issue.author.login}
className="size-3.5 rounded-full border border-border"
/>
<span>{issue.author.login}</span>
</>
)}
<span>·</span>
<span>{formatRelativeTime(issue.updatedAt)}</span>
</p>
</div>
{issue.comments > 0 && (
<div className="mt-[3px] flex shrink-0 items-center gap-4">
<span className="flex items-center gap-1 text-xs tabular-nums text-muted-foreground">
<CommentIcon size={13} strokeWidth={2} />
{issue.comments}
</span>
</div>
)}
</a>
);
}
223 changes: 191 additions & 32 deletions apps/dashboard/src/routes/_protected/issues.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import { CommentIcon, InboxIcon, IssuesIcon } from "@quickhub/icons";
import { cn } from "@quickhub/ui/lib/utils";
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
type ComponentType,
type RefObject,
useEffect,
useRef,
useState,
} from "react";
import { IssueRow } from "#/components/issues/issue-row";
import { DashboardContentLoading } from "#/components/layouts/dashboard-content-loading";
import { githubMyIssuesQueryOptions } from "#/lib/github.query";
import type { IssueSummary } from "#/lib/github.types";
import type { IssueSummary, MyIssuesResult } from "#/lib/github.types";
import { useHasMounted } from "#/lib/use-has-mounted";

export const Route = createFileRoute("/_protected/issues")({
Expand All @@ -11,29 +21,79 @@ export const Route = createFileRoute("/_protected/issues")({

function IssuesPage() {
const { user } = Route.useRouteContext();
const scope = { userId: user.id };
const hasMounted = useHasMounted();
const scrollContainerRef = useRef<HTMLDivElement>(null);
const query = useQuery({
...githubMyIssuesQueryOptions({ userId: user.id }),
...githubMyIssuesQueryOptions(scope),
enabled: hasMounted,
});

if (query.error) throw query.error;
if (query.data) {
const data = query.data;
const groups: IssueGroupData[] = [
{
id: "assigned",
title: "Assigned",
icon: InboxIcon,
issues: data.assigned,
},
{
id: "authored",
title: "Authored",
icon: IssuesIcon,
issues: data.authored,
},
{
id: "mentioned",
title: "Mentioned",
icon: CommentIcon,
issues: data.mentioned,
},
];
const totalIssues = groups.reduce(
(sum, group) => sum + group.issues.length,
0,
);

return (
<div className="flex h-full flex-col gap-6 overflow-auto p-6">
<header className="space-y-1">
<p className="text-sm font-medium text-muted-foreground">
Cached issue groups
</p>
<h1 className="text-2xl font-semibold tracking-tight">Issues</h1>
</header>

<div className="grid gap-4 xl:grid-cols-3">
<IssueGroup title="Assigned" issues={data.assigned} />
<IssueGroup title="Authored" issues={data.authored} />
<IssueGroup title="Mentioned" issues={data.mentioned} />
<div ref={scrollContainerRef} className="h-full overflow-auto py-10">
<div className="mx-auto grid max-w-7xl gap-14 px-6 xl:grid-cols-[minmax(15rem,18rem)_minmax(0,1fr)]">
<aside className="flex h-fit flex-col gap-5 xl:sticky xl:top-0">
<div className="flex flex-col gap-2">
<h1 className="text-2xl font-semibold tracking-tight">Issues</h1>
<p className="text-sm text-muted-foreground">
<span className="tabular-nums">{totalIssues}</span> open issues
across your queues
</p>
</div>

<nav className="flex flex-col gap-2" aria-label="Issue groups">
{groups.map((group) => (
<IssueMetricCard
key={group.id}
href={`#${group.id}`}
icon={group.icon}
label={group.title}
value={group.issues.length}
/>
))}
</nav>
</aside>

<div className="flex flex-col gap-2">
{groups.map((group) => (
<IssueGroup
key={group.id}
id={group.id}
title={group.title}
icon={group.icon}
issues={group.issues}
scrollContainerRef={scrollContainerRef}
/>
))}
</div>
</div>
</div>
);
Expand All @@ -45,34 +105,133 @@ function IssuesPage() {
return null;
}

type IssueGroupData = {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
issues: MyIssuesResult[keyof MyIssuesResult];
};

const ISSUE_GROUP_STICKY_TOP = -32;

function IssueMetricCard({
href,
icon: Icon,
label,
value,
}: {
href: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
label: string;
value: number;
}) {
const content = (
<>
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-muted-foreground">
<Icon size={15} strokeWidth={1.9} />
</div>
<p className="truncate text-sm font-medium">{label}</p>
</div>
<p className="font-semibold tabular-nums leading-tight">{value}</p>
</>
);

if (value === 0) {
return (
<div
aria-disabled="true"
className="flex items-center justify-between gap-4 rounded-xl bg-surface-1 px-3.5 py-3 opacity-70"
>
{content}
</div>
);
}

return (
<a
href={href}
className="flex items-center justify-between gap-4 rounded-xl bg-surface-1 px-3.5 py-3 transition-colors hover:bg-surface-2"
>
{content}
</a>
);
}

function IssueGroup({
id,
title,
icon: Icon,
issues,
scrollContainerRef,
}: {
id: string;
title: string;
icon: ComponentType<{ size?: number; strokeWidth?: number }>;
issues: IssueSummary[];
scrollContainerRef: RefObject<HTMLDivElement | null>;
}) {
const sectionRef = useRef<HTMLElement>(null);
const headerRef = useRef<HTMLDivElement>(null);
const [isStickyActive, setIsStickyActive] = useState(false);

useEffect(() => {
const scrollContainer = scrollContainerRef.current;
const section = sectionRef.current;
const header = headerRef.current;

if (!scrollContainer || !section || !header) {
return;
}

const updateStickyState = () => {
const scrollContainerRect = scrollContainer.getBoundingClientRect();
const sectionRect = section.getBoundingClientRect();
const stickyTop = scrollContainerRect.top + ISSUE_GROUP_STICKY_TOP;
const headerHeight = header.offsetHeight;
const isStuck =
sectionRect.top <= stickyTop &&
sectionRect.bottom > stickyTop + headerHeight;

setIsStickyActive((current) => (current === isStuck ? current : isStuck));
};

updateStickyState();
scrollContainer.addEventListener("scroll", updateStickyState, {
passive: true,
});
window.addEventListener("resize", updateStickyState);

return () => {
scrollContainer.removeEventListener("scroll", updateStickyState);
window.removeEventListener("resize", updateStickyState);
};
}, [scrollContainerRef]);

return (
<section className="rounded-2xl border bg-background/70 p-4">
<div className="mb-3 flex items-center justify-between">
<h2 className="font-medium">{title}</h2>
<span className="text-sm text-muted-foreground">{issues.length}</span>
<section ref={sectionRef} id={id}>
<div
ref={headerRef}
className={cn(
"sticky -top-8 z-10 flex items-center justify-between gap-3 rounded-lg bg-surface-1 px-3 py-2 transition-shadow",
isStickyActive && "shadow-lg",
issues.length === 0 && "opacity-70",
)}
>
<div className="flex min-w-0 items-center gap-2">
<div className="shrink-0 text-muted-foreground">
<Icon size={15} strokeWidth={1.9} />
</div>
<h2 className="truncate text-sm font-medium">{title}</h2>
</div>
<span className="text-sm tabular-nums text-muted-foreground">
{issues.length}
</span>
</div>
{issues.length === 0 ? (
<p className="text-sm text-muted-foreground">
No issues in this slice.
</p>
) : (
<div className="space-y-3">
{issues.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{issues.map((issue) => (
<div key={issue.id} className="rounded-xl border px-3 py-2">
<p className="text-sm font-medium">
#{issue.number} {issue.title}
</p>
<p className="text-sm text-muted-foreground">
{issue.repository.fullName}
</p>
</div>
<IssueRow key={issue.id} issue={issue} />
))}
</div>
)}
Expand Down