diff --git a/package-lock.json b/package-lock.json index e1f81c4..31a7bfe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "fumadocs-core": "16.8.5", "fumadocs-mdx": "14.3.2", "fumadocs-ui": "16.8.5", + "lucide-react": "^1.14.0", "lucide-static": "^1.14.0", "react": "^19.2.5", "react-dom": "^19.2.5", @@ -5765,9 +5766,9 @@ } }, "node_modules/lucide-react": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.11.0.tgz", - "integrity": "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g==", + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", "license": "ISC", "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 95df64c..aff8f15 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "fumadocs-core": "16.8.5", "fumadocs-mdx": "14.3.2", "fumadocs-ui": "16.8.5", + "lucide-react": "^1.14.0", "lucide-static": "^1.14.0", "react": "^19.2.5", "react-dom": "^19.2.5", diff --git a/src/components/github-info.tsx b/src/components/github-info.tsx new file mode 100644 index 0000000..728c328 --- /dev/null +++ b/src/components/github-info.tsx @@ -0,0 +1,56 @@ +import { fetcher, useFallbackData } from "@/lib/fetcher"; +import { GitFork, Star } from "lucide-react"; +import useSWR from "swr"; + +const defaultFormatter = new Intl.NumberFormat("en", { + notation: "compact", + maximumFractionDigits: 1, +}); + +type RepoInfo = { + stargazers_count: number; + forks: number; +}; + +export function GithubInfo() { + const { + data: fallbackData, + hasFreshData, + setCachedData, + markCacheWindow, + } = useFallbackData("github_info"); + const { data } = useSWR("https://api.github.com/repos/OrcaCD/orca-cd", fetcher, { + onSuccess: setCachedData, + onError: markCacheWindow, + fallbackData: fallbackData ?? undefined, + revalidateOnMount: !hasFreshData, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }); + + const repoData = data ?? fallbackData; + + return ( + +

+ + GitHub + + + OrcaCD/orca-cd +

+
+ + {!repoData ? "..." : defaultFormatter.format(repoData.stargazers_count)} + + {!repoData ? "..." : defaultFormatter.format(repoData.forks)} +
+
+ ); +} diff --git a/src/components/github-release.tsx b/src/components/github-release.tsx index 04c6eeb..34904d0 100644 --- a/src/components/github-release.tsx +++ b/src/components/github-release.tsx @@ -1,12 +1,34 @@ +import { fetcher, useFallbackData } from "@/lib/fetcher"; import useSWR from "swr"; +type ReleaseData = { + tag_name: string; +}; + export function GitHubRelease() { - const { data, isLoading } = useSWR( + const { + data: fallbackData, + hasFreshData, + setCachedData, + markCacheWindow, + } = useFallbackData("github_release"); + const { data, isLoading } = useSWR( "https://api.github.com/repos/OrcaCD/orca-cd/releases/latest", - // oxlint-disable-next-line promise/prefer-await-to-then - (...args) => fetch(...args).then((res) => res.json()), + fetcher, + { + onSuccess: setCachedData, + onError: markCacheWindow, + fallbackData: fallbackData ?? undefined, + revalidateOnMount: !hasFreshData, + revalidateOnFocus: false, + revalidateOnReconnect: false, + shouldRetryOnError: false, + }, ); + const releaseData = data ?? fallbackData; + const isPending = !releaseData && isLoading; + return ( - {isLoading ? "..." : (data.tag_name ?? "No release yet")} + {isPending ? "..." : (releaseData?.tag_name ?? "No release yet")} ); } diff --git a/src/lib/fetcher.ts b/src/lib/fetcher.ts new file mode 100644 index 0000000..c2b9848 --- /dev/null +++ b/src/lib/fetcher.ts @@ -0,0 +1,117 @@ +import { useEffect, useState } from "react"; + +const DEFAULT_CACHE_TTL_MS = 60 * 60 * 1000; + +type LocalStorageCache = { + value: JSON | null; + expiresAt: number; +}; + +type FallbackDataState = { + data: JSON | null; + hasFreshData: boolean; +}; + +function isLocalStorageCache(value: unknown): value is LocalStorageCache { + if (!value || typeof value !== "object") { + return false; + } + + if (!("value" in value) || !("expiresAt" in value)) { + return false; + } + + return typeof (value as { expiresAt: unknown }).expiresAt === "number"; +} + +function readFallbackData(key: string): FallbackDataState { + if (typeof window === "undefined") { + return { + data: null, + hasFreshData: false, + }; + } + + const cached = localStorage.getItem(key); + if (!cached) { + return { + data: null, + hasFreshData: false, + }; + } + + try { + const parsed = JSON.parse(cached) as unknown; + + if (isLocalStorageCache(parsed)) { + return { + data: parsed.value, + hasFreshData: parsed.expiresAt > Date.now(), + }; + } + + // Backward compatibility for old cache entries without metadata. + return { + data: parsed as JSON, + hasFreshData: false, + }; + } catch { + return { + data: null, + hasFreshData: false, + }; + } +} + +export async function fetcher(input: RequestInfo, init?: RequestInit): Promise { + const res = await fetch(input, init); + if (!res.ok) { + throw new Error(`An error occurred while fetching the data: ${res.statusText}`); + } + return (await res.json()) as JSON; +} + +export function useFallbackData(key: string, ttlMs = DEFAULT_CACHE_TTL_MS) { + const [fallbackData, setFallbackData] = useState>(() => + readFallbackData(key), + ); + + const setCacheEntry = (data: JSON | null) => { + if (typeof window === "undefined") { + return; + } + + const cachePayload: LocalStorageCache = { + value: data, + expiresAt: Date.now() + ttlMs, + }; + + localStorage.setItem(key, JSON.stringify(cachePayload)); + setFallbackData({ + data, + hasFreshData: true, + }); + }; + + useEffect(() => { + setFallbackData(readFallbackData(key)); + + const handleStorageChange = (event: StorageEvent) => { + if (event.key === key) { + setFallbackData(readFallbackData(key)); + } + }; + + window.addEventListener("storage", handleStorageChange); + return () => { + window.removeEventListener("storage", handleStorageChange); + }; + }, [key]); + + return { + data: fallbackData.data, + hasFreshData: fallbackData.hasFreshData, + setCachedData: setCacheEntry, + markCacheWindow: () => setCacheEntry(fallbackData.data), + }; +} diff --git a/src/lib/layout.shared.tsx b/src/lib/layout.shared.tsx index b8edb24..2115bcf 100644 --- a/src/lib/layout.shared.tsx +++ b/src/lib/layout.shared.tsx @@ -5,10 +5,9 @@ import { NavbarMenuLink, NavbarMenuTrigger, } from "fumadocs-ui/layouts/home/navbar"; -import { GithubInfo } from "fumadocs-ui/components/github-info"; - import type { BaseLayoutProps, LinkItemType } from "fumadocs-ui/layouts/shared"; import { GitHubRelease } from "@/components/github-release"; +import { GithubInfo } from "@/components/github-info"; export function baseOptions(): BaseLayoutProps { return { @@ -86,6 +85,6 @@ export const navbarLinks: LinkItemType[] = [ { type: "custom", secondary: true, - children: , + children: , }, ];