Compact pill (GitHub icon | star count) placed left of the version display. Star count is fetched from the GitHub API once per 24h and cached in localStorage; the button hides entirely when no count is available (e.g. air-gapped deployments), matching the existing update- check graceful-degradation pattern. Fetch is bounded by a 4s abort timeout to avoid hanging requests offline.
141 lines
3.4 KiB
TypeScript
141 lines
3.4 KiB
TypeScript
import externalLinks from '@/constants/external-links';
|
|
import { GithubFilled } from '@ant-design/icons';
|
|
import { useIntl } from '@umijs/max';
|
|
import { Tooltip } from 'antd';
|
|
import { useEffect, useState } from 'react';
|
|
import styled from 'styled-components';
|
|
|
|
const REPO = 'gpustack/gpustack';
|
|
const CACHE_KEY = 'gpustack:github-stars';
|
|
const CACHE_TTL = 24 * 60 * 60 * 1000;
|
|
const FETCH_TIMEOUT = 4000;
|
|
|
|
const StarLink = styled.a`
|
|
display: inline-flex;
|
|
align-items: stretch;
|
|
height: 24px;
|
|
border-radius: var(--ant-border-radius);
|
|
border: 1px solid var(--ant-color-border-secondary);
|
|
background-color: var(--ant-color-bg-container);
|
|
color: var(--ant-color-text-secondary);
|
|
font-size: 12px;
|
|
line-height: 1;
|
|
overflow: hidden;
|
|
transition:
|
|
border-color 0.2s,
|
|
color 0.2s;
|
|
|
|
&:hover {
|
|
border-color: var(--ant-color-border);
|
|
color: var(--ant-color-text);
|
|
}
|
|
|
|
.seg {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.seg + .seg {
|
|
border-left: 1px solid var(--ant-color-border-secondary);
|
|
background-color: var(--ant-color-fill-quaternary);
|
|
}
|
|
|
|
.anticon {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.count {
|
|
font-weight: 500;
|
|
font-variant-numeric: tabular-nums;
|
|
min-width: 1.5em;
|
|
text-align: center;
|
|
}
|
|
`;
|
|
|
|
const formatCount = (n: number): string => {
|
|
if (n >= 1000) {
|
|
const k = n / 1000;
|
|
return k >= 10 ? `${Math.round(k)}k` : `${k.toFixed(1)}k`;
|
|
}
|
|
return String(n);
|
|
};
|
|
|
|
type CacheEntry = { value: number; time: number };
|
|
|
|
const readCache = (): CacheEntry | null => {
|
|
try {
|
|
const raw = localStorage.getItem(CACHE_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw);
|
|
if (typeof parsed?.value !== 'number' || typeof parsed?.time !== 'number') {
|
|
return null;
|
|
}
|
|
return parsed;
|
|
} catch {
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const writeCache = (value: number) => {
|
|
try {
|
|
localStorage.setItem(
|
|
CACHE_KEY,
|
|
JSON.stringify({ value, time: Date.now() })
|
|
);
|
|
} catch {
|
|
// ignore quota errors
|
|
}
|
|
};
|
|
|
|
const GithubStar = () => {
|
|
const intl = useIntl();
|
|
const [count, setCount] = useState<number | null>(
|
|
() => readCache()?.value ?? null
|
|
);
|
|
|
|
useEffect(() => {
|
|
const cached = readCache();
|
|
const fresh = cached && Date.now() - cached.time < CACHE_TTL;
|
|
if (fresh) return;
|
|
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
|
|
fetch(`https://api.github.com/repos/${REPO}`, { signal: controller.signal })
|
|
.then((r) => (r.ok ? r.json() : null))
|
|
.then((data) => {
|
|
if (!data || typeof data.stargazers_count !== 'number') return;
|
|
setCount(data.stargazers_count);
|
|
writeCache(data.stargazers_count);
|
|
})
|
|
.catch(() => {
|
|
// offline, blocked, rate-limited — stay hidden if no cache
|
|
})
|
|
.finally(() => clearTimeout(timer));
|
|
|
|
return () => {
|
|
clearTimeout(timer);
|
|
controller.abort();
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<Tooltip title={intl.formatMessage({ id: 'common.github.star.tooltip' })}>
|
|
<StarLink href={externalLinks.github} target="_blank" rel="noreferrer">
|
|
<span className="seg">
|
|
<GithubFilled />
|
|
</span>
|
|
<span className="seg">
|
|
<span className="count">
|
|
{count != null ? formatCount(count) : 'Star'}
|
|
</span>
|
|
</span>
|
|
</StarLink>
|
|
</Tooltip>
|
|
);
|
|
};
|
|
|
|
export default GithubStar;
|