diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11133e5a..dd83d475 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -30,7 +30,7 @@ cp apps/web/.env.example apps/web/.env bun install # 6. Run database migrations -cd apps/web && npx prisma migrate dev && npx prisma generate && cd ../.. +cd apps/web && bunx prisma migrate dev && bunx prisma generate && cd ../.. # 7. Start dev server bun dev diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts index e02bc3e8..4125826f 100644 --- a/apps/web/next.config.ts +++ b/apps/web/next.config.ts @@ -10,6 +10,7 @@ const KNOWN_ROUTES = [ "dashboard", "debug", "extension", + "gists", "issues", "notifications", "orgs", diff --git a/apps/web/prisma/migrations/20260228005751_init/migration.sql b/apps/web/prisma/migrations/20260228005751_init/migration.sql new file mode 100644 index 00000000..0e2f4a2b --- /dev/null +++ b/apps/web/prisma/migrations/20260228005751_init/migration.sql @@ -0,0 +1,8 @@ +/* + This file was added to keep backwards compatibility with older versions without dropping the whole database. No idea how it appeared in the first place. + Deleting the folder gives the following prisma error: + - The following migration(s) are applied to the database but missing from the local migrations directory: 20260228005751_init + - You may use prisma migrate reset to drop the development database. + - All data will be lost. + If you can figure out how to delete this folder, please do. +*/ diff --git a/apps/web/src/app/(app)/[owner]/page.tsx b/apps/web/src/app/(app)/[owner]/page.tsx index 6af180c9..2deddfdd 100644 --- a/apps/web/src/app/(app)/[owner]/page.tsx +++ b/apps/web/src/app/(app)/[owner]/page.tsx @@ -9,6 +9,8 @@ import { getUserOrgTopRepos, getContributionData, getUserEvents, + getUserGists, + getUserStarredGists, } from "@/lib/github"; import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; import { OrgDetailContent } from "@/components/orgs/org-detail-content"; @@ -116,16 +118,26 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s let contributionData: Awaited> = null; let orgTopRepos: Awaited> = []; let activityEvents: Awaited> = []; + let gistsData: Awaited> = []; + let starredGistsData: Awaited> = []; if (!isBot) { try { - const [reposResult, orgsResult, contributionsResult, eventsResult] = - await Promise.allSettled([ - getUserPublicRepos(userData.login, 100), - getUserPublicOrgs(userData.login), - getContributionData(userData.login), - getUserEvents(userData.login, 100), - ]); + const [ + reposResult, + orgsResult, + contributionsResult, + eventsResult, + gistsResult, + starredGistsResult, + ] = await Promise.allSettled([ + getUserPublicRepos(userData.login, 100), + getUserPublicOrgs(userData.login), + getContributionData(userData.login), + getUserEvents(userData.login, 100), + getUserGists(userData.login, 100), + getUserStarredGists(100), + ]); if (reposResult.status === "fulfilled") reposData = reposResult.value; if (orgsResult.status === "fulfilled") orgsData = orgsResult.value; if (contributionsResult.status === "fulfilled") { @@ -133,6 +145,9 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s } if (eventsResult.status === "fulfilled") activityEvents = eventsResult.value; + if (gistsResult.status === "fulfilled") gistsData = gistsResult.value; + if (starredGistsResult.status === "fulfilled") + starredGistsData = starredGistsResult.value; if (orgsData.length > 0) { orgTopRepos = await getUserOrgTopRepos( orgsData.map((o) => o.login), @@ -191,6 +206,28 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s forks_count: r.forks_count, language: r.language, }))} + gists={gistsData.map((gist) => ({ + id: gist.id, + description: gist.description, + html_url: gist.html_url, + public: gist.public, + created_at: gist.created_at, + updated_at: gist.updated_at, + stars: gist.stars ?? 0, + files: gist.files, + comments: gist.comments, + }))} + starredGists={starredGistsData.map((gist) => ({ + id: gist.id, + description: gist.description, + html_url: gist.html_url, + public: gist.public, + created_at: gist.created_at, + updated_at: gist.updated_at, + stars: gist.stars ?? 0, + files: gist.files, + comments: gist.comments, + }))} /> ); } diff --git a/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/comments/page.tsx b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/comments/page.tsx new file mode 100644 index 00000000..345e3062 --- /dev/null +++ b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/comments/page.tsx @@ -0,0 +1,21 @@ +import { notFound } from "next/navigation"; +import { getGist, getGistComments } from "@/lib/github"; +import { GistComments } from "@/components/gist/gist-comments"; + +export default async function GistCommentsPage({ + params, +}: { + params: Promise<{ owner: string; gistId: string }>; +}) { + const { gistId } = await params; + const [gist, comments] = await Promise.all([ + getGist(gistId).catch(() => null), + getGistComments(gistId).catch(() => []), + ]); + + if (!gist) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/layout.tsx b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/layout.tsx new file mode 100644 index 00000000..d5c8ab5b --- /dev/null +++ b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/layout.tsx @@ -0,0 +1,70 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistNav } from "@/components/gist/gist-nav"; +import { GistHeader } from "@/components/gist/gist-header"; +import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ owner: string; gistId: string }>; +}): Promise { + const { gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + return { title: "Gist Not Found" }; + } + + const title = gist.description || Object.values(gist.files)[0]?.filename || "Untitled Gist"; + const firstFile = Object.values(gist.files)[0]; + + return { + title: `${title} - Gist by ${gist.owner.login}`, + description: `Gist created by ${gist.owner.login}${firstFile ? ` - ${firstFile.filename}` : ""}`, + openGraph: { + title: `${title} - Gist`, + ...ogImages(ogImageUrl({ type: "owner", owner: gist.owner.login })), + }, + twitter: { + card: "summary_large_image", + ...ogImages(ogImageUrl({ type: "owner", owner: gist.owner.login })), + }, + }; +} + +export default async function GistLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ owner: string; gistId: string }>; +}) { + const { owner, gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + notFound(); + } + + const fileCount = Object.keys(gist.files).length; + + return ( +
+
+ +
+ +
+
+ {children} +
+ ); +} diff --git a/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx new file mode 100644 index 00000000..acf2379d --- /dev/null +++ b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistFiles } from "@/components/gist/gist-files"; + +export default async function GistFilesPage({ + params, +}: { + params: Promise<{ owner: string; gistId: string }>; +}) { + const { gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/revisions/page.tsx b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/revisions/page.tsx new file mode 100644 index 00000000..4087f92c --- /dev/null +++ b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/revisions/page.tsx @@ -0,0 +1,18 @@ +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistRevisions } from "@/components/gist/gist-revisions"; + +export default async function GistRevisionsPage({ + params, +}: { + params: Promise<{ owner: string; gistId: string }>; +}) { + const { gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/app/(app)/repos/[owner]/gist/actions.ts b/apps/web/src/app/(app)/repos/[owner]/gist/actions.ts new file mode 100644 index 00000000..62c45ba2 --- /dev/null +++ b/apps/web/src/app/(app)/repos/[owner]/gist/actions.ts @@ -0,0 +1,29 @@ +"use server"; + +import { getOctokit } from "@/lib/github"; +import { getErrorMessage } from "@/lib/utils"; +import { revalidatePath } from "next/cache"; + +export async function starGist(owner: string, gistId: string) { + const octokit = await getOctokit(); + if (!octokit) return { error: "Not authenticated" }; + try { + await octokit.gists.star({ gist_id: gistId }); + revalidatePath(`/repos/${owner}/gist/${gistId}`); + return { success: true }; + } catch (e: unknown) { + return { error: getErrorMessage(e) || "Failed to star gist" }; + } +} + +export async function unstarGist(owner: string, gistId: string) { + const octokit = await getOctokit(); + if (!octokit) return { error: "Not authenticated" }; + try { + await octokit.gists.unstar({ gist_id: gistId }); + revalidatePath(`/repos/${owner}/gist/${gistId}`); + return { success: true }; + } catch (e: unknown) { + return { error: getErrorMessage(e) || "Failed to unstar gist" }; + } +} diff --git a/apps/web/src/components/dashboard/contribution-chart.tsx b/apps/web/src/components/dashboard/contribution-chart.tsx index df27d8e4..40345372 100644 --- a/apps/web/src/components/dashboard/contribution-chart.tsx +++ b/apps/web/src/components/dashboard/contribution-chart.tsx @@ -1,6 +1,8 @@ "use client"; +import { useIsMobile } from "@/hooks/use-is-mobile"; import { cn } from "@/lib/utils"; +import { ChevronRight } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; interface ContributionDay { @@ -44,6 +46,9 @@ const MONTH_LABEL_MIN_GAP_PX = 8; const MONTH_LABEL_MIN_SPACING_PX = 24 + MONTH_LABEL_MIN_GAP_PX; const TOOLTIP_EDGE_PADDING_PX = 8; const FALLBACK_TOOLTIP_WIDTH_PX = 120; +const FALLBACK_TOOLTIP_HEIGHT_PX = 56; +const TOOLTIP_GAP_PX = 8; +const HELD_TOOLTIP_GAP_PX = 14; function getMonthFromDate(date: string): number { const parts = date.split("-"); @@ -56,17 +61,86 @@ function getMonthFromDate(date: string): number { return parsed.getUTCMonth(); } +function getYearFromDate(date: string): number { + const parts = date.split("-"); + if (parts.length >= 1) { + const year = Number(parts[0]); + if (year >= 1970 && year <= 9999) return year; + } + const parsed = new Date(date); + if (Number.isNaN(parsed.getTime())) return new Date().getUTCFullYear(); + return parsed.getUTCFullYear(); +} + export function ContributionChart({ data }: { data: ContributionData }) { + const isMobile = useIsMobile(); const [hovered, setHovered] = useState(null); const [tooltipX, setTooltipX] = useState(0); + const [tooltipY, setTooltipY] = useState(0); + const [halfYear, setHalfYear] = useState<"h1" | "h2">("h1"); + const [isHalfTransitioning, setIsHalfTransitioning] = useState(false); + const [heldCell, setHeldCell] = useState<{ + date: string; + weekIndex: number; + dayIndex: number; + } | null>(null); const scrollContainerRef = useRef(null); const hoveredCellRef = useRef(null); const tooltipRef = useRef(null); + const activePointerIdRef = useRef(null); + const halfTransitionTimeoutRef = useRef(null); + + const targetYear = useMemo(() => { + const counts = new Map(); + + for (const week of data.weeks) { + for (const day of week.contributionDays) { + const year = getYearFromDate(day.date); + counts.set(year, (counts.get(year) ?? 0) + 1); + } + } + + let bestYear = new Date().getUTCFullYear(); + let bestCount = -1; + for (const [year, count] of counts) { + if (count > bestCount) { + bestCount = count; + bestYear = year; + } + } + + return bestYear; + }, [data.weeks]); + + const visibleWeeks = useMemo(() => { + if (isMobile !== true) return data.weeks; + + const weeks = data.weeks.filter((week) => + week.contributionDays.some((day) => { + const year = getYearFromDate(day.date); + if (year !== targetYear) return false; + const month = getMonthFromDate(day.date); + return halfYear === "h1" ? month <= 5 : month >= 6; + }), + ); + + return weeks.length > 0 ? weeks : data.weeks; + }, [data.weeks, halfYear, isMobile, targetYear]); + + const dayByDate = useMemo(() => { + const map = new Map(); + for (const week of visibleWeeks) { + for (const day of week.contributionDays) { + map.set(day.date, day); + } + } + return map; + }, [visibleWeeks]); const monthPositions = useMemo(() => { const positions: { label: string; col: number }[] = []; let last = -1; - data.weeks.forEach((week, i) => { + visibleWeeks.forEach((week, i) => { const d = week.contributionDays[0]; if (d) { const m = getMonthFromDate(d.date); @@ -77,7 +151,7 @@ export function ContributionChart({ data }: { data: ContributionData }) { } }); return positions; - }, [data.weeks]); + }, [visibleWeeks]); const visibleMonthPositions = useMemo(() => { const candidates = monthPositions.filter((month, index, all) => { @@ -98,7 +172,7 @@ export function ContributionChart({ data }: { data: ContributionData }) { }); }, [monthPositions]); - const updateTooltipPosition = useCallback((cell: HTMLDivElement) => { + const updateTooltipPosition = useCallback((cell: HTMLDivElement, held = false) => { const parent = scrollContainerRef.current; if (!parent) return; @@ -112,18 +186,143 @@ export function ContributionChart({ data }: { data: ContributionData }) { if (minX >= maxX) { setTooltipX(parent.clientWidth / 2); + const tooltipHeight = + tooltipRef.current?.offsetHeight ?? FALLBACK_TOOLTIP_HEIGHT_PX; + const rawY = + cellRect.top - + parentRect.top - + tooltipHeight - + (held ? HELD_TOOLTIP_GAP_PX : TOOLTIP_GAP_PX); + setTooltipY(rawY); return; } setTooltipX(Math.min(Math.max(rawX, minX), maxX)); + const tooltipHeight = + tooltipRef.current?.offsetHeight ?? FALLBACK_TOOLTIP_HEIGHT_PX; + const rawY = + cellRect.top - + parentRect.top - + tooltipHeight - + (held ? HELD_TOOLTIP_GAP_PX : TOOLTIP_GAP_PX); + setTooltipY(rawY); }, []); + const clearHeldCell = useCallback(() => { + activePointerIdRef.current = null; + setHeldCell(null); + hoveredCellRef.current = null; + setHovered(null); + }, []); + + const setHeldFromPointer = useCallback( + ( + day: ContributionDay, + weekIndex: number, + dayIndex: number, + element: HTMLDivElement, + ) => { + hoveredCellRef.current = element; + setHeldCell({ + date: day.date, + weekIndex, + dayIndex, + }); + setHovered(day); + updateTooltipPosition(element, true); + }, + [updateTooltipPosition], + ); + + const getHeldCellTransform = useCallback( + (weekIndex: number, dayIndex: number, date: string) => { + if (!heldCell) return null; + + if (heldCell.date === date) { + return { + zIndex: 30, + }; + } + + const dx = weekIndex - heldCell.weekIndex; + const dy = dayIndex - heldCell.dayIndex; + const distance = Math.max(Math.abs(dx), Math.abs(dy)); + + if (distance > 2 || distance === 0) return null; + + const push = distance === 1 ? 3 : 1.5; + const translateX = dx === 0 ? 0 : Math.sign(dx) * push; + const translateY = dy === 0 ? 0 : Math.sign(dy) * push; + + return { + transform: `translate3d(${translateX}px, ${translateY}px, 0)`, + zIndex: distance === 1 ? 20 : 10, + }; + }, + [heldCell], + ); + + const updateHeldFromPoint = useCallback( + (clientX: number, clientY: number) => { + const target = document.elementFromPoint(clientX, clientY); + if (!(target instanceof HTMLElement)) return; + + const cell = target.closest("[data-contrib-cell='true']"); + if (!cell) return; + + const date = cell.dataset.date; + const weekIndex = Number(cell.dataset.weekIndex); + const dayIndex = Number(cell.dataset.dayIndex); + + if (!date || Number.isNaN(weekIndex) || Number.isNaN(dayIndex)) return; + + const day = dayByDate.get(date); + if (!day) return; + + hoveredCellRef.current = cell; + setHeldCell((current) => { + if ( + current?.date === date && + current.weekIndex === weekIndex && + current.dayIndex === dayIndex + ) { + return current; + } + return { + date, + weekIndex, + dayIndex, + }; + }); + setHovered((current) => (current?.date === day.date ? current : day)); + updateTooltipPosition(cell, true); + }, + [dayByDate, updateTooltipPosition], + ); + + const toggleHalfYear = useCallback(() => { + if (isMobile !== true) return; + if (isHalfTransitioning) return; + + setIsHalfTransitioning(true); + if (halfTransitionTimeoutRef.current !== null) { + window.clearTimeout(halfTransitionTimeoutRef.current); + } + + halfTransitionTimeoutRef.current = window.setTimeout(() => { + setHalfYear((current) => (current === "h1" ? "h2" : "h1")); + window.requestAnimationFrame(() => { + setIsHalfTransitioning(false); + }); + }, 130); + }, [isHalfTransitioning, isMobile]); + useEffect(() => { if (!hovered || !hoveredCellRef.current) return; const update = () => { if (!hoveredCellRef.current) return; - updateTooltipPosition(hoveredCellRef.current); + updateTooltipPosition(hoveredCellRef.current, Boolean(heldCell)); }; update(); @@ -138,7 +337,64 @@ export function ContributionChart({ data }: { data: ContributionData }) { parent?.removeEventListener("scroll", update); window.removeEventListener("resize", update); }; - }, [hovered, updateTooltipPosition]); + }, [heldCell, hovered, updateTooltipPosition]); + + useEffect(() => { + clearHeldCell(); + }, [clearHeldCell, halfYear]); + + useEffect(() => { + if (!heldCell) return; + + const release = () => clearHeldCell(); + window.addEventListener("pointerup", release, { + passive: true, + }); + window.addEventListener("pointercancel", release, { + passive: true, + }); + window.addEventListener("touchend", release, { + passive: true, + }); + + return () => { + window.removeEventListener("pointerup", release); + window.removeEventListener("pointercancel", release); + window.removeEventListener("touchend", release); + }; + }, [clearHeldCell, heldCell]); + + useEffect(() => { + if (!heldCell) return; + + const body = document.body; + const html = document.documentElement; + const prevBodyOverflow = body.style.overflow; + const prevHtmlOverflow = html.style.overflow; + const prevBodyTouchAction = body.style.touchAction; + const prevHtmlTouchAction = html.style.touchAction; + + body.style.overflow = "hidden"; + html.style.overflow = "hidden"; + body.style.touchAction = "none"; + html.style.touchAction = "none"; + + const preventTouchMove = (event: TouchEvent) => { + event.preventDefault(); + }; + + document.addEventListener("touchmove", preventTouchMove, { + passive: false, + }); + + return () => { + body.style.overflow = prevBodyOverflow; + html.style.overflow = prevHtmlOverflow; + body.style.touchAction = prevBodyTouchAction; + html.style.touchAction = prevHtmlTouchAction; + document.removeEventListener("touchmove", preventTouchMove); + }; + }, [heldCell]); return (
@@ -158,7 +414,7 @@ export function ContributionChart({ data }: { data: ContributionData }) {
@@ -172,29 +428,55 @@ export function ContributionChart({ data }: { data: ContributionData }) {
{hovered && ( -
-
-
- - { - hovered.contributionCount - } - {" "} +
+
+
+ {hovered.contributionCount} +
+
contribution {hovered.contributionCount !== 1 ? "s" : ""}
-
+
{new Date( hovered.date, ).toLocaleDateString( @@ -207,17 +489,25 @@ export function ContributionChart({ data }: { data: ContributionData }) { )}
+ {heldCell && ( + + )}
)}
-
+
{/* Day labels column */}
{DAYS.map((day, i) => ( @@ -236,7 +526,7 @@ export function ContributionChart({ data }: { data: ContributionData }) {
{/* Grid column */} -
+
{/* Month labels — absolutely positioned so they don't clip */}
{visibleMonthPositions.map((m) => ( @@ -257,34 +547,49 @@ export function ContributionChart({ data }: { data: ContributionData }) { {/* Cells */}
- {data.weeks.map((week, wi) => ( + {visibleWeeks.map((week, wi) => (
{week.contributionDays.map( - (day) => ( + ( + day, + di, + ) => (
{ + if ( + heldCell + ) + return; hoveredCellRef.current = e.currentTarget; setHovered( @@ -295,12 +600,92 @@ export function ContributionChart({ data }: { data: ContributionData }) { ); }} onMouseLeave={() => { + if ( + heldCell + ) + return; hoveredCellRef.current = null; setHovered( null, ); }} + onPointerDown={( + e, + ) => { + if ( + e.pointerType === + "mouse" + ) + return; + activePointerIdRef.current = + e.pointerId; + e.currentTarget.setPointerCapture( + e.pointerId, + ); + e.preventDefault(); + setHeldFromPointer( + day, + wi, + di, + e.currentTarget, + ); + }} + onPointerMove={( + e, + ) => { + if ( + e.pointerType === + "mouse" + ) + return; + if ( + activePointerIdRef.current !== + e.pointerId + ) + return; + e.preventDefault(); + updateHeldFromPoint( + e.clientX, + e.clientY, + ); + }} + onPointerUp={( + e, + ) => { + if ( + e.pointerType === + "mouse" + ) + return; + if ( + activePointerIdRef.current !== + e.pointerId + ) + return; + clearHeldCell(); + }} + onPointerCancel={ + clearHeldCell + } + onLostPointerCapture={ + clearHeldCell + } + onContextMenu={( + e, + ) => + e.preventDefault() + } + data-contrib-cell="true" + data-date={ + day.date + } + data-week-index={ + wi + } + data-day-index={ + di + } /> ), )} @@ -311,6 +696,27 @@ export function ContributionChart({ data }: { data: ContributionData }) {
+ + {/* Mobile half-year toggle row */} +
+ +
); } diff --git a/apps/web/src/components/gist/gist-comments.tsx b/apps/web/src/components/gist/gist-comments.tsx new file mode 100644 index 00000000..3381c2bc --- /dev/null +++ b/apps/web/src/components/gist/gist-comments.tsx @@ -0,0 +1,25 @@ +import { CommentThread } from "@/components/shared/comment-thread"; +import type { GistComment, GistDetail } from "@/lib/github-types"; + +interface GistCommentsProps { + gist: GistDetail; + comments: GistComment[]; +} + +export function GistComments({ + // `gist` is reserved for future use (e.g., to show context or allow adding comments) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + gist, + comments, +}: GistCommentsProps) { + return ( +
+
+

Comments

+
+
+ +
+
+ ); +} diff --git a/apps/web/src/components/gist/gist-files.tsx b/apps/web/src/components/gist/gist-files.tsx new file mode 100644 index 00000000..35b4142d --- /dev/null +++ b/apps/web/src/components/gist/gist-files.tsx @@ -0,0 +1,164 @@ +import { FileCode2 } from "lucide-react"; +import { CodeViewer } from "@/components/repo/code-viewer"; +import { MarkdownBlobView } from "@/components/repo/markdown-blob-view"; +import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import type { GistDetail } from "@/lib/github-types"; +import { formatBytes, getLanguageColor, getLanguageFromFilename } from "@/lib/github-utils"; + +const MARKDOWN_EXTENSIONS = new Set(["md", "mdx", "markdown", "mdown", "mkd"]); + +function isMarkdownFile(filename: string): boolean { + const ext = filename.split(".").pop()?.toLowerCase() || ""; + return MARKDOWN_EXTENSIONS.has(ext); +} + +interface GistFilesProps { + gist: GistDetail; +} + +export function GistFiles({ gist }: GistFilesProps) { + const files = Object.entries(gist.files).map(([key, file]) => ({ + key, + filename: file.filename || key, + file, + })); + + return ( +
+ {files.map(({ key, filename, file }) => { + const hasInlineContent = + file.content !== undefined && file.content !== null; + const inlineContent = file.content ?? ""; + const isMarkdown = isMarkdownFile(filename); + const language = file.language || getLanguageFromFilename(filename); + + return ( +
+
+ + + {filename} + +
+ {language && ( + + + {language} + + )} + {file.size > 0 && ( + + {formatBytes( + file.size, + )} + + )} + {file.raw_url && ( + + Raw + + )} +
+
+ +
+ {hasInlineContent ? ( + isMarkdown ? ( + + } + previewView={ +
+
+ +
+
+ } + fileSize={file.size} + lineCount={ + inlineContent.split( + "\n", + ).length + } + language={getLanguageFromFilename( + filename, + )} + content={ + inlineContent + } + filePath={filename} + filename={filename} + /> + ) : ( + + ) + ) : ( +
+

+ File content is not + available inline. +

+ {file.raw_url && ( + + Open raw + file + + )} +
+ )} +
+
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/gist/gist-header.tsx b/apps/web/src/components/gist/gist-header.tsx new file mode 100644 index 00000000..b8dbe54f --- /dev/null +++ b/apps/web/src/components/gist/gist-header.tsx @@ -0,0 +1,91 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ExternalLink, Globe, Lock, Star } from "lucide-react"; +import { GistStarButton } from "./gist-star-button"; +import type { GistDetail } from "@/lib/github-types"; +import { formatNumber } from "@/lib/utils"; +import { TimeAgo } from "@/components/ui/time-ago"; + +function getGistTitle(gist: GistDetail): string { + const firstFile = Object.values(gist.files)[0]; + return gist.description?.trim() || firstFile?.filename || "Untitled Gist"; +} + +interface GistHeaderProps { + gist: GistDetail; +} + +export function GistHeader({ gist }: GistHeaderProps) { + const title = getGistTitle(gist); + const showDescription = !!gist.description?.trim() && gist.description.trim() !== title; + + return ( +
+
+
+ {gist.owner.login} + + {gist.owner.login} + + / +

+ {title} +

+
+ + {showDescription && ( +

+ {gist.description} +

+ )} + +
+ + {gist.public ? ( + + ) : ( + + )} + {gist.public ? "Public" : "Secret"} + + {gist.stars > 0 && ( + + + {formatNumber(gist.stars)} stars + + )} + + Updated + +
+
+ + +
+ ); +} diff --git a/apps/web/src/components/gist/gist-nav.tsx b/apps/web/src/components/gist/gist-nav.tsx new file mode 100644 index 00000000..2485bd67 --- /dev/null +++ b/apps/web/src/components/gist/gist-nav.tsx @@ -0,0 +1,107 @@ +"use client"; + +import { useRef, useState, useEffect, useCallback } from "react"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; + +interface GistNavProps { + owner: string; + gistId: string; + fileCount: number; + revisionCount: number; + commentCount: number; +} + +export function GistNav({ owner, gistId, fileCount, revisionCount, commentCount }: GistNavProps) { + const pathname = usePathname(); + const base = `/${owner}/gist/${gistId}`; + const containerRef = useRef(null); + const [indicator, setIndicator] = useState({ left: 0, width: 0 }); + const [hasAnimated, setHasAnimated] = useState(false); + + const tabs = [ + { + label: "Files", + href: base, + active: pathname === base, + count: fileCount, + }, + { + label: "Revisions", + href: `${base}/revisions`, + active: pathname.startsWith(`${base}/revisions`), + count: revisionCount, + }, + { + label: "Comments", + href: `${base}/comments`, + active: pathname.startsWith(`${base}/comments`), + count: commentCount, + }, + ]; + + const updateIndicator = useCallback(() => { + if (!containerRef.current) return; + const activeEl = + containerRef.current.querySelector("[data-active='true']"); + if (activeEl) { + setIndicator({ + left: activeEl.offsetLeft, + width: activeEl.offsetWidth, + }); + activeEl.scrollIntoView({ + block: "nearest", + inline: "center", + behavior: "smooth", + }); + if (!hasAnimated) setHasAnimated(true); + } + }, [hasAnimated]); + + useEffect(() => { + updateIndicator(); + }, [pathname, updateIndicator]); + + return ( +
+ {tabs.map((tab) => ( + + {tab.label} + {tab.count > 0 && ( + + {tab.count} + + )} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/gist/gist-revisions.tsx b/apps/web/src/components/gist/gist-revisions.tsx new file mode 100644 index 00000000..c15d588c --- /dev/null +++ b/apps/web/src/components/gist/gist-revisions.tsx @@ -0,0 +1,57 @@ +import { History } from "lucide-react"; +import { TimeAgo } from "@/components/ui/time-ago"; +import type { GistDetail } from "@/lib/github-types"; + +interface GistRevisionsProps { + gist: GistDetail; +} + +export function GistRevisions({ gist }: GistRevisionsProps) { + return ( +
+ {gist.history.length > 0 ? ( + + ) : ( +
+ No revision history available +
+ )} +
+ ); +} diff --git a/apps/web/src/components/gist/gist-star-button.tsx b/apps/web/src/components/gist/gist-star-button.tsx new file mode 100644 index 00000000..2920a4e5 --- /dev/null +++ b/apps/web/src/components/gist/gist-star-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Star } from "lucide-react"; +import { starGist, unstarGist } from "@/app/(app)/repos/[owner]/gist/actions"; +import { cn, formatNumber } from "@/lib/utils"; + +interface GistStarButtonProps { + gistId: string; + starred: boolean; + starCount: number; +} + +export function GistStarButton({ gistId, starred, starCount }: GistStarButtonProps) { + const [isStarred, setIsStarred] = useState(starred); + const [count, setCount] = useState(starCount); + const [isPending, startTransition] = useTransition(); + + const toggle = () => { + const next = !isStarred; + setIsStarred(next); + setCount((c) => c + (next ? 1 : -1)); + startTransition(async () => { + const res = next ? await starGist(gistId) : await unstarGist(gistId); + if (res.error) { + setIsStarred(!next); + setCount((c) => c + (next ? -1 : 1)); + } + }); + }; + + return ( + + ); +} diff --git a/apps/web/src/components/shared/github-link-interceptor.tsx b/apps/web/src/components/shared/github-link-interceptor.tsx index a147844a..944cbefe 100644 --- a/apps/web/src/components/shared/github-link-interceptor.tsx +++ b/apps/web/src/components/shared/github-link-interceptor.tsx @@ -27,7 +27,8 @@ export function GitHubLinkInterceptor({ children }: { children: React.ReactNode // Only intercept github.com links try { const url = new URL(href); - if (url.hostname !== "github.com") return; + const host = url.hostname.toLowerCase(); + if (host !== "github.com" && host !== "gist.github.com") return; } catch { return; } diff --git a/apps/web/src/components/users/user-profile-content.tsx b/apps/web/src/components/users/user-profile-content.tsx index 3ae2c98d..242bf631 100644 --- a/apps/web/src/components/users/user-profile-content.tsx +++ b/apps/web/src/components/users/user-profile-content.tsx @@ -6,9 +6,10 @@ import { XIcon } from "@/components/shared/icons/x-icon"; import { TimeAgo } from "@/components/ui/time-ago"; import { UserProfileActivityTimelineBoundary } from "@/components/users/user-profile-activity-timeline-boundary"; import { UserProfileActivityTimeline } from "@/components/users/user-profile-activity-timeline"; +import { UserProfileGists } from "@/components/users/user-profile-gists"; import { UserProfileScoreRing } from "@/components/users/user-profile-score-ring"; import { getLanguageColor } from "@/lib/github-utils"; -import type { ActivityEvent } from "@/lib/github-types"; +import type { ActivityEvent, UserGist } from "@/lib/github-types"; import { computeUserProfileScore } from "@/lib/user-profile-score"; import { cn, formatNumber } from "@/lib/utils"; import { @@ -18,6 +19,7 @@ import { CalendarDays, ChevronRight, ExternalLink, + FileCode, FolderGit2, GitFork, Link2, @@ -32,6 +34,7 @@ import Link from "next/link"; import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +// !TODO: Last item in languages row should take up remaining space on mobile for a cleaner look export interface UserProfile { login: string; name: string | null; @@ -90,7 +93,11 @@ const filterTypes = ["all", "sources", "forks", "archived"] as const; const sortTypes = ["updated", "name", "stars"] as const; -const tabTypes = ["repositories", "activity"] as const; +const tabTypes = ["repositories", "activity", "gists"] as const; + +const gistFilterTypes = ["all", "public", "secret", "starred"] as const; + +const gistSortTypes = ["updated", "stars", "created"] as const; function formatJoinedDate(value: string | null): string | null { if (!value) return null; @@ -117,6 +124,8 @@ export function UserProfileContent({ contributions, activityEvents = [], orgTopRepos = [], + gists = [], + starredGists = [], }: { user: UserProfile; repos: UserRepo[]; @@ -124,6 +133,8 @@ export function UserProfileContent({ contributions: ContributionData | null; activityEvents?: ActivityEvent[]; orgTopRepos?: OrgTopRepo[]; + gists?: UserGist[]; + starredGists?: UserGist[]; }) { const [tab, setTab] = useQueryState( "tab", @@ -142,6 +153,17 @@ export function UserProfileContent({ const [showMoreLanguages, setShowMoreLanguages] = useState(false); const [selectedYear, setSelectedYear] = useState(null); + // Gists-specific states + const [gistSearch, setGistSearch] = useQueryState("gist_q", parseAsString.withDefault("")); + const [gistFilter, setGistFilter] = useQueryState( + "gist_filter", + parseAsStringLiteral(gistFilterTypes).withDefault("all"), + ); + const [gistSort, setGistSort] = useQueryState( + "gist_sort", + parseAsStringLiteral(gistSortTypes).withDefault("updated"), + ); + const currentYear = new Date().getFullYear(); const activeYear = selectedYear ?? currentYear; @@ -403,6 +425,57 @@ export function UserProfileContent({ setShowMoreLanguages(false); }, [setFilter, setSearch]); + // Gists filtering and sorting + const filteredGists = useMemo(() => { + // Use starred gists when filter is "starred", otherwise use user's gists + const sourceGists = gistFilter === "starred" ? starredGists : gists; + + return sourceGists + .filter((gist) => { + if ( + gistSearch && + ![ + gist.description || "", + ...Object.values(gist.files).map( + (f: { filename: string }) => f.filename, + ), + ] + .join(" ") + .toLowerCase() + .includes(gistSearch.toLowerCase()) + ) { + return false; + } + // Only apply public/secret filters when not viewing starred gists + if (gistFilter !== "starred") { + if (gistFilter === "public" && !gist.public) return false; + if (gistFilter === "secret" && gist.public) return false; + } + return true; + }) + .sort((a, b) => { + if (gistSort === "stars") { + const starsDiff = (b.stars ?? 0) - (a.stars ?? 0); + if (starsDiff !== 0) return starsDiff; + } + if (gistSort === "created") { + return ( + new Date(b.created_at).getTime() - + new Date(a.created_at).getTime() + ); + } + return ( + new Date(b.updated_at).getTime() - + new Date(a.updated_at).getTime() + ); + }); + }, [gists, starredGists, gistSearch, gistFilter, gistSort]); + + const clearGistFilters = useCallback(() => { + setGistSearch(""); + setGistFilter("all"); + }, [setGistSearch, setGistFilter]); + const toggleLanguageFilter = useCallback((language: string) => { setLanguageFilter((current) => (current === language ? null : language)); setShowMoreLanguages(false); @@ -474,7 +547,7 @@ export function UserProfileContent({ return (
{/* ── Left sidebar ── */} -
); diff --git a/apps/web/src/components/users/user-profile-gists.tsx b/apps/web/src/components/users/user-profile-gists.tsx new file mode 100644 index 00000000..ac3af07c --- /dev/null +++ b/apps/web/src/components/users/user-profile-gists.tsx @@ -0,0 +1,133 @@ +"use client"; + +import { TimeAgo } from "@/components/ui/time-ago"; +import { getLanguageColor } from "@/lib/github-utils"; +import type { UserGist } from "@/lib/github-types"; +import { ChevronRight, FileCode, MessageSquare } from "lucide-react"; +import Link from "next/link"; + +interface UserProfileGistsProps { + gists: UserGist[]; + ownerLogin?: string; +} + +export function UserProfileGists({ gists, ownerLogin }: UserProfileGistsProps) { + if (gists.length === 0) { + return ( +
+ +

+ No gists found +

+
+ ); + } + + return ( +
+ {gists.map((gist) => { + const fileCount = Object.keys(gist.files).length; + const fileList = Object.values(gist.files); + const firstFile = fileList[0]; + const languages = [ + ...new Set( + fileList + .map((f) => f.language) + .filter((l): l is string => Boolean(l)), + ), + ]; + + return ( + + +
+
+ + {gist.description || + firstFile?.filename || + "Untitled"} + + {!gist.public && ( + + Secret + + )} +
+ + {gist.description && + firstFile?.filename && ( +

+ {firstFile.filename} +

+ )} + +
+ + + {fileCount} file + {fileCount !== 1 ? "s" : ""} + + + {gist.comments > 0 && ( + + + {gist.comments} + + )} + + {languages.length > 0 && ( +
+ {languages + .slice(0, 3) + .map( + ( + lang, + ) => ( + + + { + lang + } + + ), + )} +
+ )} + + + + +
+
+ + + ); + })} +
+ ); +} diff --git a/apps/web/src/lib/github-types.ts b/apps/web/src/lib/github-types.ts index 8c478f7e..d359b83d 100644 --- a/apps/web/src/lib/github-types.ts +++ b/apps/web/src/lib/github-types.ts @@ -177,3 +177,49 @@ export interface SearchResult { items: Array; total_count: number; } + +export interface UserGist { + id: string; + description: string | null; + html_url: string; + public: boolean; + created_at: string; + updated_at: string; + stars: number; + files: Record< + string, + { + filename: string; + type: string; + language: string | null; + size: number; + content?: string | null; + raw_url?: string; + } + >; + comments: number; +} + +export interface GistDetail extends UserGist { + owner: { + login: string; + avatar_url: string; + }; + history: Array<{ + version: string; + committed_at: string; + }>; + viewerHasStarred: boolean; +} + +export interface GistComment { + id: number; + body: string; + created_at: string; + updated_at: string; + user: { + login: string; + avatar_url: string; + } | null; + author_association?: string; +} diff --git a/apps/web/src/lib/github-utils.ts b/apps/web/src/lib/github-utils.ts index bdf490f2..cca80a7f 100644 --- a/apps/web/src/lib/github-utils.ts +++ b/apps/web/src/lib/github-utils.ts @@ -111,6 +111,11 @@ export function toInternalUrl(htmlUrl: string): string { if (parsed.type === "user") return `/users/${parsed.owner}`; if (parsed.type === "stars") return parsed.username ? `/stars/${parsed.username}` : "/stars"; + if (parsed.type === "gist") { + return parsed.owner + ? `/${parsed.owner}/gist/${parsed.gistId}` + : `/gists/${parsed.gistId}`; + } const base = `/${parsed.owner}/${parsed.repo}`; @@ -227,6 +232,11 @@ type ParsedGitHubUrl = | { type: "stars"; username?: string; + } + | { + type: "gist"; + gistId: string; + owner?: string; }; function parsePositiveInt(value: string | undefined): number | null { @@ -236,14 +246,37 @@ function parsePositiveInt(value: string | undefined): number | null { return parsed > 0 && parsed <= Number.MAX_SAFE_INTEGER ? parsed : null; } +function parseGistId(value: string | undefined): string | null { + if (!value) return null; + if (!/^[a-zA-Z0-9]{5,}$/.test(value)) return null; + return value; +} + export function parseGitHubUrl(htmlUrl: string): ParsedGitHubUrl | null { try { const url = new URL(htmlUrl); - if (url.hostname !== "github.com") return null; + const host = url.hostname.toLowerCase(); const parts = url.pathname.split("/").filter(Boolean); if (parts.length === 0) return null; + if (host === "gist.github.com") { + if (parts.length === 1) { + const gistId = parseGistId(parts[0]); + return gistId ? { type: "gist", gistId } : null; + } + + const gistId = parseGistId(parts[1]); + if (!gistId) return null; + return { + type: "gist", + gistId, + owner: parts[0], + }; + } + + if (host !== "github.com") return null; + if (parts[0].toLowerCase() === "stars") { if (parts.length === 1) return { type: "stars" }; if (parts.length === 2) return { type: "stars", username: parts[1] }; diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts index c13ada3c..fe908928 100644 --- a/apps/web/src/lib/github.ts +++ b/apps/web/src/lib/github.ts @@ -2,6 +2,7 @@ import { Octokit } from "@octokit/rest"; import { headers } from "next/headers"; import { cache } from "react"; import { $Session, getServerSession } from "./auth"; +import type { UserGist, GistDetail, GistComment } from "./github-types"; import { claimDueGithubSyncJobs, deleteGithubCacheByPrefix, @@ -86,6 +87,9 @@ type GitDataSyncJobType = | "user_profile" | "user_public_repos" | "user_public_orgs" + | "user_gists" + | "user_starred_gists" + | "gist" | "repo_workflows" | "repo_workflow_runs" | "repo_nav_counts" @@ -118,6 +122,7 @@ const SHAREABLE_CACHE_TYPES: ReadonlySet = new Set([ "user_profile", "user_public_repos", "user_public_orgs", + "user_gists", "user_events", "org", "org_repos", @@ -149,6 +154,7 @@ interface GitDataSyncJobPayload { language?: string; since?: "daily" | "weekly" | "monthly"; openIssuesAndPrs?: number; + gistId?: string; } interface LocalFirstGitReadOptions { @@ -420,6 +426,18 @@ function buildUserPublicOrgsCacheKey(username: string): string { return `user_public_orgs:${username.toLowerCase()}`; } +function buildUserGistsCacheKey(username: string, perPage: number): string { + return `user_gists:${username.toLowerCase()}:${perPage}`; +} + +function buildUserStarredGistsCacheKey(perPage: number): string { + return `user_starred_gists:${perPage}`; +} + +function buildGistCacheKey(gistId: string): string { + return `gist:${gistId}`; +} + function buildRepoWorkflowsCacheKey(owner: string, repo: string): string { return `repo_workflows:${normalizeRepoKey(owner, repo)}`; } @@ -628,7 +646,7 @@ async function fetchOrgFromGitHub(octokit: Octokit, org: string) { try { const { data } = await octokit.orgs.get({ org }); return data; - } catch (error) { + } catch { // 404 return null; } @@ -1540,6 +1558,196 @@ async function fetchUserPublicOrgsFromGitHub(octokit: Octokit, username: string) return data; } +async function enrichGistStarsFromGraphQL( + token: string | null | undefined, + gists: UserGist[], +): Promise { + const normalized = gists.map((gist) => ({ + ...gist, + stars: gist.stars ?? 0, + })); + if (!token || normalized.length === 0) return normalized; + + const starCountByGistId = new Map(); + const chunkSize = 50; + + for (let offset = 0; offset < normalized.length; offset += chunkSize) { + const chunk = normalized + .slice(offset, offset + chunkSize) + .filter((gist) => gist.id.trim().length > 0); + if (chunk.length === 0) continue; + + const aliases = chunk.map( + (_gist, i) => `g${i}: gist(name: $id${i}) { stargazerCount }`, + ); + const variableDefinitions = chunk.map((_gist, i) => `$id${i}: String!`).join(", "); + const variables = Object.fromEntries(chunk.map((gist, i) => [`id${i}`, gist.id])); + const query = `query(${variableDefinitions}) { ${aliases.join("\n")} }`; + + try { + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + signal: AbortSignal.timeout(8_000), + }); + if (!response.ok) continue; + + const json = (await response.json()) as { + data?: Record; + }; + if (!json.data) continue; + + for (let i = 0; i < chunk.length; i++) { + const stars = json.data[`g${i}`]?.stargazerCount; + if (typeof stars === "number" && Number.isFinite(stars)) { + starCountByGistId.set(chunk[i].id, Math.max(0, stars)); + } + } + } catch { + continue; + } + } + + if (starCountByGistId.size === 0) return normalized; + + return normalized.map((gist) => ({ + ...gist, + stars: starCountByGistId.get(gist.id) ?? gist.stars ?? 0, + })); +} + +// !chore: clean this up - I don't like the way two calls are made +async function fetchUserGistsFromGitHub( + octokit: Octokit, + username: string, + perPage: number, + token?: string | null, +): Promise { + const { data } = await octokit.gists.listForUser({ + username, + per_page: perPage, + }); + const mapped = data.map((gist) => ({ + id: gist.id, + description: gist.description, + html_url: gist.html_url, + public: gist.public, + created_at: gist.created_at, + updated_at: gist.updated_at, + stars: 0, + files: Object.fromEntries( + Object.entries(gist.files || {}).map(([key, file]) => [ + key, + { + filename: file.filename || "", + type: file.type || "", + language: file.language || null, + size: file.size || 0, + }, + ]), + ), + comments: gist.comments, + })); + + return enrichGistStarsFromGraphQL(token, mapped); +} + +async function fetchUserStarredGistsFromGitHub( + octokit: Octokit, + perPage: number, + token?: string | null, +): Promise { + const { data } = await octokit.gists.listStarred({ + per_page: perPage, + }); + const mapped = data.map((gist) => ({ + id: gist.id, + description: gist.description, + html_url: gist.html_url, + public: gist.public, + created_at: gist.created_at, + updated_at: gist.updated_at, + stars: 0, + files: Object.fromEntries( + Object.entries(gist.files || {}).map(([key, file]) => [ + key, + { + filename: file.filename || "", + type: file.type || "", + language: file.language || null, + size: file.size || 0, + }, + ]), + ), + comments: gist.comments, + })); + + return enrichGistStarsFromGraphQL(token, mapped); +} + +async function fetchGistFromGitHub( + octokit: Octokit, + gistId: string, +): Promise< + | (UserGist & { + owner: { login: string; avatar_url: string }; + history: Array<{ version: string; committed_at: string }>; + viewerHasStarred: boolean; + }) + | null +> { + try { + const [{ data }, starredResult] = await Promise.all([ + octokit.gists.get({ gist_id: gistId }), + octokit.gists + .checkIsStarred({ gist_id: gistId }) + .catch(() => ({ status: 404 })), + ]); + return { + id: data.id || gistId, + description: data.description ?? null, + html_url: data.html_url || "", + public: data.public ?? false, + created_at: data.created_at || "", + updated_at: data.updated_at || "", + stars: 0, + files: Object.fromEntries( + Object.entries(data.files || {}) + .filter(([, file]) => file != null) + .map(([key, file]) => [ + key, + { + filename: file?.filename || "", + type: file?.type || "", + language: file?.language ?? null, + size: file?.size || 0, + content: file?.content ?? null, + raw_url: file?.raw_url || "", + }, + ]), + ), + comments: data.comments || 0, + owner: { + login: data.owner?.login || "", + avatar_url: data.owner?.avatar_url || "", + }, + history: + data.history?.map((h) => ({ + version: h.version || "", + committed_at: h.committed_at || "", + })) || [], + viewerHasStarred: starredResult.status === 204, + }; + } catch (error) { + console.error("[fetchGistFromGitHub] Error fetching gist:", error); + return null; + } +} + async function fetchUserOrgTopReposFromGitHub( octokit: Octokit, orgLogins: string[], @@ -1802,6 +2010,51 @@ async function processGitDataSyncJob( ); return; } + case "user_gists": { + if (!payload.username) return; + const perPage = payload.perPage ?? 30; + const data = await fetchUserGistsFromGitHub( + authCtx.octokit, + payload.username, + perPage, + authCtx.token, + ); + await upsertCacheWithShared( + authCtx.userId, + buildUserGistsCacheKey(payload.username, perPage), + "user_gists", + data, + ); + return; + } + case "user_starred_gists": { + const perPage = payload.perPage ?? 30; + const data = await fetchUserStarredGistsFromGitHub( + authCtx.octokit, + perPage, + authCtx.token, + ); + await upsertCacheWithShared( + authCtx.userId, + buildUserStarredGistsCacheKey(perPage), + "user_starred_gists", + data, + ); + return; + } + case "gist": { + if (!payload.gistId) return; + const data = await fetchGistFromGitHub(authCtx.octokit, payload.gistId); + if (data) { + await upsertCacheWithShared( + authCtx.userId, + buildGistCacheKey(payload.gistId), + "gist", + data, + ); + } + return; + } case "starred_repos": { const perPage = payload.perPage ?? 10; const data = await fetchStarredReposFromGitHub(authCtx.octokit, perPage); @@ -5910,26 +6163,6 @@ function mapDependabotAlert(alert: unknown): DependabotAlertSummary { }; } -function mapCodeScanningAlert(alert: unknown): CodeScanningAlertSummary { - const row = asRecord(alert); - const rule = asRecord(row?.rule); - const tool = asRecord(row?.tool); - const instance = asRecord(row?.most_recent_instance); - const location = asRecord(instance?.location); - - return { - number: asNumber(row?.number) ?? 0, - state: asString(row?.state) ?? "unknown", - severity: asString(rule?.severity) ?? asString(rule?.security_severity_level), - ruleId: asString(rule?.id), - ruleDescription: asString(rule?.description) ?? asString(rule?.name), - toolName: asString(tool?.name), - path: asString(location?.path), - createdAt: asString(row?.created_at) ?? "", - htmlUrl: asString(row?.html_url) ?? "", - }; -} - function mapSecretScanningAlert(alert: unknown): SecretScanningAlertSummary { const row = asRecord(alert); @@ -6382,6 +6615,84 @@ export async function getUserPublicOrgs(username: string) { }); } +export async function getUserGists(username: string, perPage = 30) { + const authCtx = await getGitHubAuthContext(); + return readLocalFirstGitData({ + authCtx, + cacheKey: buildUserGistsCacheKey(username, perPage), + cacheType: "user_gists", + fallback: [], + jobType: "user_gists", + jobPayload: { username, perPage }, + fetchRemote: (octokit) => + fetchUserGistsFromGitHub( + octokit, + username, + perPage, + authCtx?.token ?? null, + ), + }); +} + +export async function getUserStarredGists(perPage = 30) { + const authCtx = await getGitHubAuthContext(); + return readLocalFirstGitData({ + authCtx, + cacheKey: buildUserStarredGistsCacheKey(perPage), + cacheType: "user_starred_gists", + fallback: [], + jobType: "user_starred_gists", + jobPayload: { perPage }, + fetchRemote: (octokit) => + fetchUserStarredGistsFromGitHub(octokit, perPage, authCtx?.token ?? null), + }); +} + +export async function getGist(gistId: string): Promise { + const authCtx = await getGitHubAuthContext(); + + return readLocalFirstGitData({ + authCtx, + cacheKey: buildGistCacheKey(gistId), + cacheType: "gist", + fallback: null, + jobType: "gist", + jobPayload: { gistId }, + fetchRemote: async (octokit) => { + const result = await fetchGistFromGitHub(octokit, gistId); + return result; + }, + }); +} + +export async function getGistComments(gistId: string): Promise { + const octokit = await getOctokit(); + if (!octokit) return []; + + try { + const { data } = await octokit.gists.listComments({ + gist_id: gistId, + per_page: 100, + }); + + return data.map((comment) => ({ + id: comment.id, + body: comment.body ?? "", + created_at: comment.created_at ?? "", + updated_at: comment.updated_at ?? "", + user: comment.user + ? { + login: comment.user.login ?? "", + avatar_url: comment.user.avatar_url ?? "", + } + : null, + author_association: comment.author_association ?? undefined, + })); + } catch { + return []; + } +} + export async function getUserOrgTopRepos(orgLogins: string[]) { if (orgLogins.length === 0) return []; const authCtx = await getGitHubAuthContext(); diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 12037899..71cd0a71 100644 --- a/apps/web/src/proxy.ts +++ b/apps/web/src/proxy.ts @@ -18,6 +18,7 @@ const APP_ROUTES = new Set([ "api", "debug", "_next", + "gists", ]); export default async function middleware(request: NextRequest) { @@ -49,7 +50,6 @@ export default async function middleware(request: NextRequest) { const owner = segments[0]; const repo = segments[1]; const rest = segments.slice(2); - // /:owner/:repo/pull/:number → /repos/:owner/:repo/pulls/:number if (rest[0] === "pull" && rest[1]) { const url = request.nextUrl.clone(); @@ -89,6 +89,13 @@ export default async function middleware(request: NextRequest) { } } + // /:owner/gist/:gistId(/...) → /repos/:owner/gist/:gistId(/...) + if (repo === "gist" && rest[0]) { + const url = request.nextUrl.clone(); + url.pathname = `/repos/${owner}/gist/${rest.join("/")}`; + return NextResponse.rewrite(url); + } + // Generic: /:owner/:repo/... → /repos/:owner/:repo/... const url = request.nextUrl.clone(); url.pathname = `/repos/${segments.join("/")}`;