diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 6153ed9..3ad150a 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -36,6 +36,7 @@ "agentation": "^3.0.2", "better-auth": "^1.6.0", "drizzle-orm": "^0.45.2", + "motion": "^12.38.0", "next-themes": "^0.4.6", "nuqs": "^2.8.9", "octokit": "^5.0.5", diff --git a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx index 740fe8e..87c2060 100644 --- a/apps/dashboard/src/components/layouts/dashboard-topbar.tsx +++ b/apps/dashboard/src/components/layouts/dashboard-topbar.tsx @@ -20,16 +20,20 @@ import { DropdownMenuShortcut, DropdownMenuTrigger, } from "@diffkit/ui/components/dropdown-menu"; +import { useQuery } from "@tanstack/react-query"; import { Link, useRouter } from "@tanstack/react-router"; import { useTheme } from "next-themes"; import { useEffect, useMemo, useRef, useState } from "react"; import { DashboardTabs } from "#/components/layouts/dashboard-tabs"; import { signOutToLogin } from "#/lib/auth-actions"; +import { githubViewerQueryOptions } from "#/lib/github.query"; import { useGlobalShortcuts } from "#/lib/shortcuts"; import { type Tab, useTabs } from "#/lib/tab-store"; +import { useHasMounted } from "#/lib/use-has-mounted"; interface DashboardTopbarProps { user: { + id: string; name?: string | null; email: string; image?: string | null; @@ -66,6 +70,12 @@ export function DashboardTopbar({ const { theme, setTheme } = useTheme(); const [avatarLoadFailed, setAvatarLoadFailed] = useState(false); const openTabs = useTabs(); + const hasMounted = useHasMounted(); + const viewerQuery = useQuery({ + ...githubViewerQueryOptions({ userId: user.id }), + enabled: hasMounted, + }); + const viewerLogin = viewerQuery.data?.login; // Store router in a ref — only used imperatively (navigate, preload), // never read during render, so we avoid subscribing to state changes. const router = useRouter(); @@ -226,9 +236,11 @@ export function DashboardTopbar({ - - Profile - + + + Profile + + diff --git a/apps/dashboard/src/components/profile/contribution-graph.tsx b/apps/dashboard/src/components/profile/contribution-graph.tsx new file mode 100644 index 0000000..b56eb2d --- /dev/null +++ b/apps/dashboard/src/components/profile/contribution-graph.tsx @@ -0,0 +1,250 @@ +import { cn } from "@diffkit/ui/lib/utils"; +import { motion } from "motion/react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { GitHubContributionCalendar } from "#/lib/github.types"; + +type ContributionGraphProps = { + calendar: GitHubContributionCalendar; + className?: string; +}; + +type CellData = { + x: number; + y: number; + level: 0 | 1 | 2 | 3 | 4; + date: string; + count: number; +}; + +type TooltipState = { + cell: CellData; + pageX: number; + pageY: number; +}; + +const CELL_SIZE = 11; +const CELL_GAP = 3; +const CELL_STEP = CELL_SIZE + CELL_GAP; + +const LEVEL_COLORS_LIGHT = [ + "oklch(0.82 0.005 286)", + "oklch(0.82 0.12 150)", + "oklch(0.72 0.16 150)", + "oklch(0.60 0.19 150)", + "oklch(0.48 0.19 150)", +] as const; + +const LEVEL_COLORS_DARK = [ + "oklch(0.25 0.006 286)", + "oklch(0.35 0.10 150)", + "oklch(0.45 0.14 150)", + "oklch(0.55 0.17 150)", + "oklch(0.65 0.19 150)", +] as const; + +function formatDate(dateStr: string) { + const date = new Date(dateStr); + return date.toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function ContributionGraph({ + calendar, + className, +}: ContributionGraphProps) { + const svgRef = useRef(null); + const tooltipRef = useRef(null); + const [tooltip, setTooltip] = useState(null); + const [tooltipLeft, setTooltipLeft] = useState(0); + + useLayoutEffect(() => { + if (!tooltip || !tooltipRef.current) return; + const el = tooltipRef.current; + const halfWidth = el.offsetWidth / 2; + const padding = 8; + let left = tooltip.pageX; + + if (left - halfWidth < padding) { + left = halfWidth + padding; + } else if (left + halfWidth > window.innerWidth - padding) { + left = window.innerWidth - halfWidth - padding; + } + + setTooltipLeft(left); + }, [tooltip]); + + const { cells, cellsByDate } = useMemo(() => { + const result: CellData[] = []; + const map = new Map(); + + for (let weekIdx = 0; weekIdx < calendar.weeks.length; weekIdx++) { + const week = calendar.weeks[weekIdx]; + for (const day of week.days) { + const dayOfWeek = new Date(day.date).getUTCDay(); + const cell: CellData = { + x: weekIdx * CELL_STEP, + y: dayOfWeek * CELL_STEP, + level: day.level, + date: day.date, + count: day.count, + }; + result.push(cell); + map.set(day.date, cell); + } + } + + return { cells: result, cellsByDate: map }; + }, [calendar.weeks]); + + const totalCols = calendar.weeks.length; + const totalRows = 7; + const centerCol = (totalCols - 1) / 2; + const centerRow = (totalRows - 1) / 2; + // Max distance from center for normalization (corner cell) + const maxDist = Math.sqrt(centerCol ** 2 + centerRow ** 2); + + const svgWidth = totalCols * CELL_STEP - CELL_GAP; + const svgHeight = totalRows * CELL_STEP - CELL_GAP; + + const handleMouseMove = useCallback( + (e: React.MouseEvent) => { + const svg = svgRef.current; + if (!svg) return; + + const pt = svg.createSVGPoint(); + pt.x = e.clientX; + pt.y = e.clientY; + const svgPt = pt.matrixTransform(svg.getScreenCTM()?.inverse()); + + const col = Math.floor(svgPt.x / CELL_STEP); + const row = Math.floor(svgPt.y / CELL_STEP); + + // Check the point is within a cell, not in the gap + const cellX = svgPt.x - col * CELL_STEP; + const cellY = svgPt.y - row * CELL_STEP; + if (cellX > CELL_SIZE || cellY > CELL_SIZE || cellX < 0 || cellY < 0) { + setTooltip(null); + return; + } + + const week = calendar.weeks[col]; + if (!week) { + setTooltip(null); + return; + } + + const day = week.days.find((d) => new Date(d.date).getUTCDay() === row); + if (!day) { + setTooltip(null); + return; + } + + const cell = cellsByDate.get(day.date); + if (!cell) { + setTooltip(null); + return; + } + + setTooltip({ cell, pageX: e.clientX, pageY: e.clientY }); + }, + [calendar.weeks, cellsByDate], + ); + + const handleMouseLeave = useCallback(() => { + setTooltip(null); + }, []); + + return ( +
+ + {cells.map((cell) => { + const col = cell.x / CELL_STEP; + const row = cell.y / CELL_STEP; + return ( + + ); + })} + + + {tooltip && + createPortal( +
+ + {tooltip.cell.count} contribution + {tooltip.cell.count !== 1 ? "s" : ""} + {" "} + on {formatDate(tooltip.cell.date)} +
, + document.body, + )} + + +
+ ); +} diff --git a/apps/dashboard/src/components/profile/pinned-repo-card.tsx b/apps/dashboard/src/components/profile/pinned-repo-card.tsx new file mode 100644 index 0000000..5f45a51 --- /dev/null +++ b/apps/dashboard/src/components/profile/pinned-repo-card.tsx @@ -0,0 +1,61 @@ +import { GitForkIcon, StarIcon } from "@diffkit/icons"; +import type { PinnedRepo } from "#/lib/github.types"; + +export function PinnedRepoCard({ repo }: { repo: PinnedRepo }) { + return ( + +
+ + {repo.name} + + {repo.isPrivate && ( + + Private + + )} +
+ + {repo.description && ( +

+ {repo.description} +

+ )} + +
+ {repo.language && ( + + + {repo.language} + + )} + {repo.stars > 0 && ( + + + {formatStars(repo.stars)} + + )} + {repo.forks > 0 && ( + + + {formatStars(repo.forks)} + + )} +
+
+ ); +} + +function formatStars(count: number): string { + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return count.toString(); +} diff --git a/apps/dashboard/src/components/profile/user-activity-feed.tsx b/apps/dashboard/src/components/profile/user-activity-feed.tsx new file mode 100644 index 0000000..e4cf26a --- /dev/null +++ b/apps/dashboard/src/components/profile/user-activity-feed.tsx @@ -0,0 +1,668 @@ +import { + CircleIcon, + CodeIcon, + CommentIcon, + GitBranchIcon, + GitCommitIcon, + GitPullRequestIcon, + IssuesIcon, + StarIcon, +} from "@diffkit/icons"; +import { cn } from "@diffkit/ui/lib/utils"; +import { formatRelativeTime } from "#/lib/format-relative-time"; +import type { UserActivityEvent } from "#/lib/github.types"; +import { getPrStateConfig } from "#/lib/pr-state"; + +type MonthGroup = { + label: string; + items: FeedItem[]; +}; + +type FeedItem = + | { kind: "event"; event: UserActivityEvent } + | { kind: "commits"; summary: CommitsSummary } + | { + kind: "prs"; + highlighted: UserActivityEvent[]; + summary: PrSummary | null; + }; + +type CommitsSummary = { + totalCommits: number; + repos: Array<{ name: string; url: string; commits: number }>; +}; + +type PrSummary = { + totalPrs: number; + repos: Array<{ + name: string; + url: string; + open: number; + merged: number; + closed: number; + }>; +}; + +function buildMonthGroups(events: UserActivityEvent[]): MonthGroup[] { + const sorted = [...events].sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + const groupMap = new Map(); + + for (const event of sorted) { + const date = new Date(event.createdAt); + const key = `${date.getFullYear()}-${String(date.getMonth()).padStart(2, "0")}`; + + let group = groupMap.get(key); + if (!group) { + group = []; + groupMap.set(key, group); + } + group.push(event); + } + + return Array.from(groupMap.entries()).map(([, groupEvents]) => { + const filtered = groupEvents.filter( + (e) => e.type !== "CreateEvent" && e.type !== "DeleteEvent", + ); + const pushEvents = filtered.filter((e) => e.type === "PushEvent"); + const prEvents = filtered.filter((e) => e.type === "PullRequestEvent"); + const otherEvents = filtered.filter( + (e) => e.type !== "PushEvent" && e.type !== "PullRequestEvent", + ); + + const items: FeedItem[] = otherEvents.map((event) => ({ + kind: "event", + event, + })); + + if (pushEvents.length > 0) { + const repoMap = new Map< + string, + { name: string; url: string; commits: number } + >(); + let totalCommits = 0; + + for (const push of pushEvents) { + const commitCount = push.commits?.length ?? 1; + totalCommits += commitCount; + const existing = repoMap.get(push.repo.name); + if (existing) { + existing.commits += commitCount; + } else { + repoMap.set(push.repo.name, { + name: push.repo.name, + url: push.repo.url, + commits: commitCount, + }); + } + } + + const repos = Array.from(repoMap.values()).sort( + (a, b) => b.commits - a.commits, + ); + + items.unshift({ + kind: "commits", + summary: { + totalCommits, + repos, + }, + }); + } + + if (prEvents.length > 0) { + // Deduplicate PR events by PR number — keep the latest event per PR + // so we reflect the most recent state (e.g. merged > closed > opened) + const prByKey = new Map(); + for (const pr of prEvents) { + const key = `${pr.repo.name}#${pr.prDetail?.number ?? pr.id}`; + const existing = prByKey.get(key); + if ( + !existing || + new Date(pr.createdAt) > new Date(existing.createdAt) + ) { + prByKey.set(key, pr); + } + } + const uniquePrs = Array.from(prByKey.values()).sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(), + ); + + const highlighted = uniquePrs.slice(0, 3); + const rest = uniquePrs.slice(3); + + let summary: PrSummary | null = null; + if (rest.length > 0) { + const repoMap = new Map< + string, + { + name: string; + url: string; + open: number; + merged: number; + closed: number; + } + >(); + + for (const pr of rest) { + const state = pr.prDetail?.state ?? "open"; + const existing = repoMap.get(pr.repo.name); + if (existing) { + if (state === "merged") existing.merged++; + else if (state === "closed") existing.closed++; + else existing.open++; + } else { + repoMap.set(pr.repo.name, { + name: pr.repo.name, + url: pr.repo.url, + open: state === "open" ? 1 : 0, + merged: state === "merged" ? 1 : 0, + closed: state === "closed" ? 1 : 0, + }); + } + } + + summary = { + totalPrs: rest.length, + repos: Array.from(repoMap.values()).sort( + (a, b) => + b.open + b.merged + b.closed - (a.open + a.merged + a.closed), + ), + }; + } + + items.unshift({ + kind: "prs", + highlighted, + summary, + }); + } + + return { + label: new Date(groupEvents[0].createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }), + items, + }; + }); +} + +export function UserActivityFeed({ events }: { events: UserActivityEvent[] }) { + if (events.length === 0) { + return ( +

+ No recent activity. +

+ ); + } + + const groups = buildMonthGroups(events); + + return ( +
+ {groups.map((group, groupIdx) => ( +
+
+ + {group.label} + +
+ {group.items.map((item) => { + if (item.kind === "commits") { + return ( + + ); + } + if (item.kind === "prs") { + return ( + + ); + } + return ; + })} +
+ ))} +
+ ); +} + +function CommitsSummaryRow({ summary }: { summary: CommitsSummary }) { + return ( +
+
+ +
+
+ + Created{" "} + + {summary.totalCommits} commit + {summary.totalCommits !== 1 ? "s" : ""} + {" "} + in{" "} + + {summary.repos.length} repositor + {summary.repos.length !== 1 ? "ies" : "y"} + + +
+ {summary.repos.map((repo) => ( +
+ + {repo.name} + + + {repo.commits} commit{repo.commits !== 1 ? "s" : ""} + +
+ ))} +
+
+
+ ); +} + +function PrGroupRow({ + highlighted, + summary, +}: { + highlighted: UserActivityEvent[]; + summary: PrSummary | null; +}) { + return ( +
+ {highlighted.map((event) => ( + + ))} + {summary && ( +
+
+ +
+
+ + Opened{" "} + + {summary.totalPrs} other pull request + {summary.totalPrs !== 1 ? "s" : ""} + {" "} + in{" "} + + {summary.repos.length} repositor + {summary.repos.length !== 1 ? "ies" : "y"} + + +
+ {summary.repos.map((repo) => ( +
+ + {repo.name} + +
+ {repo.closed > 0 && ( + {repo.closed} closed + )} + {repo.merged > 0 && ( + + {repo.merged} merged + + )} + {repo.open > 0 && ( + {repo.open} open + )} +
+
+ ))} +
+
+
+ )} +
+ ); +} + +function ActivityEventRow({ event }: { event: UserActivityEvent }) { + const icon = getEventIcon(event); + const description = getEventDescription(event); + const hasCard = event.prDetail || event.issueDetail; + + return ( +
+
+ {icon} +
+
+
+ + {description} + + + {formatRelativeTime(event.createdAt)} + +
+ {event.prDetail && ( + + )} + {event.issueDetail && ( + + )} + {!hasCard && event.commentBody && ( +

+ {event.commentBody} +

+ )} +
+
+ ); +} + +function PrCard({ + pr, + title, + repo, +}: { + pr: NonNullable; + title: string | null; + repo: { name: string; url: string }; +}) { + // Build internal link: repo.name is "owner/repo" + const [owner, repoName] = repo.name.split("/"); + const href = + owner && repoName ? `/${owner}/${repoName}/pull/${pr.number}` : pr.url; + const hasDiff = pr.additions > 0 || pr.deletions > 0; + const stateConfig = getPrStateConfig(pr); + const StateIcon = stateConfig.icon; + + return ( + +
+ + + {title ?? `#${pr.number}`} + + {pr.isDraft && ( + + Draft + + )} +
+ {pr.body && ( +

+ {pr.body} +

+ )} + {pr.labels.length > 0 && ( +
+ {pr.labels.map((label) => ( + + {label.name} + + ))} +
+ )} +
+ {hasDiff && ( + <> + + +{pr.additions.toLocaleString()} + + + -{pr.deletions.toLocaleString()} + + + )} + {pr.changedFiles > 0 && ( + + {pr.changedFiles} file{pr.changedFiles !== 1 ? "s" : ""} + + )} + {pr.comments > 0 && ( + + + {pr.comments} + + )} +
+ {pr.headRef && pr.baseRef && ( +
+ + {pr.headRef} + + + + {pr.baseRef} + +
+ )} +
+ ); +} + +function IssueCard({ + issue, + title, + repo, +}: { + issue: NonNullable; + title: string | null; + repo: { name: string; url: string }; +}) { + const [owner, repoName] = repo.name.split("/"); + const href = + owner && repoName + ? `/${owner}/${repoName}/issues/${issue.number}` + : issue.url; + + return ( + +
+ + + {title ?? `#${issue.number}`} + +
+ {issue.body && ( +

+ {issue.body} +

+ )} + {issue.comments > 0 && ( +
+ + {issue.comments} +
+ )} +
+ ); +} + +function getEventIcon(event: UserActivityEvent) { + const cls = "size-3 text-muted-foreground"; + const sw = 2; + + switch (event.type) { + case "PushEvent": + return ; + case "CreateEvent": + case "DeleteEvent": + return ; + case "PullRequestEvent": + case "PullRequestReviewEvent": + return ; + case "IssuesEvent": + return ; + case "IssueCommentEvent": + return ; + case "WatchEvent": + return ; + case "ForkEvent": + return ; + case "ReleaseEvent": + return ; + default: + return ; + } +} + +function getEventDescription(event: UserActivityEvent) { + const repo = ( + + {event.repo.name} + + ); + + switch (event.type) { + case "CreateEvent": + if (event.refType === "repository") { + return <>Created repository {repo}; + } + return ( + <> + Created {event.refType}{" "} + {event.ref} in{" "} + {repo} + + ); + case "DeleteEvent": + return ( + <> + Deleted {event.refType}{" "} + {event.ref} in{" "} + {repo} + + ); + case "PullRequestEvent": + return ( + <> + {capitalizeFirst(event.action)} PR{" "} + {event.title} in{" "} + {repo} + + ); + case "PullRequestReviewEvent": + return ( + <> + Reviewed PR{" "} + {event.title} in{" "} + {repo} + + ); + case "IssuesEvent": + return ( + <> + {capitalizeFirst(event.action)} issue{" "} + {event.title} in{" "} + {repo} + + ); + case "IssueCommentEvent": + return ( + <> + Commented on{" "} + {event.title} in{" "} + {repo} + + ); + case "WatchEvent": + return <>Starred {repo}; + case "ForkEvent": + return <>Forked {repo}; + case "ReleaseEvent": + return ( + <> + {capitalizeFirst(event.action)} release{" "} + {event.title} in{" "} + {repo} + + ); + default: + return ( + <> + {event.type.replace("Event", "")} in {repo} + + ); + } +} + +function capitalizeFirst(str: string | null): string { + if (!str) return ""; + return str.charAt(0).toUpperCase() + str.slice(1); +} diff --git a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx index 6b6c7bd..2d7562f 100644 --- a/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx +++ b/apps/dashboard/src/components/pulls/detail/pull-detail-header.tsx @@ -1,12 +1,4 @@ -import { - FileIcon, - GitCommitIcon, - GitMergeIcon, - GitPullRequestClosedIcon, - GitPullRequestDraftIcon, - GitPullRequestIcon, - ReviewsIcon, -} from "@diffkit/icons"; +import { FileIcon, GitCommitIcon, ReviewsIcon } from "@diffkit/icons"; import { Tooltip, TooltipContent, @@ -17,50 +9,9 @@ import { Link } from "@tanstack/react-router"; import { useCallback, useRef, useState } from "react"; import { DetailPageTitle } from "#/components/details/detail-page"; import type { PullDetail } from "#/lib/github.types"; +import { getPrStateConfig } from "#/lib/pr-state"; -type PullStateConfig = { - icon: React.ComponentType<{ - size?: number; - strokeWidth?: number; - className?: string; - }>; - color: string; - label: string; - badgeClass: string; -}; - -export function getPrStateConfig(pr: PullDetail): PullStateConfig { - if (pr.isDraft) { - return { - icon: GitPullRequestDraftIcon, - color: "text-muted-foreground", - label: "Draft", - badgeClass: "bg-muted text-muted-foreground", - }; - } - if (pr.isMerged || pr.mergedAt) { - return { - icon: GitMergeIcon, - color: "text-purple-500", - label: "Merged", - badgeClass: "bg-purple-500/10 text-purple-500", - }; - } - if (pr.state === "closed") { - return { - icon: GitPullRequestClosedIcon, - color: "text-red-500", - label: "Closed", - badgeClass: "bg-red-500/10 text-red-500", - }; - } - return { - icon: GitPullRequestIcon, - color: "text-green-500", - label: "Open", - badgeClass: "bg-green-500/10 text-green-500", - }; -} +export { getPrStateConfig, type PrStateConfig } from "#/lib/pr-state"; export function PullDetailHeader({ owner, diff --git a/apps/dashboard/src/components/pulls/pull-request-row.tsx b/apps/dashboard/src/components/pulls/pull-request-row.tsx index d93828f..f37fe99 100644 --- a/apps/dashboard/src/components/pulls/pull-request-row.tsx +++ b/apps/dashboard/src/components/pulls/pull-request-row.tsx @@ -1,11 +1,4 @@ -import { - CommentIcon, - GitMergeIcon, - GitPullRequestClosedIcon, - GitPullRequestDraftIcon, - GitPullRequestIcon, - ViewIcon, -} from "@diffkit/icons"; +import { CommentIcon, ViewIcon } from "@diffkit/icons"; import { Spinner } from "@diffkit/ui/components/spinner"; import { cn } from "@diffkit/ui/lib/utils"; import { useQuery } from "@tanstack/react-query"; @@ -17,6 +10,7 @@ import { githubPullCommentsQueryOptions, } from "#/lib/github.query"; import type { PullSummary } from "#/lib/github.types"; +import { getPrStateConfig } from "#/lib/pr-state"; import { preloadRouteOnce } from "#/lib/route-preload"; const Markdown = lazy(() => @@ -25,19 +19,6 @@ const Markdown = lazy(() => })), ); -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 const PullRequestRow = memo(function PullRequestRow({ pr, scope, @@ -45,7 +26,7 @@ export const PullRequestRow = memo(function PullRequestRow({ pr: PullSummary; scope: GitHubQueryScope; }) { - const { icon: Icon, color } = getPrStateProps(pr); + const { icon: Icon, color } = getPrStateConfig(pr); const href = `/${pr.repository.owner}/${pr.repository.name}/pull/${pr.number}`; const [expanded, setExpanded] = useState(false); const router = useRouter(); diff --git a/apps/dashboard/src/lib/github-cache-policy.ts b/apps/dashboard/src/lib/github-cache-policy.ts index c37b95c..9c26ef5 100644 --- a/apps/dashboard/src/lib/github-cache-policy.ts +++ b/apps/dashboard/src/lib/github-cache-policy.ts @@ -19,8 +19,16 @@ export const githubCachePolicy = { staleTimeMs: 20 * 1000, gcTimeMs: 10 * 60 * 1000, }, + userActivity: { + staleTimeMs: 5 * 60 * 1000, + gcTimeMs: 30 * 60 * 1000, + }, status: { staleTimeMs: 15 * 1000, gcTimeMs: 5 * 60 * 1000, }, + contributions: { + staleTimeMs: 60 * 60 * 1000, + gcTimeMs: 24 * 60 * 60 * 1000, + }, } as const; diff --git a/apps/dashboard/src/lib/github.functions.ts b/apps/dashboard/src/lib/github.functions.ts index 73de41b..4be0dd0 100644 --- a/apps/dashboard/src/lib/github.functions.ts +++ b/apps/dashboard/src/lib/github.functions.ts @@ -3,10 +3,14 @@ import { type Octokit as OctokitType, RequestError } from "octokit"; import { debug } from "./debug"; import type { CommandPaletteSearchResult, + ContributionDay, + ContributionWeek, CreateLabelInput, CreateReviewCommentInput, GitHubActor, + GitHubContributionCalendar, GitHubLabel, + GitHubUserProfile, IssueComment, IssueDetail, IssuePageData, @@ -14,6 +18,7 @@ import type { MyIssuesResult, MyPullsResult, OrgTeam, + PinnedRepo, PullComment, PullCommit, PullDetail, @@ -31,6 +36,7 @@ import type { SetLabelsInput, SubmitReviewInput, TimelineEvent, + UserActivityEvent, UserRepoSummary, } from "./github.types"; import { @@ -3962,3 +3968,385 @@ export const createRepoLabel = createServerFn({ method: "POST" }) return null; } }); + +type UserProfileInput = { username: string }; + +export const getUserProfile = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + try { + const response = await context.octokit.rest.users.getByUsername({ + username: data.username, + }); + const user = response.data; + return { + id: user.id, + login: user.login, + name: user.name ?? null, + avatarUrl: user.avatar_url, + bio: user.bio ?? null, + company: user.company ?? null, + location: user.location ?? null, + blog: user.blog || null, + twitterUsername: user.twitter_username ?? null, + followers: user.followers ?? 0, + following: user.following ?? 0, + publicRepos: user.public_repos ?? 0, + createdAt: user.created_at, + url: user.html_url, + }; + } catch (error) { + if (error instanceof RequestError && error.status === 404) { + return null; + } + throw error; + } + }); + +export const getUserContributions = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return null; + } + + try { + const response: { + user: { + contributionsCollection: { + contributionCalendar: { + totalContributions: number; + weeks: Array<{ + contributionDays: Array<{ + date: string; + contributionCount: number; + contributionLevel: + | "NONE" + | "FIRST_QUARTILE" + | "SECOND_QUARTILE" + | "THIRD_QUARTILE" + | "FOURTH_QUARTILE"; + }>; + }>; + }; + }; + }; + } = await context.octokit.graphql( + `query($username: String!) { + user(login: $username) { + contributionsCollection { + contributionCalendar { + totalContributions + weeks { + contributionDays { + date + contributionCount + contributionLevel + } + } + } + } + } + }`, + { username: data.username }, + ); + + const calendar = + response.user.contributionsCollection.contributionCalendar; + const levelMap: Record = { + NONE: 0, + FIRST_QUARTILE: 1, + SECOND_QUARTILE: 2, + THIRD_QUARTILE: 3, + FOURTH_QUARTILE: 4, + }; + + return { + totalContributions: calendar.totalContributions, + weeks: calendar.weeks.map( + (week): ContributionWeek => ({ + days: week.contributionDays.map( + (day): ContributionDay => ({ + date: day.date, + count: day.contributionCount, + level: levelMap[day.contributionLevel] ?? 0, + }), + ), + }), + ), + }; + } catch { + return null; + } + }); + +export const getUserPinnedRepos = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + try { + const response: { + user: { + pinnedItems: { + nodes: Array<{ + name: string; + description: string | null; + stargazerCount: number; + primaryLanguage: { name: string; color: string } | null; + url: string; + owner: { login: string }; + isPrivate: boolean; + forkCount: number; + }>; + }; + }; + } = await context.octokit.graphql( + `query($username: String!) { + user(login: $username) { + pinnedItems(first: 6, types: REPOSITORY) { + nodes { + ... on Repository { + name + description + stargazerCount + primaryLanguage { name color } + url + owner { login } + isPrivate + forkCount + } + } + } + } + }`, + { username: data.username }, + ); + + return response.user.pinnedItems.nodes.map((repo) => ({ + name: repo.name, + description: repo.description, + stars: repo.stargazerCount, + language: repo.primaryLanguage?.name ?? null, + languageColor: repo.primaryLanguage?.color ?? null, + url: repo.url, + owner: repo.owner.login, + isPrivate: repo.isPrivate, + forks: repo.forkCount, + })); + } catch { + return []; + } + }); + +type UserActivityInput = { + username: string; + isOwnProfile: boolean; + page: number; +}; + +export const getUserActivity = createServerFn({ method: "GET" }) + .inputValidator(identityValidator) + .handler(async ({ data }): Promise => { + const context = await getGitHubContext(); + if (!context) { + return []; + } + + try { + const endpoint = data.isOwnProfile + ? "GET /users/{username}/events" + : "GET /users/{username}/events/public"; + + const response = await context.octokit.request(endpoint, { + username: data.username, + per_page: 30, + page: data.page, + }); + + type GitHubEvent = (typeof response.data)[number]; + + return response.data + .map((event: GitHubEvent): UserActivityEvent | null => { + const payload = event.payload as Record; + const type = event.type ?? ""; + + type RawPR = { + title?: string; + number?: number; + body?: string; + additions?: number; + deletions?: number; + comments?: number; + changed_files?: number; + state?: string; + draft?: boolean; + merged?: boolean; + html_url?: string; + head?: { ref?: string }; + base?: { ref?: string }; + labels?: Array<{ name: string; color: string }>; + }; + type RawIssue = { + title?: string; + number?: number; + body?: string; + comments?: number; + state?: string; + html_url?: string; + }; + + let action: string | null = null; + let title: string | null = null; + let ref: string | null = null; + let refType: string | null = null; + let commits: UserActivityEvent["commits"] = null; + let commentBody: string | null = null; + let prDetail: UserActivityEvent["prDetail"] = null; + let issueDetail: UserActivityEvent["issueDetail"] = null; + + switch (type) { + case "PushEvent": + ref = (payload.ref as string)?.replace("refs/heads/", "") ?? null; + commits = Array.isArray(payload.commits) + ? (payload.commits as Array<{ sha: string; message: string }>) + .slice(0, 3) + .map((c) => ({ + sha: c.sha.slice(0, 7), + message: c.message.split("\n")[0], + })) + : null; + break; + case "CreateEvent": + case "DeleteEvent": + refType = (payload.ref_type as string) ?? null; + ref = (payload.ref as string) ?? null; + break; + case "PullRequestEvent": { + action = (payload.action as string) ?? null; + const pr = payload.pull_request as RawPR | undefined; + title = pr?.title ?? null; + if (pr) { + prDetail = { + number: pr.number ?? 0, + body: pr.body?.slice(0, 200) ?? null, + additions: pr.additions ?? 0, + deletions: pr.deletions ?? 0, + comments: pr.comments ?? 0, + changedFiles: pr.changed_files ?? 0, + state: pr.merged ? "merged" : (pr.state ?? ""), + isDraft: pr.draft ?? false, + url: pr.html_url ?? "", + headRef: pr.head?.ref ?? null, + baseRef: pr.base?.ref ?? null, + labels: (pr.labels ?? []).slice(0, 5).map((l) => ({ + name: l.name, + color: l.color, + })), + }; + } + break; + } + case "IssuesEvent": { + action = (payload.action as string) ?? null; + const issue = payload.issue as RawIssue | undefined; + title = issue?.title ?? null; + if (issue) { + issueDetail = { + number: issue.number ?? 0, + body: issue.body?.slice(0, 200) ?? null, + comments: issue.comments ?? 0, + state: issue.state ?? "", + url: issue.html_url ?? "", + }; + } + break; + } + case "IssueCommentEvent": + action = "commented"; + title = + (payload.issue as { title?: string } | undefined)?.title ?? + null; + commentBody = + (payload.comment as { body?: string } | undefined)?.body?.slice( + 0, + 200, + ) ?? null; + break; + case "WatchEvent": + action = "starred"; + break; + case "ForkEvent": + action = "forked"; + break; + case "ReleaseEvent": + action = (payload.action as string) ?? null; + title = + (payload.release as { tag_name?: string } | undefined) + ?.tag_name ?? null; + break; + case "PullRequestReviewEvent": { + action = (payload.action as string) ?? null; + const reviewPr = payload.pull_request as RawPR | undefined; + title = reviewPr?.title ?? null; + if (reviewPr) { + prDetail = { + number: reviewPr.number ?? 0, + body: reviewPr.body?.slice(0, 200) ?? null, + additions: reviewPr.additions ?? 0, + deletions: reviewPr.deletions ?? 0, + comments: reviewPr.comments ?? 0, + changedFiles: reviewPr.changed_files ?? 0, + state: reviewPr.merged ? "merged" : (reviewPr.state ?? ""), + isDraft: reviewPr.draft ?? false, + url: reviewPr.html_url ?? "", + headRef: reviewPr.head?.ref ?? null, + baseRef: reviewPr.base?.ref ?? null, + labels: (reviewPr.labels ?? []).slice(0, 5).map((l) => ({ + name: l.name, + color: l.color, + })), + }; + } + break; + } + default: + return null; + } + + return { + id: event.id ?? "", + type, + createdAt: event.created_at ?? "", + repo: { + name: event.repo?.name ?? "", + url: `https://github.com/${event.repo?.name ?? ""}`, + }, + actor: { + login: event.actor?.login ?? "", + avatarUrl: event.actor?.avatar_url ?? "", + }, + action, + title, + ref, + refType, + commits, + commentBody, + prDetail, + issueDetail, + }; + }) + .filter((e): e is UserActivityEvent => e !== null); + } catch { + return []; + } + }); diff --git a/apps/dashboard/src/lib/github.query.ts b/apps/dashboard/src/lib/github.query.ts index c378c25..168ca40 100644 --- a/apps/dashboard/src/lib/github.query.ts +++ b/apps/dashboard/src/lib/github.query.ts @@ -1,4 +1,4 @@ -import { queryOptions } from "@tanstack/react-query"; +import { infiniteQueryOptions, queryOptions } from "@tanstack/react-query"; import { type CommandPaletteSearchInput, getCommentPage, @@ -23,6 +23,10 @@ import { getRepoCollaborators, getRepoLabels, getTimelineEventPage, + getUserActivity, + getUserContributions, + getUserPinnedRepos, + getUserProfile, getUserRepos, searchCommandPaletteGitHub, } from "./github.functions"; @@ -160,6 +164,14 @@ export const githubQueryKeys = { scope: GitHubQueryScope, input: { owner: string; repo: string; issueNumber: number; page: number }, ) => ["github", scope.userId, "timelineEventPage", input] as const, + profile: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "profile", username] as const, + contributions: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "contributions", username] as const, + pinnedRepos: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "pinnedRepos", username] as const, + activity: (scope: GitHubQueryScope, username: string) => + ["github", scope.userId, "activity", username] as const, issues: { mine: (scope: GitHubQueryScope) => ["github", scope.userId, "issues", "mine"] as const, @@ -471,3 +483,56 @@ export function githubTimelineEventPageQueryOptions( meta: tabPersistedMeta, }); } + +export function githubUserProfileQueryOptions( + scope: GitHubQueryScope, + username: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.profile(scope, username), + queryFn: () => getUserProfile({ data: { username } }), + staleTime: githubCachePolicy.viewer.staleTimeMs, + gcTime: githubCachePolicy.viewer.gcTimeMs, + }); +} + +export function githubUserContributionsQueryOptions( + scope: GitHubQueryScope, + username: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.contributions(scope, username), + queryFn: () => getUserContributions({ data: { username } }), + staleTime: githubCachePolicy.contributions.staleTimeMs, + gcTime: githubCachePolicy.contributions.gcTimeMs, + }); +} + +export function githubUserPinnedReposQueryOptions( + scope: GitHubQueryScope, + username: string, +) { + return queryOptions({ + queryKey: githubQueryKeys.pinnedRepos(scope, username), + queryFn: () => getUserPinnedRepos({ data: { username } }), + staleTime: githubCachePolicy.reposList.staleTimeMs, + gcTime: githubCachePolicy.reposList.gcTimeMs, + }); +} + +export function githubUserActivityQueryOptions( + scope: GitHubQueryScope, + username: string, + isOwnProfile: boolean, +) { + return infiniteQueryOptions({ + queryKey: githubQueryKeys.activity(scope, username), + queryFn: ({ pageParam }) => + getUserActivity({ data: { username, isOwnProfile, page: pageParam } }), + initialPageParam: 1, + getNextPageParam: (lastPage, _allPages, lastPageParam) => + lastPage.length === 30 ? lastPageParam + 1 : undefined, + staleTime: githubCachePolicy.userActivity.staleTimeMs, + gcTime: githubCachePolicy.userActivity.gcTimeMs, + }); +} diff --git a/apps/dashboard/src/lib/github.types.ts b/apps/dashboard/src/lib/github.types.ts index dafec0d..85a9c25 100644 --- a/apps/dashboard/src/lib/github.types.ts +++ b/apps/dashboard/src/lib/github.types.ts @@ -344,3 +344,82 @@ export type CreateReviewCommentInput = { line: number; side: "LEFT" | "RIGHT"; }; + +export type GitHubUserProfile = { + id: number; + login: string; + name: string | null; + avatarUrl: string; + bio: string | null; + company: string | null; + location: string | null; + blog: string | null; + twitterUsername: string | null; + followers: number; + following: number; + publicRepos: number; + createdAt: string; + url: string; +}; + +export type ContributionDay = { + date: string; + count: number; + level: 0 | 1 | 2 | 3 | 4; +}; + +export type ContributionWeek = { + days: ContributionDay[]; +}; + +export type GitHubContributionCalendar = { + totalContributions: number; + weeks: ContributionWeek[]; +}; + +export type PinnedRepo = { + name: string; + description: string | null; + stars: number; + language: string | null; + languageColor: string | null; + url: string; + owner: string; + isPrivate: boolean; + forks: number; +}; + +export type UserActivityEvent = { + id: string; + type: string; + createdAt: string; + repo: { name: string; url: string }; + actor: { login: string; avatarUrl: string }; + action: string | null; + title: string | null; + ref: string | null; + refType: string | null; + commits: Array<{ sha: string; message: string }> | null; + commentBody: string | null; + prDetail: { + number: number; + body: string | null; + additions: number; + deletions: number; + comments: number; + changedFiles: number; + state: string; + isDraft: boolean; + url: string; + headRef: string | null; + baseRef: string | null; + labels: Array<{ name: string; color: string }>; + } | null; + issueDetail: { + number: number; + body: string | null; + comments: number; + state: string; + url: string; + } | null; +}; diff --git a/apps/dashboard/src/lib/pr-state.ts b/apps/dashboard/src/lib/pr-state.ts new file mode 100644 index 0000000..5eee73d --- /dev/null +++ b/apps/dashboard/src/lib/pr-state.ts @@ -0,0 +1,55 @@ +import { + GitMergeIcon, + GitPullRequestClosedIcon, + GitPullRequestDraftIcon, + GitPullRequestIcon, +} from "@diffkit/icons"; + +export type PrStateConfig = { + icon: React.ComponentType<{ + size?: number; + strokeWidth?: number; + className?: string; + }>; + color: string; + label: string; + badgeClass: string; +}; + +export function getPrStateConfig(pr: { + isDraft: boolean; + state: string; + isMerged?: boolean; + mergedAt?: string | null; +}): PrStateConfig { + if (pr.isDraft) { + return { + icon: GitPullRequestDraftIcon, + color: "text-muted-foreground", + label: "Draft", + badgeClass: "bg-muted text-muted-foreground", + }; + } + if (pr.isMerged || pr.mergedAt || pr.state === "merged") { + return { + icon: GitMergeIcon, + color: "text-purple-500", + label: "Merged", + badgeClass: "bg-purple-500/10 text-purple-500", + }; + } + if (pr.state === "closed") { + return { + icon: GitPullRequestClosedIcon, + color: "text-red-500", + label: "Closed", + badgeClass: "bg-red-500/10 text-red-500", + }; + } + return { + icon: GitPullRequestIcon, + color: "text-green-500", + label: "Open", + badgeClass: "bg-green-500/10 text-green-500", + }; +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 5e556ef..e43cbbc 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -20,6 +20,7 @@ import { Route as ProtectedReviewsRouteImport } from './routes/_protected/review import { Route as ProtectedPullsRouteImport } from './routes/_protected/pulls' import { Route as ProtectedIssuesRouteImport } from './routes/_protected/issues' import { Route as ProtectedSettingsIndexRouteImport } from './routes/_protected/settings/index' +import { Route as ProtectedOwnerIndexRouteImport } from './routes/_protected/$owner/index' import { Route as ApiWebhooksGithubRouteImport } from './routes/api/webhooks/github' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ProtectedSettingsShortcutsRouteImport } from './routes/_protected/settings/shortcuts' @@ -83,6 +84,11 @@ const ProtectedSettingsIndexRoute = ProtectedSettingsIndexRouteImport.update({ path: '/', getParentRoute: () => ProtectedSettingsRoute, } as any) +const ProtectedOwnerIndexRoute = ProtectedOwnerIndexRouteImport.update({ + id: '/$owner/', + path: '/$owner/', + getParentRoute: () => ProtectedRoute, +} as any) const ApiWebhooksGithubRoute = ApiWebhooksGithubRouteImport.update({ id: '/api/webhooks/github', path: '/api/webhooks/github', @@ -141,6 +147,7 @@ export interface FileRoutesByFullPath { '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/$owner/': typeof ProtectedOwnerIndexRoute '/settings/': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute @@ -160,6 +167,7 @@ export interface FileRoutesByTo { '/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/$owner': typeof ProtectedOwnerIndexRoute '/settings': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute @@ -182,6 +190,7 @@ export interface FileRoutesById { '/_protected/settings/shortcuts': typeof ProtectedSettingsShortcutsRoute '/api/auth/$': typeof ApiAuthSplatRoute '/api/webhooks/github': typeof ApiWebhooksGithubRoute + '/_protected/$owner/': typeof ProtectedOwnerIndexRoute '/_protected/settings/': typeof ProtectedSettingsIndexRoute '/api/github/app/authorize': typeof ApiGithubAppAuthorizeRoute '/api/github/app/callback': typeof ApiGithubAppCallbackRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/api/auth/$' | '/api/webhooks/github' + | '/$owner/' | '/settings/' | '/api/github/app/authorize' | '/api/github/app/callback' @@ -223,6 +233,7 @@ export interface FileRouteTypes { | '/settings/shortcuts' | '/api/auth/$' | '/api/webhooks/github' + | '/$owner' | '/settings' | '/api/github/app/authorize' | '/api/github/app/callback' @@ -244,6 +255,7 @@ export interface FileRouteTypes { | '/_protected/settings/shortcuts' | '/api/auth/$' | '/api/webhooks/github' + | '/_protected/$owner/' | '/_protected/settings/' | '/api/github/app/authorize' | '/api/github/app/callback' @@ -343,6 +355,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ProtectedSettingsIndexRouteImport parentRoute: typeof ProtectedSettingsRoute } + '/_protected/$owner/': { + id: '/_protected/$owner/' + path: '/$owner' + fullPath: '/$owner/' + preLoaderRoute: typeof ProtectedOwnerIndexRouteImport + parentRoute: typeof ProtectedRoute + } '/api/webhooks/github': { id: '/api/webhooks/github' path: '/api/webhooks/github' @@ -421,6 +440,7 @@ interface ProtectedRouteChildren { ProtectedReviewsRoute: typeof ProtectedReviewsRoute ProtectedSettingsRoute: typeof ProtectedSettingsRouteWithChildren ProtectedIndexRoute: typeof ProtectedIndexRoute + ProtectedOwnerIndexRoute: typeof ProtectedOwnerIndexRoute ProtectedOwnerRepoIssuesIssueIdRoute: typeof ProtectedOwnerRepoIssuesIssueIdRoute ProtectedOwnerRepoPullPullIdRoute: typeof ProtectedOwnerRepoPullPullIdRoute ProtectedOwnerRepoReviewPullIdRoute: typeof ProtectedOwnerRepoReviewPullIdRoute @@ -432,6 +452,7 @@ const ProtectedRouteChildren: ProtectedRouteChildren = { ProtectedReviewsRoute: ProtectedReviewsRoute, ProtectedSettingsRoute: ProtectedSettingsRouteWithChildren, ProtectedIndexRoute: ProtectedIndexRoute, + ProtectedOwnerIndexRoute: ProtectedOwnerIndexRoute, ProtectedOwnerRepoIssuesIssueIdRoute: ProtectedOwnerRepoIssuesIssueIdRoute, ProtectedOwnerRepoPullPullIdRoute: ProtectedOwnerRepoPullPullIdRoute, ProtectedOwnerRepoReviewPullIdRoute: ProtectedOwnerRepoReviewPullIdRoute, diff --git a/apps/dashboard/src/routes/_protected/$owner/index.tsx b/apps/dashboard/src/routes/_protected/$owner/index.tsx new file mode 100644 index 0000000..4d8c70e --- /dev/null +++ b/apps/dashboard/src/routes/_protected/$owner/index.tsx @@ -0,0 +1,339 @@ +import { + BuildingIcon, + ChevronDownIcon, + ExternalLinkIcon, + FollowersIcon, + LocationIcon, + PenIcon, +} from "@diffkit/icons"; +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@diffkit/ui/components/avatar"; +import { Button } from "@diffkit/ui/components/button"; +import { Skeleton } from "@diffkit/ui/components/skeleton"; +import { Spinner } from "@diffkit/ui/components/spinner"; +import { useInfiniteQuery, useQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; +import { ContributionGraph } from "#/components/profile/contribution-graph"; +import { PinnedRepoCard } from "#/components/profile/pinned-repo-card"; +import { UserActivityFeed } from "#/components/profile/user-activity-feed"; +import { + githubUserActivityQueryOptions, + githubUserContributionsQueryOptions, + githubUserPinnedReposQueryOptions, + githubUserProfileQueryOptions, + githubViewerQueryOptions, +} from "#/lib/github.query"; +import type { GitHubUserProfile } from "#/lib/github.types"; +import { buildSeo, formatPageTitle } from "#/lib/seo"; +import { useHasMounted } from "#/lib/use-has-mounted"; + +export const Route = createFileRoute("/_protected/$owner/")({ + ssr: false, + loader: async ({ context, params }) => { + const scope = { userId: context.user.id }; + await Promise.all([ + context.queryClient.ensureQueryData( + githubUserProfileQueryOptions(scope, params.owner), + ), + context.queryClient.ensureQueryData(githubViewerQueryOptions(scope)), + context.queryClient.ensureQueryData( + githubUserPinnedReposQueryOptions(scope, params.owner), + ), + ]); + // Contributions & activity load client-side + void context.queryClient.prefetchQuery( + githubUserContributionsQueryOptions(scope, params.owner), + ); + }, + head: ({ match, params }) => + buildSeo({ + path: match.pathname, + title: formatPageTitle(`@${params.owner}`), + description: `GitHub profile for ${params.owner}.`, + robots: "noindex", + }), + component: ProfilePage, +}); + +function ProfilePage() { + const { owner } = Route.useParams(); + const { user } = Route.useRouteContext(); + const scope = { userId: user.id }; + const hasMounted = useHasMounted(); + + // Server-side: available immediately from loader + const profileQuery = useQuery(githubUserProfileQueryOptions(scope, owner)); + const viewerQuery = useQuery(githubViewerQueryOptions(scope)); + const pinnedReposQuery = useQuery( + githubUserPinnedReposQueryOptions(scope, owner), + ); + + // Client-side: load after mount + const contributionsQuery = useQuery({ + ...githubUserContributionsQueryOptions(scope, owner), + enabled: hasMounted, + }); + + const profile = profileQuery.data; + const contributions = contributionsQuery.data; + const pinnedRepos = pinnedReposQuery.data; + const isOwnProfile = viewerQuery.data?.login === owner; + + const activityQuery = useInfiniteQuery({ + ...githubUserActivityQueryOptions(scope, owner, isOwnProfile), + enabled: hasMounted && viewerQuery.data !== undefined, + }); + const activity = activityQuery.data?.pages.flat(); + + if (profileQuery.error) throw profileQuery.error; + + return ( +
+ {/* Banner — contribution graph with aurora overlay */} +
+ {contributions ? ( + <> + {/* Contribution graph */} + + + {/* Edge fades */} +
+
+
+ + ) : null} +
+ + {/* Profile content */} +
+ {/* Avatar — overlaps the banner */} +
+ + {profile ? ( + <> + + + {(profile.name ?? profile.login).charAt(0).toUpperCase()} + + + ) : ( + + )} + + + {isOwnProfile && ( + + )} +
+ + {/* User info */} + {profile ? ( +
+
+

+ {profile.name ?? profile.login} +

+
+ @{profile.login} + · + + Joined{" "} + {new Date(profile.createdAt).toLocaleDateString("en-US", { + month: "long", + year: "numeric", + })} + +
+
+ + {profile.bio && ( +

{profile.bio}

+ )} + + {/* Metadata row */} + + + {/* Followers / Following */} + + + {/* Pinned Repos */} + {pinnedRepos && pinnedRepos.length > 0 && ( +
+

+ Pinned +

+
+ {pinnedRepos.map((repo) => ( + + ))} +
+
+ )} + + {/* Activity */} + {activity && activity.length > 0 && ( +
+
+

Recent activity

+ + {activity.length} + +
+ + {activityQuery.hasNextPage && ( + + )} +
+ )} +
+ ) : ( + + )} +
+
+ ); +} + +function ProfileMetadata({ profile }: { profile: GitHubUserProfile }) { + const items: Array<{ + icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; + label: string; + href?: string; + }> = []; + + if (profile.company) { + items.push({ icon: BuildingIcon, label: profile.company }); + } + if (profile.location) { + items.push({ icon: LocationIcon, label: profile.location }); + } + if (profile.blog) { + const url = profile.blog.startsWith("http") + ? profile.blog + : `https://${profile.blog}`; + const displayUrl = profile.blog + .replace(/^https?:\/\//, "") + .replace(/\/$/, ""); + items.push({ icon: ExternalLinkIcon, label: displayUrl, href: url }); + } + if (profile.twitterUsername) { + items.push({ + icon: ExternalLinkIcon, + label: `@${profile.twitterUsername}`, + href: `https://x.com/${profile.twitterUsername}`, + }); + } + + if (items.length === 0) return null; + + return ( +
+ {items.map((item) => { + const content = ( + + + {item.label} + + ); + + if (item.href) { + return ( + + {content} + + ); + } + + return {content}; + })} +
+ ); +} + +function ProfileSkeleton() { + return ( +
+
+ + +
+ +
+ + + +
+
+ + +
+
+ ); +} + +function formatCount(count: number): string { + if (count >= 1000) { + return `${(count / 1000).toFixed(1).replace(/\.0$/, "")}k`; + } + return count.toString(); +} diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 1dbab83..499fe84 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -12,6 +12,7 @@ export { ArrowUp01Icon as ChevronUpIcon, BookOpen01Icon as BookOpenIcon, Bug01Icon as BugIcon, + Building03Icon as BuildingIcon, Calendar01Icon as CalendarIcon, Cancel01Icon as CloseIcon, Cancel01Icon as XIcon, @@ -31,13 +32,18 @@ export { FolderLibraryIcon, GitBranchIcon, GitCommitIcon, + GitForkIcon, GitMergeIcon, GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, Home01Icon as HomeIcon, InboxIcon, + Link01Icon as LinkIcon, + Link02Icon as ExternalLinkIcon, Loading03Icon as LoaderCircleIcon, + Location01Icon as LocationIcon, + Mail01Icon as MailIcon, Message01Icon as MessageIcon, Moon02Icon as MoonIcon, MoreHorizontalIcon, @@ -54,6 +60,8 @@ export { Tick02Icon as TickIcon, UserAdd01Icon as UserAddIcon, UserCircleIcon, + UserGroupIcon as FollowersIcon, ViewIcon, } from "@hugeicons/react"; export { GitHubLogo, GitHubWordmarkLogo } from "./brand-logos"; +export { PenIcon } from "./pen-icon"; diff --git a/packages/icons/src/pen-icon.tsx b/packages/icons/src/pen-icon.tsx new file mode 100644 index 0000000..8b51b50 --- /dev/null +++ b/packages/icons/src/pen-icon.tsx @@ -0,0 +1,25 @@ +import type { SVGProps } from "react"; + +export function PenIcon(props: SVGProps & { size?: number }) { + const { size = 24, width, height, ...rest } = props; + return ( + + + + + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3f45ad5..71a6f3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -74,6 +74,9 @@ importers: drizzle-orm: specifier: ^0.45.2 version: 0.45.2(@cloudflare/workers-types@4.20260405.1)(@opentelemetry/api@1.9.1)(kysely@0.28.15) + motion: + specifier: ^12.38.0 + version: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -3604,6 +3607,20 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + framer-motion@12.38.0: + resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -4071,6 +4088,26 @@ packages: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} + motion-dom@12.38.0: + resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==} + + motion-utils@12.36.0: + resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==} + + motion@12.38.0: + resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -7872,6 +7909,15 @@ snapshots: dependencies: to-regex-range: 5.0.1 + framer-motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + motion-dom: 12.38.0 + motion-utils: 12.36.0 + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + fsevents@2.3.3: optional: true @@ -8590,6 +8636,20 @@ snapshots: minipass@7.1.3: {} + motion-dom@12.38.0: + dependencies: + motion-utils: 12.36.0 + + motion-utils@12.36.0: {} + + motion@12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + dependencies: + framer-motion: 12.38.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + tslib: 2.8.1 + optionalDependencies: + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + ms@2.1.3: {} nanoid@3.3.11: {}