From 738e999a28908b0da0e8e4788d41df91597ab856 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Wed, 4 Mar 2026 23:42:31 +0300 Subject: [PATCH 01/19] use bunx instead of npx --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From f6b9814770796dc49e847ccf0f852161a4de927e Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Thu, 5 Mar 2026 00:51:30 +0300 Subject: [PATCH 02/19] fix: improve mobile responsiveness for user profile page --- .../dashboard/contribution-chart.tsx | 2 +- .../components/users/user-profile-content.tsx | 257 ++++++++++++------ 2 files changed, 175 insertions(+), 84 deletions(-) diff --git a/apps/web/src/components/dashboard/contribution-chart.tsx b/apps/web/src/components/dashboard/contribution-chart.tsx index df27d8e4..b052dbb4 100644 --- a/apps/web/src/components/dashboard/contribution-chart.tsx +++ b/apps/web/src/components/dashboard/contribution-chart.tsx @@ -210,7 +210,7 @@ export function ContributionChart({ data }: { data: ContributionData }) { )} -
+
{/* ── 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..3dcc980a --- /dev/null +++ b/apps/web/src/components/users/user-profile-gists.tsx @@ -0,0 +1,147 @@ +"use client"; + +import { TimeAgo } from "@/components/ui/time-ago"; +import { getLanguageColor } from "@/lib/github-utils"; +import { ChevronRight, FileCode, MessageSquare } from "lucide-react"; + +export interface UserGist { + id: string; + description: string | null; + html_url: string; + public: boolean; + created_at: string; + updated_at: string; + files: Record< + string, + { + filename: string; + type: string; + language: string | null; + size: number; + } + >; + comments: number; +} + +interface UserProfileGistsProps { + gists: UserGist[]; +} + +export function UserProfileGists({ gists }: 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..bbca85d0 100644 --- a/apps/web/src/lib/github-types.ts +++ b/apps/web/src/lib/github-types.ts @@ -177,3 +177,22 @@ 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; + files: Record< + string, + { + filename: string; + type: string; + language: string | null; + size: number; + } + >; + comments: number; +} diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts index c13ada3c..e66f46f9 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 } from "./github-types"; import { claimDueGithubSyncJobs, deleteGithubCacheByPrefix, @@ -86,6 +87,8 @@ type GitDataSyncJobType = | "user_profile" | "user_public_repos" | "user_public_orgs" + | "user_gists" + | "user_starred_gists" | "repo_workflows" | "repo_workflow_runs" | "repo_nav_counts" @@ -118,6 +121,8 @@ const SHAREABLE_CACHE_TYPES: ReadonlySet = new Set([ "user_profile", "user_public_repos", "user_public_orgs", + "user_gists", + "user_starred_gists", "user_events", "org", "org_repos", @@ -420,6 +425,14 @@ 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 buildRepoWorkflowsCacheKey(owner: string, repo: string): string { return `repo_workflows:${normalizeRepoKey(owner, repo)}`; } @@ -1540,6 +1553,66 @@ async function fetchUserPublicOrgsFromGitHub(octokit: Octokit, username: string) return data; } +async function fetchUserGistsFromGitHub( + octokit: Octokit, + username: string, + perPage: number, +): Promise { + const { data } = await octokit.gists.listForUser({ + username, + per_page: perPage, + }); + return 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, + 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, + })); +} + +async function fetchUserStarredGistsFromGitHub( + octokit: Octokit, + perPage: number, +): Promise { + const { data } = await octokit.gists.listStarred({ + per_page: perPage, + }); + return 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, + 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, + })); +} + async function fetchUserOrgTopReposFromGitHub( octokit: Octokit, orgLogins: string[], @@ -1802,6 +1875,36 @@ 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, + ); + 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, + ); + await upsertCacheWithShared( + authCtx.userId, + buildUserStarredGistsCacheKey(perPage), + "user_starred_gists", + data, + ); + return; + } case "starred_repos": { const perPage = payload.perPage ?? 10; const data = await fetchStarredReposFromGitHub(authCtx.octokit, perPage); @@ -6382,6 +6485,32 @@ 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), + }); +} + +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), + }); +} + export async function getUserOrgTopRepos(orgLogins: string[]) { if (orgLogins.length === 0) return []; const authCtx = await getGitHubAuthContext(); From e6e4fba24d5fd0077f77117bb9baf1580272b910 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Thu, 5 Mar 2026 04:19:49 +0300 Subject: [PATCH 09/19] feat: implemented gists - Added gists tab to user profile with count badge - Implemented search and filters (All, Public, Secret, Starred) - Added sorting by Updated/Created - Created gist detail page with file viewer - Added API functions for fetching gists and starred gists - Added caching support for gist data --- apps/web/next.config.ts | 1 + apps/web/src/app/(app)/[owner]/page.tsx | 2 + .../web/src/app/(app)/gists/[gistId]/page.tsx | 45 +++ .../components/gist/gist-detail-content.tsx | 342 ++++++++++++++++++ .../shared/github-link-interceptor.tsx | 3 +- .../components/users/user-profile-content.tsx | 30 +- .../components/users/user-profile-gists.tsx | 12 +- apps/web/src/lib/github-types.ts | 14 + apps/web/src/lib/github-utils.ts | 31 +- apps/web/src/lib/github.ts | 174 ++++++++- 10 files changed, 634 insertions(+), 20 deletions(-) create mode 100644 apps/web/src/app/(app)/gists/[gistId]/page.tsx create mode 100644 apps/web/src/components/gist/gist-detail-content.tsx 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/src/app/(app)/[owner]/page.tsx b/apps/web/src/app/(app)/[owner]/page.tsx index 005b7b2e..2deddfdd 100644 --- a/apps/web/src/app/(app)/[owner]/page.tsx +++ b/apps/web/src/app/(app)/[owner]/page.tsx @@ -213,6 +213,7 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s public: gist.public, created_at: gist.created_at, updated_at: gist.updated_at, + stars: gist.stars ?? 0, files: gist.files, comments: gist.comments, }))} @@ -223,6 +224,7 @@ export default async function OwnerPage({ params }: { params: Promise<{ owner: s 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)/gists/[gistId]/page.tsx b/apps/web/src/app/(app)/gists/[gistId]/page.tsx new file mode 100644 index 00000000..fcf30439 --- /dev/null +++ b/apps/web/src/app/(app)/gists/[gistId]/page.tsx @@ -0,0 +1,45 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistDetailContent } from "@/components/gist/gist-detail-content"; +import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ 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 GistPage({ params }: { params: Promise<{ gistId: string }> }) { + const { gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/components/gist/gist-detail-content.tsx b/apps/web/src/components/gist/gist-detail-content.tsx new file mode 100644 index 00000000..6b92bb29 --- /dev/null +++ b/apps/web/src/components/gist/gist-detail-content.tsx @@ -0,0 +1,342 @@ +import Image from "next/image"; +import Link from "next/link"; +import { ExternalLink, FileCode2, Globe, History, Lock, MessageSquare, Star } 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 { TimeAgo } from "@/components/ui/time-ago"; +import type { GistDetail } from "@/lib/github-types"; +import { formatBytes, getLanguageColor, getLanguageFromFilename } from "@/lib/github-utils"; +import { formatNumber } from "@/lib/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); +} + +function getGistTitle(gist: GistDetail): string { + const firstFile = Object.values(gist.files)[0]; + return gist.description?.trim() || firstFile?.filename || "Untitled Gist"; +} + +export async function GistDetailContent({ gist }: { gist: GistDetail }) { + const files = Object.entries(gist.files).map(([key, file]) => ({ + key, + filename: file.filename || key, + file, + })); + const title = getGistTitle(gist); + const showDescription = !!gist.description?.trim() && gist.description.trim() !== title; + + return ( +
+
+
+
+
+ {gist.owner.login} + + {gist.owner.login} + + + / + +

+ {title} +

+
+ + {showDescription && ( +

+ {gist.description} +

+ )} + +
+ + + {files.length} file + {files.length !== 1 ? "s" : ""} + + + {gist.public ? ( + + ) : ( + + )} + {gist.public ? "Public" : "Secret"} + + {gist.comments > 0 && ( + + + {formatNumber( + gist.comments, + )}{" "} + comments + + )} + {gist.stars > 0 && ( + + + {formatNumber( + gist.stars, + )}{" "} + stars + + )} + + Updated{" "} + + +
+
+ + + + View on GitHub + +
+
+ +
+
+ {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/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 355b15dc..04ff351f 100644 --- a/apps/web/src/components/users/user-profile-content.tsx +++ b/apps/web/src/components/users/user-profile-content.tsx @@ -97,7 +97,7 @@ const tabTypes = ["repositories", "activity", "gists"] as const; const gistFilterTypes = ["all", "public", "secret", "starred"] as const; -const gistSortTypes = ["updated", "created"] as const; +const gistSortTypes = ["updated", "stars", "created"] as const; function formatJoinedDate(value: string | null): string | null { if (!value) return null; @@ -452,6 +452,10 @@ export function UserProfileContent({ 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() - @@ -1491,8 +1495,11 @@ export function UserProfileContent({ (current) => current === "updated" - ? "created" - : "updated", + ? "stars" + : current === + "stars" + ? "created" + : "updated", ) } className="hidden lg:flex items-center gap-1.5 px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-muted-foreground border border-border hover:text-foreground/60 hover:bg-muted/60 dark:hover:bg-white/3 transition-colors cursor-pointer rounded-sm shrink-0" @@ -1500,7 +1507,10 @@ export function UserProfileContent({ {gistSort === "updated" ? "Updated" - : "Created"} + : gistSort === + "stars" + ? "Stars" + : "Created"}
@@ -1552,8 +1562,11 @@ export function UserProfileContent({ (current) => current === "updated" - ? "created" - : "updated", + ? "stars" + : current === + "stars" + ? "created" + : "updated", ) } className="flex-1 flex items-center justify-center gap-1.5 px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-muted-foreground hover:text-foreground/60 hover:bg-muted/60 dark:hover:bg-white/3 transition-colors cursor-pointer" @@ -1561,7 +1574,10 @@ export function UserProfileContent({ {gistSort === "updated" ? "Updated" - : "Created"} + : gistSort === + "stars" + ? "Stars" + : "Created"}
diff --git a/apps/web/src/components/users/user-profile-gists.tsx b/apps/web/src/components/users/user-profile-gists.tsx index 3dcc980a..f54c9751 100644 --- a/apps/web/src/components/users/user-profile-gists.tsx +++ b/apps/web/src/components/users/user-profile-gists.tsx @@ -3,6 +3,7 @@ import { TimeAgo } from "@/components/ui/time-ago"; import { getLanguageColor } from "@/lib/github-utils"; import { ChevronRight, FileCode, MessageSquare } from "lucide-react"; +import Link from "next/link"; export interface UserGist { id: string; @@ -11,6 +12,7 @@ export interface UserGist { public: boolean; created_at: string; updated_at: string; + stars: number; files: Record< string, { @@ -54,17 +56,15 @@ export function UserProfileGists({ gists }: UserProfileGistsProps) { ]; return ( -
- + {gist.description || firstFile?.filename || "Untitled"} @@ -139,7 +139,7 @@ export function UserProfileGists({ gists }: UserProfileGistsProps) {
-
+ ); })} diff --git a/apps/web/src/lib/github-types.ts b/apps/web/src/lib/github-types.ts index bbca85d0..b66d2e8a 100644 --- a/apps/web/src/lib/github-types.ts +++ b/apps/web/src/lib/github-types.ts @@ -185,6 +185,7 @@ export interface UserGist { public: boolean; created_at: string; updated_at: string; + stars: number; files: Record< string, { @@ -192,7 +193,20 @@ export interface UserGist { 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; + }>; +} diff --git a/apps/web/src/lib/github-utils.ts b/apps/web/src/lib/github-utils.ts index bdf490f2..e3a412f2 100644 --- a/apps/web/src/lib/github-utils.ts +++ b/apps/web/src/lib/github-utils.ts @@ -111,6 +111,7 @@ 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 `/gists/${parsed.gistId}`; const base = `/${parsed.owner}/${parsed.repo}`; @@ -227,6 +228,11 @@ type ParsedGitHubUrl = | { type: "stars"; username?: string; + } + | { + type: "gist"; + gistId: string; + owner?: string; }; function parsePositiveInt(value: string | undefined): number | null { @@ -236,14 +242,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 e66f46f9..58ca5763 100644 --- a/apps/web/src/lib/github.ts +++ b/apps/web/src/lib/github.ts @@ -2,7 +2,7 @@ import { Octokit } from "@octokit/rest"; import { headers } from "next/headers"; import { cache } from "react"; import { $Session, getServerSession } from "./auth"; -import type { UserGist } from "./github-types"; +import type { UserGist, GistDetail } from "./github-types"; import { claimDueGithubSyncJobs, deleteGithubCacheByPrefix, @@ -89,6 +89,7 @@ type GitDataSyncJobType = | "user_public_orgs" | "user_gists" | "user_starred_gists" + | "gist" | "repo_workflows" | "repo_workflow_runs" | "repo_nav_counts" @@ -123,6 +124,7 @@ const SHAREABLE_CACHE_TYPES: ReadonlySet = new Set([ "user_public_orgs", "user_gists", "user_starred_gists", + "gist", "user_events", "org", "org_repos", @@ -154,6 +156,7 @@ interface GitDataSyncJobPayload { language?: string; since?: "daily" | "weekly" | "monthly"; openIssuesAndPrs?: number; + gistId?: string; } interface LocalFirstGitReadOptions { @@ -433,6 +436,10 @@ 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)}`; } @@ -1553,22 +1560,87 @@ 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, }); - return data.map((gist) => ({ + 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, @@ -1582,22 +1654,26 @@ async function fetchUserGistsFromGitHub( ), 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, }); - return data.map((gist) => ({ + 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, @@ -1611,6 +1687,59 @@ async function fetchUserStarredGistsFromGitHub( ), 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 }>; + }) + | null +> { + try { + const { data } = await octokit.gists.get({ gist_id: gistId }); + 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 || "", + })) || [], + }; + } catch { + return null; + } } async function fetchUserOrgTopReposFromGitHub( @@ -1882,6 +2011,7 @@ async function processGitDataSyncJob( authCtx.octokit, payload.username, perPage, + authCtx.token, ); await upsertCacheWithShared( authCtx.userId, @@ -1896,6 +2026,7 @@ async function processGitDataSyncJob( const data = await fetchUserStarredGistsFromGitHub( authCtx.octokit, perPage, + authCtx.token, ); await upsertCacheWithShared( authCtx.userId, @@ -1905,6 +2036,19 @@ async function processGitDataSyncJob( ); 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); @@ -6494,7 +6638,13 @@ export async function getUserGists(username: string, perPage = 30) { fallback: [], jobType: "user_gists", jobPayload: { username, perPage }, - fetchRemote: (octokit) => fetchUserGistsFromGitHub(octokit, username, perPage), + fetchRemote: (octokit) => + fetchUserGistsFromGitHub( + octokit, + username, + perPage, + authCtx?.token ?? null, + ), }); } @@ -6507,7 +6657,21 @@ export async function getUserStarredGists(perPage = 30) { fallback: [], jobType: "user_starred_gists", jobPayload: { perPage }, - fetchRemote: (octokit) => fetchUserStarredGistsFromGitHub(octokit, 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: (octokit) => fetchGistFromGitHub(octokit, gistId), }); } From 7b64314378a7fa911a679255cf2223acca4f6abd Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Thu, 5 Mar 2026 04:35:48 +0300 Subject: [PATCH 10/19] fix: gist routing and type fixes - Added gist route at /[owner]/gist/[gistId] to match link URLs - Fixed import path in /gists/[gistId] page - Made stars field optional in UserGist interface - Gists now redirect to owner-specific URLs --- .../app/(app)/[owner]/gist/[gistId]/page.tsx | 49 +++++++++++++++++++ .../web/src/app/(app)/gists/[gistId]/page.tsx | 6 ++- .../users/[username]/gist/[gistId]/page.tsx | 49 +++++++++++++++++++ .../components/gist/gist-detail-content.tsx | 2 +- .../components/users/user-profile-content.tsx | 5 +- .../components/users/user-profile-gists.tsx | 11 +++-- apps/web/src/lib/github-utils.ts | 6 ++- 7 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx create mode 100644 apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx diff --git a/apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx b/apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx new file mode 100644 index 00000000..444bb8ee --- /dev/null +++ b/apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistDetailContent } from "@/components/gist/gist-detail-content"; +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 OwnerGistPage({ + 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)/gists/[gistId]/page.tsx b/apps/web/src/app/(app)/gists/[gistId]/page.tsx index fcf30439..40826ea8 100644 --- a/apps/web/src/app/(app)/gists/[gistId]/page.tsx +++ b/apps/web/src/app/(app)/gists/[gistId]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; import { getGist } from "@/lib/github"; import { GistDetailContent } from "@/components/gist/gist-detail-content"; import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; @@ -41,5 +41,9 @@ export default async function GistPage({ params }: { params: Promise<{ gistId: s notFound(); } + if (gist.owner?.login) { + redirect(`/${gist.owner.login}/gist/${gist.id}`); + } + return ; } diff --git a/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx b/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx new file mode 100644 index 00000000..d2823b8c --- /dev/null +++ b/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx @@ -0,0 +1,49 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { getGist } from "@/lib/github"; +import { GistDetailContent } from "@/components/gist/gist-detail-content"; +import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ username: 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 UserGistPage({ + params, +}: { + params: Promise<{ username: string; gistId: string }>; +}) { + const { gistId } = await params; + const gist = await getGist(gistId).catch(() => null); + + if (!gist) { + notFound(); + } + + return ; +} diff --git a/apps/web/src/components/gist/gist-detail-content.tsx b/apps/web/src/components/gist/gist-detail-content.tsx index 6b92bb29..be92a8df 100644 --- a/apps/web/src/components/gist/gist-detail-content.tsx +++ b/apps/web/src/components/gist/gist-detail-content.tsx @@ -21,7 +21,7 @@ function getGistTitle(gist: GistDetail): string { return gist.description?.trim() || firstFile?.filename || "Untitled Gist"; } -export async function GistDetailContent({ gist }: { gist: GistDetail }) { +export function GistDetailContent({ gist }: { gist: GistDetail }) { const files = Object.entries(gist.files).map(([key, file]) => ({ key, filename: file.filename || key, diff --git a/apps/web/src/components/users/user-profile-content.tsx b/apps/web/src/components/users/user-profile-content.tsx index 04ff351f..0ef0ce7e 100644 --- a/apps/web/src/components/users/user-profile-content.tsx +++ b/apps/web/src/components/users/user-profile-content.tsx @@ -1606,7 +1606,10 @@ export function UserProfileContent({ {/* Gists list */}
- +
)} diff --git a/apps/web/src/components/users/user-profile-gists.tsx b/apps/web/src/components/users/user-profile-gists.tsx index f54c9751..eacbccfc 100644 --- a/apps/web/src/components/users/user-profile-gists.tsx +++ b/apps/web/src/components/users/user-profile-gists.tsx @@ -12,7 +12,7 @@ export interface UserGist { public: boolean; created_at: string; updated_at: string; - stars: number; + stars?: number; files: Record< string, { @@ -27,9 +27,10 @@ export interface UserGist { interface UserProfileGistsProps { gists: UserGist[]; + ownerLogin?: string; } -export function UserProfileGists({ gists }: UserProfileGistsProps) { +export function UserProfileGists({ gists, ownerLogin }: UserProfileGistsProps) { if (gists.length === 0) { return (
@@ -58,7 +59,11 @@ export function UserProfileGists({ gists }: UserProfileGistsProps) { return ( diff --git a/apps/web/src/lib/github-utils.ts b/apps/web/src/lib/github-utils.ts index e3a412f2..cca80a7f 100644 --- a/apps/web/src/lib/github-utils.ts +++ b/apps/web/src/lib/github-utils.ts @@ -111,7 +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 `/gists/${parsed.gistId}`; + if (parsed.type === "gist") { + return parsed.owner + ? `/${parsed.owner}/gist/${parsed.gistId}` + : `/gists/${parsed.gistId}`; + } const base = `/${parsed.owner}/${parsed.repo}`; From e8f4688ebf4edbc7271a6455a9a4ef653281ec02 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Thu, 5 Mar 2026 05:16:43 +0300 Subject: [PATCH 11/19] fixed routing --- .../web/src/app/(app)/gists/[gistId]/page.tsx | 49 ------------------- .../[owner]/gist/[gistId]/page.tsx | 0 .../users/[username]/gist/[gistId]/page.tsx | 49 ------------------- apps/web/src/lib/github.ts | 16 +++++- apps/web/src/proxy.ts | 9 +++- 5 files changed, 22 insertions(+), 101 deletions(-) delete mode 100644 apps/web/src/app/(app)/gists/[gistId]/page.tsx rename apps/web/src/app/(app)/{ => repos}/[owner]/gist/[gistId]/page.tsx (100%) delete mode 100644 apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx diff --git a/apps/web/src/app/(app)/gists/[gistId]/page.tsx b/apps/web/src/app/(app)/gists/[gistId]/page.tsx deleted file mode 100644 index 40826ea8..00000000 --- a/apps/web/src/app/(app)/gists/[gistId]/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Metadata } from "next"; -import { notFound, redirect } from "next/navigation"; -import { getGist } from "@/lib/github"; -import { GistDetailContent } from "@/components/gist/gist-detail-content"; -import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; - -export async function generateMetadata({ - params, -}: { - params: Promise<{ 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 GistPage({ params }: { params: Promise<{ gistId: string }> }) { - const { gistId } = await params; - const gist = await getGist(gistId).catch(() => null); - - if (!gist) { - notFound(); - } - - if (gist.owner?.login) { - redirect(`/${gist.owner.login}/gist/${gist.id}`); - } - - return ; -} diff --git a/apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx similarity index 100% rename from apps/web/src/app/(app)/[owner]/gist/[gistId]/page.tsx rename to apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx diff --git a/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx b/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx deleted file mode 100644 index d2823b8c..00000000 --- a/apps/web/src/app/(app)/users/[username]/gist/[gistId]/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import type { Metadata } from "next"; -import { notFound } from "next/navigation"; -import { getGist } from "@/lib/github"; -import { GistDetailContent } from "@/components/gist/gist-detail-content"; -import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; - -export async function generateMetadata({ - params, -}: { - params: Promise<{ username: 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 UserGistPage({ - params, -}: { - params: Promise<{ username: string; gistId: string }>; -}) { - const { gistId } = await params; - const gist = await getGist(gistId).catch(() => null); - - if (!gist) { - notFound(); - } - - return ; -} diff --git a/apps/web/src/lib/github.ts b/apps/web/src/lib/github.ts index 58ca5763..57c96881 100644 --- a/apps/web/src/lib/github.ts +++ b/apps/web/src/lib/github.ts @@ -1737,7 +1737,8 @@ async function fetchGistFromGitHub( committed_at: h.committed_at || "", })) || [], }; - } catch { + } catch (error) { + console.error("[fetchGistFromGitHub] Error fetching gist:", error); return null; } } @@ -6663,7 +6664,13 @@ export async function getUserStarredGists(perPage = 30) { } export async function getGist(gistId: string): Promise { + console.log("[getGist] Called with gistId:", gistId); const authCtx = await getGitHubAuthContext(); + console.log("[getGist] authCtx exists:", !!authCtx); + if (!authCtx) { + console.log("[getGist] No auth context, returning null"); + return null; + } return readLocalFirstGitData({ authCtx, cacheKey: buildGistCacheKey(gistId), @@ -6671,7 +6678,12 @@ export async function getGist(gistId: string): Promise { fallback: null, jobType: "gist", jobPayload: { gistId }, - fetchRemote: (octokit) => fetchGistFromGitHub(octokit, gistId), + fetchRemote: async (octokit) => { + console.log("[getGist] Fetching from GitHub API..."); + const result = await fetchGistFromGitHub(octokit, gistId); + console.log("[getGist] GitHub API result:", result ? "found" : "null"); + return result; + }, }); } diff --git a/apps/web/src/proxy.ts b/apps/web/src/proxy.ts index 12037899..57e5ab40 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("/")}`; From b5e49f980cf56a3d06b9f08683f0c666626ea7d1 Mon Sep 17 00:00:00 2001 From: Cyberistic Date: Thu, 5 Mar 2026 05:24:01 +0300 Subject: [PATCH 12/19] added comments --- .../repos/[owner]/gist/[gistId]/page.tsx | 9 ++- .../components/gist/gist-detail-content.tsx | 26 ++++++- apps/web/src/lib/github-types.ts | 12 ++++ apps/web/src/lib/github.ts | 68 +++++++++++-------- 4 files changed, 81 insertions(+), 34 deletions(-) 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 index 444bb8ee..33fc8242 100644 --- a/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx +++ b/apps/web/src/app/(app)/repos/[owner]/gist/[gistId]/page.tsx @@ -1,6 +1,6 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; -import { getGist } from "@/lib/github"; +import { getGist, getGistComments } from "@/lib/github"; import { GistDetailContent } from "@/components/gist/gist-detail-content"; import { ogImageUrl, ogImages } from "@/lib/og/og-utils"; @@ -39,11 +39,14 @@ export default async function OwnerGistPage({ params: Promise<{ owner: string; gistId: string }>; }) { const { gistId } = await params; - const gist = await getGist(gistId).catch(() => null); + const [gist, comments] = await Promise.all([ + getGist(gistId).catch(() => null), + getGistComments(gistId).catch(() => []), + ]); if (!gist) { notFound(); } - return ; + return ; } diff --git a/apps/web/src/components/gist/gist-detail-content.tsx b/apps/web/src/components/gist/gist-detail-content.tsx index be92a8df..856ad447 100644 --- a/apps/web/src/components/gist/gist-detail-content.tsx +++ b/apps/web/src/components/gist/gist-detail-content.tsx @@ -4,8 +4,9 @@ import { ExternalLink, FileCode2, Globe, History, Lock, MessageSquare, Star } fr import { CodeViewer } from "@/components/repo/code-viewer"; import { MarkdownBlobView } from "@/components/repo/markdown-blob-view"; import { MarkdownRenderer } from "@/components/shared/markdown-renderer"; +import { CommentThread } from "@/components/shared/comment-thread"; import { TimeAgo } from "@/components/ui/time-ago"; -import type { GistDetail } from "@/lib/github-types"; +import type { GistComment, GistDetail } from "@/lib/github-types"; import { formatBytes, getLanguageColor, getLanguageFromFilename } from "@/lib/github-utils"; import { formatNumber } from "@/lib/utils"; @@ -21,7 +22,13 @@ function getGistTitle(gist: GistDetail): string { return gist.description?.trim() || firstFile?.filename || "Untitled Gist"; } -export function GistDetailContent({ gist }: { gist: GistDetail }) { +export function GistDetailContent({ + gist, + comments = [], +}: { + gist: GistDetail; + comments?: GistComment[]; +}) { const files = Object.entries(gist.files).map(([key, file]) => ({ key, filename: file.filename || key, @@ -276,6 +283,21 @@ export function GistDetailContent({ gist }: { gist: GistDetail }) { ); })} + +
+
+ + + Comments + + + {formatNumber(gist.comments)} + +
+
+ +
+