Copilot commented on code in PR #464:
URL: https://github.com/apache/casbin-website/pull/464#discussion_r3071300915
##########
src/theme/Footer/Links/MultiColumn/index.js:
##########
@@ -0,0 +1,75 @@
+import React from "react";
+import clsx from "clsx";
+import {ThemeClassNames} from "@docusaurus/theme-common";
+import LinkItem from "@theme/Footer/LinkItem";
+import FooterMoreBadges from "@site/src/components/FooterMoreBadges";
+
+function ColumnLinkItem({item}) {
+ return item.html ? (
+ <li
+ className={clsx("footer__item", item.className)}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <li key={item.href ?? item.to} className="footer__item">
+ <LinkItem item={item} />
+ </li>
+ );
+}
+
+function CustomColumnItem({item}) {
+ return item.html ? (
+ <div
+ className={item.className}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <div className="footer__item">
+ <LinkItem item={item} />
+ </div>
+ );
+}
+
+function Column({column}) {
+ const isFooterMoreColumn =
column.className?.split(/\s+/u).includes("footer-more-column");
+
+ return (
+ <div
+ className={clsx(
+ ThemeClassNames.layout.footer.column,
+ "col footer__col",
+ column.className
+ )}
+ >
+ <div className="footer__title">{column.title}</div>
+ {isFooterMoreColumn ? (
+ <>
+ <FooterMoreBadges />
+ {column.items.map((item, index) => (
+ <CustomColumnItem key={index} item={item} />
+ ))}
Review Comment:
Using array indices as React keys can cause incorrect reconciliation when
items/columns are reordered/inserted/removed, leading to subtle UI bugs. Prefer
stable keys derived from the data (e.g., `item.href ?? item.to ?? item.label`
and `column.title`/`column.className` if stable/unique).
##########
src/components/FooterMoreBadges/index.js:
##########
@@ -0,0 +1,379 @@
+import React, {useEffect, useId, useRef, useState} from "react";
+import styles from "./styles.module.css";
+
+const CACHE_KEY = "casbin-footer-github-stars-v1";
+const CACHE_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+const STAR_FORMATTER = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: 1,
+});
+
+const GITHUB_BADGES = [
+ {
+ label: "Casbin",
+ owner: "casbin",
+ repo: "casbin",
+ href: "https://github.com/casbin/casbin",
+ labelWidth: 64,
+ },
+ {
+ label: "jCasbin",
+ owner: "casbin",
+ repo: "jcasbin",
+ href: "https://github.com/casbin/jcasbin",
+ labelWidth: 70,
+ },
+ {
+ label: "Node-Casbin",
+ owner: "casbin",
+ repo: "node-casbin",
+ href: "https://github.com/casbin/node-casbin",
+ labelWidth: 96,
+ },
+ {
+ label: "PHP-Casbin",
+ owner: "php-casbin",
+ repo: "php-casbin",
+ href: "https://github.com/php-casbin/php-casbin",
+ labelWidth: 90,
+ },
+ {
+ label: "PyCasbin",
+ owner: "casbin",
+ repo: "pycasbin",
+ href: "https://github.com/casbin/pycasbin",
+ labelWidth: 76,
+ },
+ {
+ label: "Casbin.NET",
+ owner: "casbin",
+ repo: "Casbin.NET",
+ href: "https://github.com/casbin/Casbin.NET",
+ labelWidth: 88,
+ },
+ {
+ label: "Casbin-CPP",
+ owner: "casbin",
+ repo: "casbin-cpp",
+ href: "https://github.com/casbin/casbin-cpp",
+ labelWidth: 90,
+ },
+ {
+ label: "Casbin-RS",
+ owner: "casbin",
+ repo: "casbin-rs",
+ href: "https://github.com/casbin/casbin-rs",
+ labelWidth: 84,
+ },
+];
+
+const DEFAULT_STARS = {
+ "casbin/casbin": 19992,
+ "casbin/jcasbin": 2631,
+ "casbin/node-casbin": 2882,
+ "php-casbin/php-casbin": 1326,
+ "casbin/pycasbin": 1724,
+ "casbin/Casbin.NET": 1309,
+ "casbin/casbin-cpp": 250,
+ "casbin/casbin-rs": 1090,
+};
+
+// github SVG path
+const GITHUB_ICON_PATH =
+ "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205
11.385.6.113.82-.258.82-.577
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633
17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236
1.07 1.835 2.809 1.305
3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93
0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3
1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23
3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805
5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0
.315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
+// X SVG path
+const X_ICON_PATH =
+ "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258
24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076
1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.186z";
+
+function getRepoKey(owner, repo) {
+ return `${owner}/${repo}`;
+}
+
+function formatStars(count) {
+ return STAR_FORMATTER.format(count).replace(/\.0(?=[A-Za-z])/u,
"").toLowerCase();
+}
+
+function getCountWidth(value) {
+ return Math.max(27, 12 + value.length * 4);
+}
+
+function readCachedStars() {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const cachedValue = window.localStorage.getItem(CACHE_KEY);
+
+ if (!cachedValue) {
+ return null;
+ }
+
+ const parsedValue = JSON.parse(cachedValue);
+
+ if (!parsedValue || typeof parsedValue.updatedAt !== "number" ||
!parsedValue.stars) {
+ return null;
+ }
+
+ return parsedValue;
+ } catch {
+ return null;
+ }
+}
+
+function writeCachedStars(stars) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify({
+ updatedAt: Date.now(),
+ stars,
+ }));
+ } catch {
+ // Ignore localStorage write errors.
+ }
+}
+
+async function fetchGithubStars() {
+ const responses = await Promise.all(
+ GITHUB_BADGES.map(async({owner, repo}) => {
+ const response = await
fetch(`https://api.github.com/repos/${owner}/${repo}`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stars for ${owner}/${repo}`);
+ }
+
+ const data = await response.json();
+ const repoKey = getRepoKey(owner, repo);
+
+ if (typeof data.stargazers_count !== "number") {
+ throw new Error(`Missing stargazers_count for ${repoKey}`);
+ }
+
+ return [repoKey, data.stargazers_count];
+ })
+ );
+
+ return Object.fromEntries(responses);
+}
+
+function GitHubStarBadge({label, labelWidth, stars}) {
+ const badgeId = useId().replace(/:/gu, "");
+ const gradientId = `footer-badge-gradient-${badgeId}`;
+ const formattedStars = formatStars(stars);
Review Comment:
`stars` can be `undefined` (e.g., new badge added without updating
`DEFAULT_STARS`, cache missing/corrupt, or partial fetch results).
`Intl.NumberFormat().format(undefined)` yields `"NaN"`, which will render as
`nan` in the UI. Make `formatStars` resilient (coerce to number and fall back),
or pass a guaranteed numeric value (e.g., `starsByRepo[repoKey] ??
DEFAULT_STARS[repoKey] ?? 0`).
##########
src/theme/Footer/Links/MultiColumn/index.js:
##########
@@ -0,0 +1,75 @@
+import React from "react";
+import clsx from "clsx";
+import {ThemeClassNames} from "@docusaurus/theme-common";
+import LinkItem from "@theme/Footer/LinkItem";
+import FooterMoreBadges from "@site/src/components/FooterMoreBadges";
+
+function ColumnLinkItem({item}) {
+ return item.html ? (
+ <li
+ className={clsx("footer__item", item.className)}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <li key={item.href ?? item.to} className="footer__item">
+ <LinkItem item={item} />
+ </li>
+ );
+}
+
+function CustomColumnItem({item}) {
+ return item.html ? (
+ <div
+ className={item.className}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <div className="footer__item">
+ <LinkItem item={item} />
+ </div>
+ );
+}
+
+function Column({column}) {
+ const isFooterMoreColumn =
column.className?.split(/\s+/u).includes("footer-more-column");
+
+ return (
+ <div
+ className={clsx(
+ ThemeClassNames.layout.footer.column,
+ "col footer__col",
+ column.className
+ )}
+ >
+ <div className="footer__title">{column.title}</div>
+ {isFooterMoreColumn ? (
+ <>
+ <FooterMoreBadges />
+ {column.items.map((item, index) => (
+ <CustomColumnItem key={index} item={item} />
+ ))}
+ </>
+ ) : (
+ <ul className="footer__items clean-list">
+ {column.items.map((item, index) => (
+ <ColumnLinkItem key={index} item={item} />
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+export default function FooterLinksMultiColumn({columns}) {
+ return (
+ <div className="row footer__links">
+ {columns.map((column, index) => (
+ <Column key={index} column={column} />
+ ))}
Review Comment:
Using array indices as React keys can cause incorrect reconciliation when
items/columns are reordered/inserted/removed, leading to subtle UI bugs. Prefer
stable keys derived from the data (e.g., `item.href ?? item.to ?? item.label`
and `column.title`/`column.className` if stable/unique).
##########
src/theme/Footer/Links/MultiColumn/index.js:
##########
@@ -0,0 +1,75 @@
+import React from "react";
+import clsx from "clsx";
+import {ThemeClassNames} from "@docusaurus/theme-common";
+import LinkItem from "@theme/Footer/LinkItem";
+import FooterMoreBadges from "@site/src/components/FooterMoreBadges";
+
+function ColumnLinkItem({item}) {
+ return item.html ? (
+ <li
+ className={clsx("footer__item", item.className)}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <li key={item.href ?? item.to} className="footer__item">
+ <LinkItem item={item} />
+ </li>
+ );
+}
+
+function CustomColumnItem({item}) {
+ return item.html ? (
+ <div
+ className={item.className}
+ // Developer provided the HTML, so assume it's safe.
+ // eslint-disable-next-line react/no-danger
+ dangerouslySetInnerHTML={{__html: item.html}}
+ />
+ ) : (
+ <div className="footer__item">
+ <LinkItem item={item} />
+ </div>
+ );
+}
+
+function Column({column}) {
+ const isFooterMoreColumn =
column.className?.split(/\s+/u).includes("footer-more-column");
+
+ return (
+ <div
+ className={clsx(
+ ThemeClassNames.layout.footer.column,
+ "col footer__col",
+ column.className
+ )}
+ >
+ <div className="footer__title">{column.title}</div>
+ {isFooterMoreColumn ? (
+ <>
+ <FooterMoreBadges />
+ {column.items.map((item, index) => (
+ <CustomColumnItem key={index} item={item} />
+ ))}
+ </>
+ ) : (
+ <ul className="footer__items clean-list">
+ {column.items.map((item, index) => (
+ <ColumnLinkItem key={index} item={item} />
+ ))}
Review Comment:
Using array indices as React keys can cause incorrect reconciliation when
items/columns are reordered/inserted/removed, leading to subtle UI bugs. Prefer
stable keys derived from the data (e.g., `item.href ?? item.to ?? item.label`
and `column.title`/`column.className` if stable/unique).
##########
src/components/FooterMoreBadges/index.js:
##########
@@ -0,0 +1,379 @@
+import React, {useEffect, useId, useRef, useState} from "react";
+import styles from "./styles.module.css";
+
+const CACHE_KEY = "casbin-footer-github-stars-v1";
+const CACHE_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+const STAR_FORMATTER = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: 1,
+});
+
+const GITHUB_BADGES = [
+ {
+ label: "Casbin",
+ owner: "casbin",
+ repo: "casbin",
+ href: "https://github.com/casbin/casbin",
+ labelWidth: 64,
+ },
+ {
+ label: "jCasbin",
+ owner: "casbin",
+ repo: "jcasbin",
+ href: "https://github.com/casbin/jcasbin",
+ labelWidth: 70,
+ },
+ {
+ label: "Node-Casbin",
+ owner: "casbin",
+ repo: "node-casbin",
+ href: "https://github.com/casbin/node-casbin",
+ labelWidth: 96,
+ },
+ {
+ label: "PHP-Casbin",
+ owner: "php-casbin",
+ repo: "php-casbin",
+ href: "https://github.com/php-casbin/php-casbin",
+ labelWidth: 90,
+ },
+ {
+ label: "PyCasbin",
+ owner: "casbin",
+ repo: "pycasbin",
+ href: "https://github.com/casbin/pycasbin",
+ labelWidth: 76,
+ },
+ {
+ label: "Casbin.NET",
+ owner: "casbin",
+ repo: "Casbin.NET",
+ href: "https://github.com/casbin/Casbin.NET",
+ labelWidth: 88,
+ },
+ {
+ label: "Casbin-CPP",
+ owner: "casbin",
+ repo: "casbin-cpp",
+ href: "https://github.com/casbin/casbin-cpp",
+ labelWidth: 90,
+ },
+ {
+ label: "Casbin-RS",
+ owner: "casbin",
+ repo: "casbin-rs",
+ href: "https://github.com/casbin/casbin-rs",
+ labelWidth: 84,
+ },
+];
+
+const DEFAULT_STARS = {
+ "casbin/casbin": 19992,
+ "casbin/jcasbin": 2631,
+ "casbin/node-casbin": 2882,
+ "php-casbin/php-casbin": 1326,
+ "casbin/pycasbin": 1724,
+ "casbin/Casbin.NET": 1309,
+ "casbin/casbin-cpp": 250,
+ "casbin/casbin-rs": 1090,
+};
+
+// github SVG path
+const GITHUB_ICON_PATH =
+ "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205
11.385.6.113.82-.258.82-.577
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633
17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236
1.07 1.835 2.809 1.305
3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93
0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3
1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23
3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805
5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0
.315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
+// X SVG path
+const X_ICON_PATH =
+ "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258
24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076
1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.186z";
+
+function getRepoKey(owner, repo) {
+ return `${owner}/${repo}`;
+}
+
+function formatStars(count) {
+ return STAR_FORMATTER.format(count).replace(/\.0(?=[A-Za-z])/u,
"").toLowerCase();
+}
+
+function getCountWidth(value) {
+ return Math.max(27, 12 + value.length * 4);
+}
+
+function readCachedStars() {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const cachedValue = window.localStorage.getItem(CACHE_KEY);
+
+ if (!cachedValue) {
+ return null;
+ }
+
+ const parsedValue = JSON.parse(cachedValue);
+
+ if (!parsedValue || typeof parsedValue.updatedAt !== "number" ||
!parsedValue.stars) {
+ return null;
+ }
+
+ return parsedValue;
+ } catch {
+ return null;
+ }
+}
+
+function writeCachedStars(stars) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify({
+ updatedAt: Date.now(),
+ stars,
+ }));
+ } catch {
+ // Ignore localStorage write errors.
+ }
+}
+
+async function fetchGithubStars() {
+ const responses = await Promise.all(
+ GITHUB_BADGES.map(async({owner, repo}) => {
+ const response = await
fetch(`https://api.github.com/repos/${owner}/${repo}`);
Review Comment:
This makes multiple unauthenticated GitHub API calls from every client
session (8 repos per refresh cycle), which is likely to hit rate limits under
real traffic and will cause many clients to silently show stale values. A more
reliable approach is to fetch stars server-side (build-time, scheduled job, or
a small API endpoint with caching) and serve a single cached payload to
clients; alternatively, batch via GitHub GraphQL to reduce request count.
##########
src/components/FooterMoreBadges/index.js:
##########
@@ -0,0 +1,379 @@
+import React, {useEffect, useId, useRef, useState} from "react";
+import styles from "./styles.module.css";
+
+const CACHE_KEY = "casbin-footer-github-stars-v1";
+const CACHE_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+const STAR_FORMATTER = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: 1,
+});
+
+const GITHUB_BADGES = [
+ {
+ label: "Casbin",
+ owner: "casbin",
+ repo: "casbin",
+ href: "https://github.com/casbin/casbin",
+ labelWidth: 64,
+ },
+ {
+ label: "jCasbin",
+ owner: "casbin",
+ repo: "jcasbin",
+ href: "https://github.com/casbin/jcasbin",
+ labelWidth: 70,
+ },
+ {
+ label: "Node-Casbin",
+ owner: "casbin",
+ repo: "node-casbin",
+ href: "https://github.com/casbin/node-casbin",
+ labelWidth: 96,
+ },
+ {
+ label: "PHP-Casbin",
+ owner: "php-casbin",
+ repo: "php-casbin",
+ href: "https://github.com/php-casbin/php-casbin",
+ labelWidth: 90,
+ },
+ {
+ label: "PyCasbin",
+ owner: "casbin",
+ repo: "pycasbin",
+ href: "https://github.com/casbin/pycasbin",
+ labelWidth: 76,
+ },
+ {
+ label: "Casbin.NET",
+ owner: "casbin",
+ repo: "Casbin.NET",
+ href: "https://github.com/casbin/Casbin.NET",
+ labelWidth: 88,
+ },
+ {
+ label: "Casbin-CPP",
+ owner: "casbin",
+ repo: "casbin-cpp",
+ href: "https://github.com/casbin/casbin-cpp",
+ labelWidth: 90,
+ },
+ {
+ label: "Casbin-RS",
+ owner: "casbin",
+ repo: "casbin-rs",
+ href: "https://github.com/casbin/casbin-rs",
+ labelWidth: 84,
+ },
+];
+
+const DEFAULT_STARS = {
+ "casbin/casbin": 19992,
+ "casbin/jcasbin": 2631,
+ "casbin/node-casbin": 2882,
+ "php-casbin/php-casbin": 1326,
+ "casbin/pycasbin": 1724,
+ "casbin/Casbin.NET": 1309,
+ "casbin/casbin-cpp": 250,
+ "casbin/casbin-rs": 1090,
+};
+
+// github SVG path
+const GITHUB_ICON_PATH =
+ "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205
11.385.6.113.82-.258.82-.577
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633
17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236
1.07 1.835 2.809 1.305
3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93
0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3
1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23
3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805
5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0
.315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
+// X SVG path
+const X_ICON_PATH =
+ "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258
24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076
1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.186z";
+
+function getRepoKey(owner, repo) {
+ return `${owner}/${repo}`;
+}
+
+function formatStars(count) {
+ return STAR_FORMATTER.format(count).replace(/\.0(?=[A-Za-z])/u,
"").toLowerCase();
+}
+
+function getCountWidth(value) {
+ return Math.max(27, 12 + value.length * 4);
+}
+
+function readCachedStars() {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const cachedValue = window.localStorage.getItem(CACHE_KEY);
+
+ if (!cachedValue) {
+ return null;
+ }
+
+ const parsedValue = JSON.parse(cachedValue);
+
+ if (!parsedValue || typeof parsedValue.updatedAt !== "number" ||
!parsedValue.stars) {
+ return null;
+ }
+
+ return parsedValue;
+ } catch {
+ return null;
+ }
+}
+
+function writeCachedStars(stars) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify({
+ updatedAt: Date.now(),
+ stars,
+ }));
+ } catch {
+ // Ignore localStorage write errors.
+ }
+}
+
+async function fetchGithubStars() {
+ const responses = await Promise.all(
+ GITHUB_BADGES.map(async({owner, repo}) => {
+ const response = await
fetch(`https://api.github.com/repos/${owner}/${repo}`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stars for ${owner}/${repo}`);
+ }
+
+ const data = await response.json();
+ const repoKey = getRepoKey(owner, repo);
+
+ if (typeof data.stargazers_count !== "number") {
+ throw new Error(`Missing stargazers_count for ${repoKey}`);
+ }
+
+ return [repoKey, data.stargazers_count];
+ })
+ );
+
+ return Object.fromEntries(responses);
+}
+
+function GitHubStarBadge({label, labelWidth, stars}) {
+ const badgeId = useId().replace(/:/gu, "");
+ const gradientId = `footer-badge-gradient-${badgeId}`;
+ const formattedStars = formatStars(stars);
+ const countWidth = getCountWidth(formattedStars);
+ const badgeWidth = labelWidth + countWidth + 7;
+ const labelCenterX = labelWidth / 2 + 8.5;
+ const countCenterX = labelWidth + 6 + countWidth / 2;
+
+ return (
+ <svg
+ className={styles.badgeSvg}
+ width={badgeWidth}
+ height="20"
Review Comment:
The PR title says “replace footer more section with text links”, but the
implemented UI appears to be custom SVG “badge” images (plus background refresh
logic). If the intent is truly “text links”, this should render textual link
content instead of SVG badges; otherwise, consider updating the PR
title/description to match the implemented badge-based design and the new
GitHub API polling behavior.
##########
src/components/FooterMoreBadges/index.js:
##########
@@ -0,0 +1,379 @@
+import React, {useEffect, useId, useRef, useState} from "react";
+import styles from "./styles.module.css";
+
+const CACHE_KEY = "casbin-footer-github-stars-v1";
+const CACHE_DURATION_MS = 2 * 60 * 60 * 1000; // 2 hours
+
+const STAR_FORMATTER = new Intl.NumberFormat("en-US", {
+ notation: "compact",
+ compactDisplay: "short",
+ maximumFractionDigits: 1,
+});
+
+const GITHUB_BADGES = [
+ {
+ label: "Casbin",
+ owner: "casbin",
+ repo: "casbin",
+ href: "https://github.com/casbin/casbin",
+ labelWidth: 64,
+ },
+ {
+ label: "jCasbin",
+ owner: "casbin",
+ repo: "jcasbin",
+ href: "https://github.com/casbin/jcasbin",
+ labelWidth: 70,
+ },
+ {
+ label: "Node-Casbin",
+ owner: "casbin",
+ repo: "node-casbin",
+ href: "https://github.com/casbin/node-casbin",
+ labelWidth: 96,
+ },
+ {
+ label: "PHP-Casbin",
+ owner: "php-casbin",
+ repo: "php-casbin",
+ href: "https://github.com/php-casbin/php-casbin",
+ labelWidth: 90,
+ },
+ {
+ label: "PyCasbin",
+ owner: "casbin",
+ repo: "pycasbin",
+ href: "https://github.com/casbin/pycasbin",
+ labelWidth: 76,
+ },
+ {
+ label: "Casbin.NET",
+ owner: "casbin",
+ repo: "Casbin.NET",
+ href: "https://github.com/casbin/Casbin.NET",
+ labelWidth: 88,
+ },
+ {
+ label: "Casbin-CPP",
+ owner: "casbin",
+ repo: "casbin-cpp",
+ href: "https://github.com/casbin/casbin-cpp",
+ labelWidth: 90,
+ },
+ {
+ label: "Casbin-RS",
+ owner: "casbin",
+ repo: "casbin-rs",
+ href: "https://github.com/casbin/casbin-rs",
+ labelWidth: 84,
+ },
+];
+
+const DEFAULT_STARS = {
+ "casbin/casbin": 19992,
+ "casbin/jcasbin": 2631,
+ "casbin/node-casbin": 2882,
+ "php-casbin/php-casbin": 1326,
+ "casbin/pycasbin": 1724,
+ "casbin/Casbin.NET": 1309,
+ "casbin/casbin-cpp": 250,
+ "casbin/casbin-rs": 1090,
+};
+
+// github SVG path
+const GITHUB_ICON_PATH =
+ "M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205
11.385.6.113.82-.258.82-.577
0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633
17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236
1.07 1.835 2.809 1.305
3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93
0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3
1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23
3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805
5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0
.315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12";
+// X SVG path
+const X_ICON_PATH =
+ "M14.234 10.162 22.977 0h-2.072l-7.591 8.824L7.251 0H.258l9.168 13.343L.258
24H2.33l8.016-9.318L16.749 24h6.993zm-2.837 3.299-.929-1.329L3.076
1.56h3.182l5.965 8.532.929 1.329 7.754 11.09h-3.186z";
+
+function getRepoKey(owner, repo) {
+ return `${owner}/${repo}`;
+}
+
+function formatStars(count) {
+ return STAR_FORMATTER.format(count).replace(/\.0(?=[A-Za-z])/u,
"").toLowerCase();
+}
+
+function getCountWidth(value) {
+ return Math.max(27, 12 + value.length * 4);
+}
+
+function readCachedStars() {
+ if (typeof window === "undefined") {
+ return null;
+ }
+
+ try {
+ const cachedValue = window.localStorage.getItem(CACHE_KEY);
+
+ if (!cachedValue) {
+ return null;
+ }
+
+ const parsedValue = JSON.parse(cachedValue);
+
+ if (!parsedValue || typeof parsedValue.updatedAt !== "number" ||
!parsedValue.stars) {
+ return null;
+ }
+
+ return parsedValue;
+ } catch {
+ return null;
+ }
+}
+
+function writeCachedStars(stars) {
+ if (typeof window === "undefined") {
+ return;
+ }
+
+ try {
+ window.localStorage.setItem(CACHE_KEY, JSON.stringify({
+ updatedAt: Date.now(),
+ stars,
+ }));
+ } catch {
+ // Ignore localStorage write errors.
+ }
+}
+
+async function fetchGithubStars() {
+ const responses = await Promise.all(
+ GITHUB_BADGES.map(async({owner, repo}) => {
+ const response = await
fetch(`https://api.github.com/repos/${owner}/${repo}`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch stars for ${owner}/${repo}`);
+ }
+
+ const data = await response.json();
+ const repoKey = getRepoKey(owner, repo);
+
+ if (typeof data.stargazers_count !== "number") {
+ throw new Error(`Missing stargazers_count for ${repoKey}`);
+ }
+
+ return [repoKey, data.stargazers_count];
+ })
+ );
+
+ return Object.fromEntries(responses);
+}
Review Comment:
`Promise.all` will reject the entire refresh if *any* repo request fails
(rate limit, network hiccup, one repo renamed, etc.), meaning you lose updates
for all repos in that cycle. Consider using `Promise.allSettled` (or
per-request try/catch) to merge successful responses while keeping existing
values for failures.
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]