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 212833d7..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: [ @@ -266,10 +265,11 @@ const config: Config = { markdown: { mermaid: true, + hooks: { + onBrokenMarkdownLinks: "warn", + }, }, - // Migrated legacy setting to markdown.hooks.onBrokenMarkdownLinks - themes: ["@docusaurus/theme-mermaid"], plugins: [ @@ -285,18 +285,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", - hooks: { - onBrokenMarkdownLinks: "warn", - }, + process.env.SHOPIFY_STOREFRONT_ACCESS_TOKEN, }, }; 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 923c16c5..51d57a00 100644 --- a/src/lib/statsProvider.tsx +++ b/src/lib/statsProvider.tsx @@ -7,8 +7,7 @@ import React, { useState, type ReactNode, } from "react"; -import { githubService, type GitHubOrgStats } from "../services/githubService"; -import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import { githubService } from "../services/githubService"; // Time filter types export type TimeFilter = "week" | "month" | "year" | "all"; @@ -22,7 +21,7 @@ interface ICommunityStatsContext { githubForksCountText: string; githubReposCount: number; githubReposCountText: string; - githubDiscussionsCount: number; + githubDiscussionsCount: number | null; githubDiscussionsCountText: string; loading: boolean; error: string | null; @@ -160,18 +159,15 @@ 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 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 @@ -433,24 +429,16 @@ 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", }; // 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 @@ -458,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 @@ -491,13 +479,13 @@ export function CommunityStatsProvider({ setGithubContributorsCount(140); setGithubForksCount(0); setGithubReposCount(20); - setGithubDiscussionsCount(0); + setGithubDiscussionsCount(null); } } finally { setLoading(false); } }, - [token, fetchAllOrgRepos, processBatch, cache], + [fetchAllOrgRepos, processBatch, cache], ); const clearCache = useCallback(() => { @@ -577,7 +565,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/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 7e5183eb..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,17 @@ 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", ); @@ -288,7 +273,7 @@ const DashboardContent: React.FC = () => { }); }; - const filteredDiscussions = React.useMemo( + const filteredDiscussions = useMemo( () => getFilteredDiscussions(discussions), [discussions, activeDiscussionTab, selectedCategory, searchQuery, sortBy], ); @@ -312,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 9b9aece7..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; @@ -10,7 +11,7 @@ export interface GitHubOrgStats { totalRepositories: number; totalContributors: number; publicRepositories: number; - discussionsCount: number; + discussionsCount: number | null; lastUpdated: number; } @@ -33,67 +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_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; } // === ADDED: setter to toggle anonymous contributors inclusion @@ -287,54 +243,6 @@ 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). - private async getDiscussionsCount( - signal?: AbortSignal, - repoName: string = "Support", - ): Promise { - try { - // GraphQL query to get discussions totalCount for a repository - 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.getHeaders(), - "Content-Type": "application/json", - }, - body: JSON.stringify({ query, variables }), - signal, - }); - - if (!resp.ok) { - console.warn(`GraphQL request for discussions failed: ${resp.status}`); - return 0; - } - - const data = await resp.json(); - if (data.errors) { - console.warn("GraphQL errors while fetching discussions:", data.errors); - return 0; - } - - const count = data?.data?.repository?.discussions?.totalCount || 0; - return Number(count); - } catch (error) { - console.warn("Error fetching discussions count via GraphQL:", error); - return 0; - } - } - // Main method to fetch all organization statistics async fetchOrganizationStats(signal?: AbortSignal): Promise { // Try to get cached data first @@ -344,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), ]); @@ -363,12 +270,10 @@ class GitHubService { 0, ); - // Estimate contributors and get discussions count - // === UPDATED: getDiscussionsCount now uses GraphQL for a specific repo (default 'Support') - const [totalContributors, discussionsCount] = await Promise.all([ - this.estimateContributors(activeRepos, signal), - this.getDiscussionsCount(signal), // default repoName: "Support" (change if you prefer another repo) - ]); + const totalContributors = await this.estimateContributors( + activeRepos, + signal, + ); const stats: GitHubOrgStats = { totalStars, @@ -376,7 +281,7 @@ class GitHubService { totalRepositories: repositories.length, publicRepositories: activeRepos.length, totalContributors, - discussionsCount, + discussionsCount: null, lastUpdated: Date.now(), }; @@ -394,7 +299,7 @@ class GitHubService { totalRepositories: 0, publicRepositories: 0, totalContributors: 0, - discussionsCount: 0, + discussionsCount: null, lastUpdated: Date.now(), }; @@ -422,195 +327,38 @@ 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 { - 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: { - ...this.getHeaders(), - "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); - - // Return mock data for development/fallback - return this.getMockDiscussions(); + 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 3a4e5021..cfc6c2e6 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 @@ -573,12 +577,15 @@ 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}`, 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: @@ -588,22 +595,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)**