From a2b6a8c130e915008dc95f7fe7e3be2f1af5c23f Mon Sep 17 00:00:00 2001 From: Abhash Chakraborty Date: Wed, 15 Apr 2026 05:37:23 +0000 Subject: [PATCH 1/4] fix: stop exposing client-side tokens --- docusaurus.config.ts | 5 +---- src/lib/statsProvider.tsx | 17 +---------------- src/services/githubService.ts | 10 +--------- wiki/Documentation.md | 19 ++----------------- 4 files changed, 5 insertions(+), 46 deletions(-) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index d4e89964..fe9ce6e8 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -267,15 +267,12 @@ const config: Config = { ], ], - // ✅ Add this customFields object to expose the token to the client-side customFields: { - gitToken: process.env.DOCUSAURUS_GIT_TOKEN, // Shopify credentials for merch store SHOPIFY_STORE_DOMAIN: process.env.SHOPIFY_STORE_DOMAIN || "junh9v-gw.myshopify.com", SHOPIFY_STOREFRONT_ACCESS_TOKEN: - process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN || - "2503dfbf93132b42e627e7d53b3ba3e9", + process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN, hooks: { onBrokenMarkdownLinks: "warn", }, diff --git a/src/lib/statsProvider.tsx b/src/lib/statsProvider.tsx index 923c16c5..682a1b7b 100644 --- a/src/lib/statsProvider.tsx +++ b/src/lib/statsProvider.tsx @@ -8,7 +8,6 @@ import React, { type ReactNode, } from "react"; import { githubService, type GitHubOrgStats } from "../services/githubService"; -import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; // Time filter types export type TimeFilter = "week" | "month" | "year" | "all"; @@ -160,11 +159,6 @@ const isPRInTimeRange = (mergedAt: string, filter: TimeFilter): boolean => { export function CommunityStatsProvider({ children, }: CommunityStatsProviderProps) { - const { - siteConfig: { customFields }, - } = useDocusaurusContext(); - const token = customFields?.gitToken || ""; - const [loading, setLoading] = useState(false); // Start with false to avoid hourglass const [error, setError] = useState(null); const [githubStarCount, setGithubStarCount] = useState(984); // Placeholder value - updated to match production @@ -433,17 +427,8 @@ export function CommunityStatsProvider({ setError(null); - if (!token) { - setError( - "GitHub token not found. Please set customFields.gitToken in docusaurus.config.js.", - ); - setLoading(false); - return; - } - try { const headers: Record = { - Authorization: `token ${token}`, Accept: "application/vnd.github.v3+json", }; @@ -497,7 +482,7 @@ export function CommunityStatsProvider({ setLoading(false); } }, - [token, fetchAllOrgRepos, processBatch, cache], + [fetchAllOrgRepos, processBatch, cache], ); const clearCache = useCallback(() => { diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 1a747133..51b7d060 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -70,18 +70,10 @@ class GitHubService { // Get headers for GitHub API requests private getHeaders(): Record { - const headers: Record = { + return { Accept: "application/vnd.github.v3+json", "Content-Type": "application/json", }; - - // Add GitHub token if available in environment - // Note: In production, you might want to use a server-side proxy to avoid exposing tokens - if (typeof window !== "undefined" && (window as any).GITHUB_TOKEN) { - headers["Authorization"] = `token ${(window as any).GITHUB_TOKEN}`; - } - - return headers; } // === ADDED: setter to toggle anonymous contributors inclusion diff --git a/wiki/Documentation.md b/wiki/Documentation.md index 3a4e5021..033b571d 100644 --- a/wiki/Documentation.md +++ b/wiki/Documentation.md @@ -573,7 +573,7 @@ Response Example: } ``` #### Authentication -All requests require a GitHub Personal Access Token: +Authenticated requests should be made from a server-side endpoint or serverless function so the token is never shipped to the browser: ```typescript const headers: Record = { Authorization: `token ${YOUR_GITHUB_TOKEN}`, @@ -588,22 +588,7 @@ Select scopes: public_repo, read:org Copy the token (you won't see it again!) #### Storing the Token: -In Docusaurus, we store it in docusaurus.config.js: -```javascript -module.exports = { - customFields: { - gitToken: process.env.GITHUB_TOKEN || '', - }, - // ... -}; -``` -Then access it: -```typescript -const { - siteConfig: { customFields }, -} = useDocusaurusContext(); -const token = customFields?.gitToken || ""; -``` +Do not store a GitHub token in `docusaurus.config.js` or any other client-bundled config. Keep it in server-side environment variables and call GitHub from a backend endpoint instead. #### Error Handling **Rate Limit Exceeded (403)** From 8f47c28fb55833e72485796fe0143a993d7c8a3f Mon Sep 17 00:00:00 2001 From: Abhash Chakraborty Date: Wed, 22 Apr 2026 07:48:33 +0000 Subject: [PATCH 2/4] fix: gate client-side GitHub discussions --- docusaurus.config.ts | 6 +++--- src/pages/dashboard/index.tsx | 1 + src/services/githubService.ts | 30 ++++++++++++++++++------------ wiki/Documentation.md | 6 +++++- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/docusaurus.config.ts b/docusaurus.config.ts index fe9ce6e8..9c7069b0 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -248,6 +248,9 @@ const config: Config = { markdown: { mermaid: true, + hooks: { + onBrokenMarkdownLinks: "warn", + }, }, // Migrated legacy setting to markdown.hooks.onBrokenMarkdownLinks @@ -273,9 +276,6 @@ const config: Config = { process.env.SHOPIFY_STORE_DOMAIN || "junh9v-gw.myshopify.com", SHOPIFY_STOREFRONT_ACCESS_TOKEN: process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN, - hooks: { - onBrokenMarkdownLinks: "warn", - }, }, }; diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index bf06f244..bfc99cd6 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -130,6 +130,7 @@ const DashboardContent: React.FC = () => { setDiscussions(discussionsData); } catch (error) { console.error("Failed to fetch discussions:", error); + setDiscussions([]); setDiscussionsError( error instanceof Error ? error.message : "Failed to load discussions", ); diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 51b7d060..24cbea61 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -64,6 +64,8 @@ class GitHubService { private readonly CACHE_KEY = "github_org_stats"; private readonly CACHE_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds private readonly BASE_URL = "https://api.github.com"; + private readonly DISCUSSIONS_UNAVAILABLE_MESSAGE = + "GitHub Discussions are disabled until a server-side GitHub proxy is configured."; // === ADDED: include anonymous contributors configurable (default false) private includeAnonymousContributors = false; @@ -76,6 +78,10 @@ class GitHubService { }; } + private canUseGitHubGraphQL(): boolean { + return typeof window === "undefined"; + } + // === ADDED: setter to toggle anonymous contributors inclusion setIncludeAnonymousContributors(value: boolean) { this.includeAnonymousContributors = value; @@ -267,16 +273,16 @@ class GitHubService { return totalContributors; } - // === UPDATED: Get discussions count for a specific repository (default: "Support") - // Reason: previous code used an org-wide issues search which returned issues, not discussions. - // This function uses GraphQL to read repository.discussions.totalCount (repo-specific). - // If you need org-wide discussions count, we should iterate all repos and sum totalCount (heavier). + // GitHub GraphQL requires authentication, so the browser should not call it directly. private async getDiscussionsCount( signal?: AbortSignal, repoName: string = "Support", ): Promise { + if (!this.canUseGitHubGraphQL()) { + return 0; + } + try { - // GraphQL query to get discussions totalCount for a repository const query = ` query ($owner: String!, $name: String!) { repository(owner: $owner, name: $name) { @@ -343,11 +349,10 @@ class GitHubService { 0, ); - // Estimate contributors and get discussions count - // === UPDATED: getDiscussionsCount now uses GraphQL for a specific repo (default 'Support') + // Estimate contributors and fetch discussion stats when a server-side context is available. const [totalContributors, discussionsCount] = await Promise.all([ this.estimateContributors(activeRepos, signal), - this.getDiscussionsCount(signal), // default repoName: "Support" (change if you prefer another repo) + this.getDiscussionsCount(signal), ]); const stats: GitHubOrgStats = { @@ -402,11 +407,14 @@ class GitHubService { return { cached: true, age, expiresIn }; } - // Fetch GitHub Discussions using GraphQL API (existing method kept intact) async fetchDiscussions( limit: number = 20, signal?: AbortSignal, ): Promise { + if (!this.canUseGitHubGraphQL()) { + throw new Error(this.DISCUSSIONS_UNAVAILABLE_MESSAGE); + } + const query = ` query GetDiscussions($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { @@ -505,9 +513,7 @@ class GitHubService { ); } catch (error) { console.error("Error fetching discussions:", error); - - // Return mock data for development/fallback - return this.getMockDiscussions(); + throw error; } } diff --git a/wiki/Documentation.md b/wiki/Documentation.md index 033b571d..1f3420ec 100644 --- a/wiki/Documentation.md +++ b/wiki/Documentation.md @@ -221,9 +221,13 @@ const isPRInTimeRange = (mergedAt: string, filter: TimeFilter): boolean => { const prDate = new Date(mergedAt); return prDate >= filterDate; }; +``` + Computed Contributors This is where React's useMemo shines: -typescriptconst contributors = useMemo(() => { + +```typescript +const contributors = useMemo(() => { if (!allContributors.length) return []; const filteredContributors = allContributors From fb43872986b26382a4f5ecd0dd1b0def8da70c5b Mon Sep 17 00:00:00 2001 From: Abhash Chakraborty Date: Fri, 24 Apr 2026 18:17:33 +0530 Subject: [PATCH 3/4] fix(github): handle unavailable discussions stats --- src/lib/statsProvider.tsx | 14 ++++++++---- src/services/githubService.ts | 41 +++++++++++++++++++++++++++-------- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/src/lib/statsProvider.tsx b/src/lib/statsProvider.tsx index 682a1b7b..c6e83c8d 100644 --- a/src/lib/statsProvider.tsx +++ b/src/lib/statsProvider.tsx @@ -21,7 +21,7 @@ interface ICommunityStatsContext { githubForksCountText: string; githubReposCount: number; githubReposCountText: string; - githubDiscussionsCount: number; + githubDiscussionsCount: number | null; githubDiscussionsCountText: string; loading: boolean; error: string | null; @@ -165,7 +165,9 @@ export function CommunityStatsProvider({ const [githubContributorsCount, setGithubContributorsCount] = useState(467); // Placeholder value - updated to match production const [githubForksCount, setGithubForksCount] = useState(1107); // Placeholder value - updated to match production const [githubReposCount, setGithubReposCount] = useState(10); // Placeholder value - updated to match production - const [githubDiscussionsCount, setGithubDiscussionsCount] = useState(0); + const [githubDiscussionsCount, setGithubDiscussionsCount] = useState< + number | null + >(null); const [lastUpdated, setLastUpdated] = useState(null); // Time filter state @@ -476,7 +478,7 @@ export function CommunityStatsProvider({ setGithubContributorsCount(140); setGithubForksCount(0); setGithubReposCount(20); - setGithubDiscussionsCount(0); + setGithubDiscussionsCount(null); } } finally { setLoading(false); @@ -562,7 +564,11 @@ export const useCommunityStatsContext = (): ICommunityStatsContext => { return context; }; -export const convertStatToText = (num: number): string => { +export const convertStatToText = (num: number | null): string => { + if (num === null) { + return "N/A"; + } + const hasIntlSupport = typeof Intl === "object" && Intl && typeof Intl.NumberFormat === "function"; diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 24cbea61..c8a720de 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -10,7 +10,7 @@ export interface GitHubOrgStats { totalRepositories: number; totalContributors: number; publicRepositories: number; - discussionsCount: number; + discussionsCount: number | null; lastUpdated: number; } @@ -82,6 +82,27 @@ class GitHubService { return typeof window === "undefined"; } + private getGitHubToken(): string | null { + if (typeof window !== "undefined") { + return null; + } + + return process.env.GITHUB_TOKEN?.trim() || null; + } + + private getGraphQLHeaders(): Record { + const token = this.getGitHubToken(); + + if (!token) { + throw new Error(this.DISCUSSIONS_UNAVAILABLE_MESSAGE); + } + + return { + ...this.getHeaders(), + Authorization: `Bearer ${token}`, + }; + } + // === ADDED: setter to toggle anonymous contributors inclusion setIncludeAnonymousContributors(value: boolean) { this.includeAnonymousContributors = value; @@ -277,9 +298,9 @@ class GitHubService { private async getDiscussionsCount( signal?: AbortSignal, repoName: string = "Support", - ): Promise { + ): Promise { if (!this.canUseGitHubGraphQL()) { - return 0; + return null; } try { @@ -295,7 +316,7 @@ class GitHubService { const resp = await fetch("https://api.github.com/graphql", { method: "POST", headers: { - ...this.getHeaders(), + ...this.getGraphQLHeaders(), "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), @@ -304,20 +325,20 @@ class GitHubService { if (!resp.ok) { console.warn(`GraphQL request for discussions failed: ${resp.status}`); - return 0; + return null; } const data = await resp.json(); if (data.errors) { console.warn("GraphQL errors while fetching discussions:", data.errors); - return 0; + return null; } const count = data?.data?.repository?.discussions?.totalCount || 0; return Number(count); } catch (error) { console.warn("Error fetching discussions count via GraphQL:", error); - return 0; + return null; } } @@ -379,7 +400,7 @@ class GitHubService { totalRepositories: 0, publicRepositories: 0, totalContributors: 0, - discussionsCount: 0, + discussionsCount: null, lastUpdated: Date.now(), }; @@ -415,6 +436,8 @@ class GitHubService { throw new Error(this.DISCUSSIONS_UNAVAILABLE_MESSAGE); } + const graphqlHeaders = this.getGraphQLHeaders(); + const query = ` query GetDiscussions($owner: String!, $name: String!, $first: Int!) { repository(owner: $owner, name: $name) { @@ -463,7 +486,7 @@ class GitHubService { const response = await fetch("https://api.github.com/graphql", { method: "POST", headers: { - ...this.getHeaders(), + ...graphqlHeaders, "Content-Type": "application/json", }, body: JSON.stringify({ query, variables }), From 23c4c6b2bb5d1072249860cdde20cf592ddf3c5e Mon Sep 17 00:00:00 2001 From: Abhash Chakraborty Date: Tue, 28 Apr 2026 08:56:18 +0000 Subject: [PATCH 4/4] fix(github): proxy discussions server-side Keep discussions live in production without exposing a client token.\n\nRoute discussion reads through a server-side API endpoint, preserve\nlegacy env compatibility during migration, and document the required\nenvironment variables. --- .env.example | 13 +- api/github-discussions.js | 163 ++++++++++++++++ docusaurus.config.ts | 3 - eslint.config.mjs | 3 + package.json | 4 +- src/lib/statsProvider.tsx | 7 +- src/pages/dashboard/index.tsx | 96 +++------ src/services/githubService.ts | 347 ++++----------------------------- src/types/githubDiscussions.ts | 33 ++++ src/types/global.d.ts | 4 + wiki/Documentation.md | 3 + 11 files changed, 274 insertions(+), 402 deletions(-) create mode 100644 api/github-discussions.js create mode 100644 src/types/githubDiscussions.ts diff --git a/.env.example b/.env.example index 84ce222f..17f6a146 100644 --- a/.env.example +++ b/.env.example @@ -1,12 +1,11 @@ -# GitHub API Configuration (Optional) -# To avoid rate limits, you can add a GitHub Personal Access Token -# Create one at: https://github.com/settings/tokens -# No special permissions needed for public repositories +# GitHub API Configuration +# Used by the server-side /api/github-discussions endpoint. +# Prefer GITHUB_TOKEN for new setups. DOCUSAURUS_GIT_TOKEN is still supported as a legacy fallback. +# Create a Classic PAT at: https://github.com/settings/tokens +# Recommended scopes: public_repo, read:org, read:discussion GITHUB_TOKEN=your_github_token_here -# GitHub token used by Docusaurus for dynamic features (discussions, stats, leaderboard) -# This must be set for the discussions section to fetch live data from GitHub -# Create a Classic PAT with read:discussion scope at https://github.com/settings/tokens +# Legacy fallback for existing deployments. Optional if GITHUB_TOKEN is already set. DOCUSAURUS_GIT_TOKEN=your_github_token_here # Shopify Configuration (for Merch Store) diff --git a/api/github-discussions.js b/api/github-discussions.js new file mode 100644 index 00000000..155881c9 --- /dev/null +++ b/api/github-discussions.js @@ -0,0 +1,163 @@ +const ORG_NAME = "recodehive"; +const DISCUSSIONS_REPO = "recode-website"; +const DEFAULT_LIMIT = 20; +const UNAVAILABLE_MESSAGE = + "GitHub Discussions are available only when a server-side GITHUB_TOKEN or DOCUSAURUS_GIT_TOKEN is configured."; + +function getToken() { + return process.env.GITHUB_TOKEN?.trim() || process.env.DOCUSAURUS_GIT_TOKEN?.trim() || ""; +} + +function parseLimit(limitParam) { + const parsed = Number.parseInt(limitParam, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_LIMIT; + } + + return Math.min(parsed, 50); +} + +function mapDiscussion(discussion) { + return { + id: discussion.id, + title: discussion.title || "Untitled discussion", + body: discussion.body || "", + author: { + login: discussion.author?.login || "Unknown", + avatar_url: discussion.author?.avatarUrl || "", + html_url: discussion.author?.url || "", + }, + category: { + name: discussion.category?.name || "General", + emoji: discussion.category?.emoji || "", + }, + created_at: discussion.createdAt, + updated_at: discussion.updatedAt, + comments: discussion.comments?.totalCount || 0, + reactions: { + total_count: discussion.reactions?.totalCount || 0, + }, + html_url: discussion.url, + labels: + discussion.labels?.nodes?.map((label) => ({ + name: label.name, + color: label.color, + })) || [], + }; +} + +async function fetchGitHubDiscussions(token, limit) { + const query = ` + query GetDiscussions($owner: String!, $name: String!, $first: Int!) { + repository(owner: $owner, name: $name) { + discussions(first: $first, orderBy: { field: UPDATED_AT, direction: DESC }) { + totalCount + nodes { + id + title + body + createdAt + updatedAt + url + author { + login + avatarUrl + url + } + category { + name + emoji + } + comments { + totalCount + } + reactions { + totalCount + } + labels(first: 10) { + nodes { + name + color + } + } + } + } + } + } + `; + + const response = await fetch("https://api.github.com/graphql", { + method: "POST", + headers: { + Accept: "application/vnd.github.v3+json", + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + query, + variables: { + owner: ORG_NAME, + name: DISCUSSIONS_REPO, + first: limit, + }, + }), + }); + + if (!response.ok) { + throw new Error(`GitHub discussions request failed: ${response.status}`); + } + + const payload = await response.json(); + + if (payload.errors?.length) { + const message = payload.errors.map((error) => error.message).join(", "); + throw new Error(message || "GitHub discussions GraphQL query failed"); + } + + const discussions = payload.data?.repository?.discussions; + + return { + available: true, + message: null, + totalCount: discussions?.totalCount ?? 0, + discussions: (discussions?.nodes || []).map(mapDiscussion), + fetchedAt: new Date().toISOString(), + }; +} + +export default async function handler(req, res) { + const token = getToken(); + + res.setHeader( + "Cache-Control", + "public, s-maxage=300, stale-while-revalidate=600", + ); + + if (!token) { + res.status(503).json({ + available: false, + message: UNAVAILABLE_MESSAGE, + totalCount: null, + discussions: [], + fetchedAt: null, + }); + return; + } + + try { + const limit = parseLimit(req.query.limit); + const data = await fetchGitHubDiscussions(token, limit); + res.status(200).json(data); + } catch (error) { + res.status(502).json({ + available: false, + message: + error instanceof Error + ? error.message + : "Failed to fetch GitHub discussions.", + totalCount: null, + discussions: [], + fetchedAt: null, + }); + } +} diff --git a/docusaurus.config.ts b/docusaurus.config.ts index 1fb4087c..e44c8a0b 100644 --- a/docusaurus.config.ts +++ b/docusaurus.config.ts @@ -19,7 +19,6 @@ const config: Config = { projectName: "recode-website", onBrokenLinks: "throw", - // onBrokenMarkdownLinks moved to markdown.hooks // Google Analytics and Theme Scripts scripts: [ @@ -271,8 +270,6 @@ const config: Config = { }, }, - // Migrated legacy setting to markdown.hooks.onBrokenMarkdownLinks - themes: ["@docusaurus/theme-mermaid"], plugins: [ diff --git a/eslint.config.mjs b/eslint.config.mjs index a8b98b3c..63fe7815 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -4,6 +4,9 @@ import tsPlugin from "@typescript-eslint/eslint-plugin"; import reactPlugin from "eslint-plugin-react"; export default [ + { + ignores: ["node_modules/", "build/", ".docusaurus/", "static/", "dist/"], + }, { files: ["**/*.{ts,tsx}"], ignores: ["node_modules/", "build/", ".docusaurus/", "static/", "dist/"], diff --git a/package.json b/package.json index f26323c9..81a9c5c4 100644 --- a/package.json +++ b/package.json @@ -14,8 +14,8 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc", - "lint": "eslint \"**/*.{js,jsx,ts,tsx}\"", - "lint:fix": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix", + "lint": "eslint .", + "lint:fix": "eslint . --fix", "format": "prettier --write \"**/*.{js,jsx,ts,tsx,json,css}\"", "format:check": "prettier --check .", "prepare": "husky" diff --git a/src/lib/statsProvider.tsx b/src/lib/statsProvider.tsx index c6e83c8d..51d57a00 100644 --- a/src/lib/statsProvider.tsx +++ b/src/lib/statsProvider.tsx @@ -7,7 +7,7 @@ import React, { useState, type ReactNode, } from "react"; -import { githubService, type GitHubOrgStats } from "../services/githubService"; +import { githubService } from "../services/githubService"; // Time filter types export type TimeFilter = "week" | "month" | "year" | "all"; @@ -435,9 +435,10 @@ export function CommunityStatsProvider({ }; // Fetch both org stats and repos in parallel - const [orgStats, repos] = await Promise.all([ + const [orgStats, repos, discussionsCount] = await Promise.all([ githubService.fetchOrganizationStats(signal), fetchAllOrgRepos(headers), + githubService.fetchDiscussionsCount(signal), ]); // Set org stats immediately @@ -445,7 +446,7 @@ export function CommunityStatsProvider({ setGithubContributorsCount(orgStats.totalContributors); setGithubForksCount(orgStats.totalForks); setGithubReposCount(orgStats.publicRepositories); - setGithubDiscussionsCount(orgStats.discussionsCount); + setGithubDiscussionsCount(discussionsCount ?? orgStats.discussionsCount); setLastUpdated(new Date(orgStats.lastUpdated)); // Process leaderboard data with concurrent processing diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 689eb1f7..8305cdcc 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -1,4 +1,4 @@ -import React, { JSX, useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import Layout from "@theme/Layout"; import Head from "@docusaurus/Head"; import BrowserOnly from "@docusaurus/BrowserOnly"; @@ -9,12 +9,12 @@ import { } from "@site/src/lib/statsProvider"; import SlotCounter from "react-slot-counter"; import { useLocation, useHistory } from "@docusaurus/router"; -import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; -import { - githubService, - GitHubDiscussion, -} from "@site/src/services/githubService"; import DiscussionCard from "@site/src/components/discussions/DiscussionCard"; +import { githubService } from "@site/src/services/githubService"; +import type { + GitHubDiscussion, + GitHubDiscussionsResponse, +} from "../../types/githubDiscussions"; import { Megaphone, Lightbulb, @@ -24,11 +24,8 @@ import { Search, TrendingUp, Home, - Trophy, Users, Gift, - Calendar, - BarChart3, ArrowLeft, GitFork, RefreshCw, @@ -48,13 +45,6 @@ type Category = | "show-and-tell" | "general"; -interface DashboardStats { - totalContributors: number; - totalRepositories: number; - totalStars: number; - totalForks: number; -} - const categories: Category[] = [ "all", "announcements", @@ -67,9 +57,6 @@ const categories: Category[] = [ const DashboardContent: React.FC = () => { const location = useLocation(); const history = useHistory(); - const { - siteConfig: { customFields }, - } = useDocusaurusContext(); const [activeTab, setActiveTab] = useState< "home" | "discuss" | "giveaway" | "contributors" >("home"); @@ -85,14 +72,6 @@ const DashboardContent: React.FC = () => { const [discussionsError, setDiscussionsError] = useState(null); const [showDashboardMenu, setShowDashboardMenu] = useState(false); - // Initialize GitHub service with token from Docusaurus config - useEffect(() => { - const token = customFields?.gitToken as string; - if (token) { - githubService.setToken(token); - } - }, [customFields?.gitToken]); - // Close dashboard menu when clicking outside useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -128,7 +107,6 @@ const DashboardContent: React.FC = () => { } }, [location]); - // Fetch discussions when discuss tab is active useEffect(() => { if (activeTab === "discuss") { fetchDiscussions(); @@ -139,10 +117,16 @@ const DashboardContent: React.FC = () => { try { setDiscussionsLoading(true); setDiscussionsError(null); - const discussionsData = await githubService.fetchDiscussions(20); - setDiscussions(discussionsData); + + const data = (await githubService.fetchDiscussions( + 20, + )) as GitHubDiscussionsResponse; + + setDiscussions(data.discussions ?? []); + setDiscussionsError( + data.available ? null : data.message || "Failed to load discussions", + ); } catch (error) { - console.error("Failed to fetch discussions:", error); setDiscussions([]); setDiscussionsError( error instanceof Error ? error.message : "Failed to load discussions", @@ -289,7 +273,7 @@ const DashboardContent: React.FC = () => { }); }; - const filteredDiscussions = React.useMemo( + const filteredDiscussions = useMemo( () => getFilteredDiscussions(discussions), [discussions, activeDiscussionTab, selectedCategory, searchQuery, sortBy], ); @@ -313,42 +297,18 @@ const DashboardContent: React.FC = () => { }; const { - githubStarCount, - githubContributorsCount, - githubForksCount, - githubReposCount, + githubStarCountText, + githubContributorsCountText, + githubForksCountText, loading, - error, } = useCommunityStatsContext(); - const [dashboardStats, setDashboardStats] = useState({ - totalContributors: 0, - totalRepositories: 0, - totalStars: 0, - totalForks: 0, - }); - - useEffect(() => { - setDashboardStats({ - totalContributors: githubContributorsCount, - totalRepositories: githubReposCount, - totalStars: githubStarCount, - totalForks: githubForksCount, - }); - }, [ - githubContributorsCount, - githubReposCount, - githubStarCount, - githubForksCount, - ]); - const StatCard: React.FC<{ icon: React.ReactNode; title: string; - value: number; valueText: string; description: string; - }> = ({ icon, title, value, valueText, description }) => ( + }> = ({ icon, title, valueText, description }) => ( { } title="Total Stars" - value={dashboardStats.totalStars} - valueText={ - useCommunityStatsContext().githubStarCountText || "937" - } + valueText={githubStarCountText || "937"} description="Stars across all our public repositories" /> } title="Contributors" - value={dashboardStats.totalContributors} - valueText={ - useCommunityStatsContext().githubContributorsCountText || - "444" - } + valueText={githubContributorsCountText || "444"} description="Amazing community members" /> } title="Forks" - value={dashboardStats.totalForks} - valueText={ - useCommunityStatsContext().githubForksCountText || "1.03K" - } + valueText={githubForksCountText || "1.03K"} description="Community contributions" /> diff --git a/src/services/githubService.ts b/src/services/githubService.ts index 459d9300..e81a81e6 100644 --- a/src/services/githubService.ts +++ b/src/services/githubService.ts @@ -1,8 +1,9 @@ -// GitHub API service for fetching organization metrics -// Uses localStorage for caching to reduce API calls -// 1) discussions count used org-wide search — replaced with repo-specific GraphQL query (default repo: "Support"). -// 2) anonymous contributors (anon=true) made configurable (default: false). -// Changes are annotated with // === ADDED and // === UPDATED where applicable. +import type { + GitHubDiscussionsResponse, +} from "../types/githubDiscussions"; + +// GitHub API service for fetching public organization metrics. +// Discussions are fetched through a server-side proxy so no token is exposed client-side. export interface GitHubOrgStats { totalStars: number; @@ -33,94 +34,22 @@ export interface GitHubOrganization { following: number; } -export interface GitHubDiscussion { - id: string; - title: string; - body: string; - author: { - login: string; - avatar_url: string; - html_url: string; - }; - category: { - name: string; - emoji: string; - }; - created_at: string; - updated_at: string; - comments: number; - reactions: { - total_count: number; - }; - html_url: string; - labels: Array<{ - name: string; - color: string; - }>; -} - class GitHubService { private readonly ORG_NAME = "recodehive"; private readonly CACHE_KEY = "github_org_stats"; private readonly CACHE_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds private readonly BASE_URL = "https://api.github.com"; - private readonly DISCUSSIONS_UNAVAILABLE_MESSAGE = - "GitHub Discussions are disabled until a server-side GitHub proxy is configured."; + private readonly DISCUSSIONS_API_URL = "/api/github-discussions"; // === ADDED: include anonymous contributors configurable (default false) private includeAnonymousContributors = false; - // === ADDED: stored token for authenticated API calls - private token: string = ""; - - // === ADDED: set the GitHub token (e.g. from Docusaurus customFields.gitToken) - setToken(token: string): void { - if (token && token.trim()) { - this.token = token.trim(); - } - } - // Get headers for GitHub API requests private getHeaders(): Record { - const headers: Record = { + return { Accept: "application/vnd.github.v3+json", "Content-Type": "application/json", }; - - // Use stored token first, then fall back to window.GITHUB_TOKEN - const token = - this.token || - (typeof window !== "undefined" ? (window as any).GITHUB_TOKEN : ""); - if (token) { - headers["Authorization"] = `token ${token}`; - } - - return headers; - } - - private canUseGitHubGraphQL(): boolean { - return typeof window === "undefined"; - } - - private getGitHubToken(): string | null { - if (typeof window !== "undefined") { - return null; - } - - return process.env.GITHUB_TOKEN?.trim() || this.token || null; - } - - private getGraphQLHeaders(): Record { - const token = this.getGitHubToken(); - - if (!token) { - throw new Error(this.DISCUSSIONS_UNAVAILABLE_MESSAGE); - } - - return { - ...this.getHeaders(), - Authorization: `Bearer ${token}`, - }; } // === ADDED: setter to toggle anonymous contributors inclusion @@ -314,54 +243,6 @@ class GitHubService { return totalContributors; } - // GitHub GraphQL requires authentication, so the browser should not call it directly. - private async getDiscussionsCount( - signal?: AbortSignal, - repoName: string = "Support", - ): Promise { - if (!this.canUseGitHubGraphQL()) { - return null; - } - - try { - const query = ` - query ($owner: String!, $name: String!) { - repository(owner: $owner, name: $name) { - discussions { totalCount } - } - } - `; - const variables = { owner: this.ORG_NAME, name: repoName }; - - const resp = await fetch("https://api.github.com/graphql", { - method: "POST", - headers: { - ...this.getGraphQLHeaders(), - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - signal, - }); - - if (!resp.ok) { - console.warn(`GraphQL request for discussions failed: ${resp.status}`); - return null; - } - - const data = await resp.json(); - if (data.errors) { - console.warn("GraphQL errors while fetching discussions:", data.errors); - return null; - } - - const count = data?.data?.repository?.discussions?.totalCount || 0; - return Number(count); - } catch (error) { - console.warn("Error fetching discussions count via GraphQL:", error); - return null; - } - } - // Main method to fetch all organization statistics async fetchOrganizationStats(signal?: AbortSignal): Promise { // Try to get cached data first @@ -371,8 +252,7 @@ class GitHubService { } try { - // Fetch organization info and repositories in parallel - const [orgInfo, repositories] = await Promise.all([ + const [, repositories] = await Promise.all([ this.fetchOrganizationInfo(signal), this.fetchAllRepositories(signal), ]); @@ -390,11 +270,10 @@ class GitHubService { 0, ); - // Estimate contributors and fetch discussion stats when a server-side context is available. - const [totalContributors, discussionsCount] = await Promise.all([ - this.estimateContributors(activeRepos, signal), - this.getDiscussionsCount(signal), - ]); + const totalContributors = await this.estimateContributors( + activeRepos, + signal, + ); const stats: GitHubOrgStats = { totalStars, @@ -402,7 +281,7 @@ class GitHubService { totalRepositories: repositories.length, publicRepositories: activeRepos.length, totalContributors, - discussionsCount, + discussionsCount: null, lastUpdated: Date.now(), }; @@ -451,195 +330,35 @@ class GitHubService { async fetchDiscussions( limit: number = 20, signal?: AbortSignal, - ): Promise { - if (!this.canUseGitHubGraphQL()) { - throw new Error(this.DISCUSSIONS_UNAVAILABLE_MESSAGE); - } - - const graphqlHeaders = this.getGraphQLHeaders(); - - const query = ` - query GetDiscussions($owner: String!, $name: String!, $first: Int!) { - repository(owner: $owner, name: $name) { - discussions(first: $first, orderBy: {field: UPDATED_AT, direction: DESC}) { - nodes { - id - title - body - createdAt - updatedAt - url - author { - login - avatarUrl - url - } - category { - name - emoji - } - comments { - totalCount - } - reactions { - totalCount - } - labels(first: 10) { - nodes { - name - color - } - } - } - } - } - } - `; - - const variables = { - owner: this.ORG_NAME, - name: "recode-website", // Main repository for discussions (unchanged) - first: limit, - }; - - try { - const response = await fetch("https://api.github.com/graphql", { - method: "POST", + ): Promise { + const response = await fetch( + `${this.DISCUSSIONS_API_URL}?limit=${encodeURIComponent(limit)}`, + { headers: { - ...graphqlHeaders, - "Content-Type": "application/json", + Accept: "application/json", }, - body: JSON.stringify({ query, variables }), signal, - }); + }, + ); - if (!response.ok) { - throw new Error(`GraphQL request failed: ${response.status}`); - } + const payload = (await response.json()) as GitHubDiscussionsResponse; - const data = await response.json(); + if (!response.ok) { + throw new Error(payload.message || "Failed to fetch GitHub discussions."); + } - if (data.errors) { - console.error("GraphQL errors:", data.errors); - throw new Error("GraphQL query failed"); - } + return payload; + } - const discussions = data.data?.repository?.discussions?.nodes || []; - - return discussions.map( - (discussion: any): GitHubDiscussion => ({ - id: discussion.id, - title: discussion.title, - body: discussion.body || "", - author: { - login: discussion.author?.login || "Unknown", - avatar_url: discussion.author?.avatarUrl || "", - html_url: discussion.author?.url || "", - }, - category: { - name: discussion.category?.name || "General", - emoji: discussion.category?.emoji || "💬", - }, - created_at: discussion.createdAt, - updated_at: discussion.updatedAt, - comments: discussion.comments?.totalCount || 0, - reactions: { - total_count: discussion.reactions?.totalCount || 0, - }, - html_url: discussion.url, - labels: - discussion.labels?.nodes?.map((label: any) => ({ - name: label.name, - color: label.color, - })) || [], - }), - ); + async fetchDiscussionsCount(signal?: AbortSignal): Promise { + try { + const payload = await this.fetchDiscussions(1, signal); + return payload.totalCount ?? null; } catch (error) { - console.error("Error fetching discussions:", error); - throw error; + console.warn("Error fetching discussions count from proxy:", error); + return null; } } - - // Mock discussions for development/fallback (unchanged) - private getMockDiscussions(): GitHubDiscussion[] { - return [ - { - id: "1", - title: "Welcome to recode hive Discussions!", - body: "This is where we discuss ideas, share knowledge, and help each other grow. Feel free to ask questions, share your projects, or just say hello!", - author: { - login: "recodehive", - avatar_url: "https://avatars.githubusercontent.com/u/your-org-id?v=4", - html_url: "https://github.com/recodehive", - }, - category: { - name: "Announcements", - emoji: "📢", - }, - created_at: new Date(Date.now() - 86400000).toISOString(), - updated_at: new Date(Date.now() - 3600000).toISOString(), - comments: 12, - reactions: { - total_count: 25, - }, - html_url: "https://github.com/recodehive/recode-website/discussions", - labels: [ - { name: "welcome", color: "0075ca" }, - { name: "community", color: "7057ff" }, - ], - }, - { - id: "2", - title: "How to contribute to open source projects?", - body: "I'm new to open source and would love to learn how to make my first contribution. Any tips or resources would be greatly appreciated!", - author: { - login: "newcontributor", - avatar_url: "https://avatars.githubusercontent.com/u/example?v=4", - html_url: "https://github.com/newcontributor", - }, - category: { - name: "Q&A", - emoji: "❓", - }, - created_at: new Date(Date.now() - 172800000).toISOString(), - updated_at: new Date(Date.now() - 7200000).toISOString(), - comments: 8, - reactions: { - total_count: 15, - }, - html_url: "https://github.com/recodehive/recode-website/discussions", - labels: [ - { name: "question", color: "d876e3" }, - { name: "beginner", color: "0e8a16" }, - ], - }, - { - id: "3", - title: "Feature Request: Dark Mode for Documentation", - body: "It would be great to have a dark mode option for the documentation pages. This would be easier on the eyes during late-night coding sessions.", - author: { - login: "darkmode-lover", - avatar_url: "https://avatars.githubusercontent.com/u/example2?v=4", - html_url: "https://github.com/darkmode-lover", - }, - category: { - name: "Ideas", - emoji: "💡", - }, - created_at: new Date(Date.now() - 259200000).toISOString(), - updated_at: new Date(Date.now() - 10800000).toISOString(), - comments: 5, - reactions: { - total_count: 22, - }, - html_url: "https://github.com/recodehive/recode-website/discussions", - labels: [ - { name: "enhancement", color: "a2eeef" }, - { name: "ui/ux", color: "f9d0c4" }, - ], - }, - ]; - } } export const githubService = new GitHubService(); diff --git a/src/types/githubDiscussions.ts b/src/types/githubDiscussions.ts new file mode 100644 index 00000000..30f3dc8b --- /dev/null +++ b/src/types/githubDiscussions.ts @@ -0,0 +1,33 @@ +export interface GitHubDiscussion { + id: string; + title: string; + body: string; + author: { + login: string; + avatar_url: string; + html_url: string; + }; + category: { + name: string; + emoji: string; + }; + created_at: string; + updated_at: string; + comments: number; + reactions: { + total_count: number; + }; + html_url: string; + labels: Array<{ + name: string; + color: string; + }>; +} + +export interface GitHubDiscussionsResponse { + available: boolean; + message: string | null; + totalCount: number | null; + discussions: GitHubDiscussion[]; + fetchedAt: string | null; +} diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 58e7830a..1eb4696b 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -100,6 +100,10 @@ declare module "@site/src/services/github" { } } +declare module "@site/src/services/githubService" { + export const githubService: any; +} + declare module "@site/src/components/ui/button" { export const Button: any; } diff --git a/wiki/Documentation.md b/wiki/Documentation.md index 1f3420ec..cfc6c2e6 100644 --- a/wiki/Documentation.md +++ b/wiki/Documentation.md @@ -583,6 +583,9 @@ const headers: Record = { Authorization: `token ${YOUR_GITHUB_TOKEN}`, Accept: "application/vnd.github.v3+json", }; +``` + +For this site, GitHub Discussions are now fetched dynamically through a server-side `/api/github-discussions` endpoint using a server-side `GITHUB_TOKEN`, and only the sanitized discussion data is exposed to the client bundle. #### Getting a Token: