diff --git a/apps/dashboard/src/components/issues/issue-row.tsx b/apps/dashboard/src/components/issues/issue-row.tsx new file mode 100644 index 0000000..875bc9d --- /dev/null +++ b/apps/dashboard/src/components/issues/issue-row.tsx @@ -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 ( + +
+ +
+
+

{issue.title}

+

+ {issue.repository.fullName} #{issue.number} + {issue.author && ( + <> + · + {issue.author.login} + {issue.author.login} + + )} + · + {formatRelativeTime(issue.updatedAt)} +

+
+ {issue.comments > 0 && ( +
+ + + {issue.comments} + +
+ )} +
+ ); +} diff --git a/apps/dashboard/src/routes/_protected/issues.tsx b/apps/dashboard/src/routes/_protected/issues.tsx index bc5807d..9ad6f94 100644 --- a/apps/dashboard/src/routes/_protected/issues.tsx +++ b/apps/dashboard/src/routes/_protected/issues.tsx @@ -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")({ @@ -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(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 ( -
-
-

- Cached issue groups -

-

Issues

-
- -
- - - +
+
+ + +
+ {groups.map((group) => ( + + ))} +
); @@ -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 = ( + <> +
+
+ +
+

{label}

+
+

{value}

+ + ); + + if (value === 0) { + return ( +
+ {content} +
+ ); + } + + return ( + + {content} + + ); +} + function IssueGroup({ + id, title, + icon: Icon, issues, + scrollContainerRef, }: { + id: string; title: string; + icon: ComponentType<{ size?: number; strokeWidth?: number }>; issues: IssueSummary[]; + scrollContainerRef: RefObject; }) { + const sectionRef = useRef(null); + const headerRef = useRef(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 ( -
-
-

{title}

- {issues.length} +
+
+
+
+ +
+

{title}

+
+ + {issues.length} +
- {issues.length === 0 ? ( -

- No issues in this slice. -

- ) : ( -
+ {issues.length > 0 && ( +
{issues.map((issue) => ( -
-

- #{issue.number} {issue.title} -

-

- {issue.repository.fullName} -

-
+ ))}
)}