Files
iptvnator/apps/website/src/lib/github-stats.ts
T
4grayandClaude Fable 5.1 baba0529ef feat(website): rebuild the hero, features, download and support sections (#1551)
* feat(website): rebuild the hero, features, download and support sections

Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.

- Hero: left-aligned headline, a primary button that follows the
  visitor's OS (server-rendered fallback goes to /download/), an "All
  platforms" button and a text link for self-hosting. Project numbers
  (stars, downloads, languages, license) are set in the page's own
  typography from the GitHub API at build time (`lib/github-stats.ts`);
  anything the build cannot resolve is left out rather than faked. A
  small "Now playing" card with fictional demo content replaces the
  mascot, badges and social chips; the screenshot is lit by a blurred
  copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
  posters with a resume bar, a download queue with a REC dot, a remote
  D-pad) instead of numbered cards with icon watermarks and a marquee.
  The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
  "Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
  package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
  which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
  unused broadcast-wave, support-signal and watermark illustrations are
  removed.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* test(website): cover the rebuilt home sections and the OS-aware download CTA

`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.

`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(website): keep iPads on the generic download path

iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 00:51:46 +02:00

101 lines
3.4 KiB
TypeScript

import { GITHUB_REPO } from './downloads';
/**
* Project numbers for the home page hero, resolved once per build from the
* GitHub API. The hero used to embed shields.io badges for these; rendering
* them as text keeps the page self-contained and lets the numbers sit in the
* page's own typography. Any number the API cannot supply (offline build,
* rate limit, `WEBSITE_SKIP_RELEASE_FETCH=1`) is `null` and simply not shown —
* a stale or invented figure would be worse than a missing one.
*/
export interface GitHubStats {
stars: number | null;
/** Sum of asset download counts over the newest 100 releases. */
downloads: number | null;
}
const API_ROOT = `https://api.github.com/repos/${GITHUB_REPO}`;
const FETCH_TIMEOUT_MS = 8000;
const EMPTY: GitHubStats = { stars: null, downloads: null };
let statsPromise: Promise<GitHubStats> | undefined;
/** Resolves once per build; every page shares the same answer. */
export function getGitHubStats(): Promise<GitHubStats> {
statsPromise ??= resolveStats();
return statsPromise;
}
/** `7013` → `7.0k`, `1_134_000` → `1.1M`. */
export function formatCompactCount(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value >= 10_000) {
return `${Math.round(value / 1_000)}k`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(1)}k`;
}
return String(value);
}
async function resolveStats(): Promise<GitHubStats> {
if (process.env.WEBSITE_SKIP_RELEASE_FETCH === '1') {
return EMPTY;
}
const [repo, releases] = await Promise.all([fetchJson(API_ROOT), fetchJson(`${API_ROOT}/releases?per_page=100`)]);
return { stars: readStars(repo), downloads: readDownloads(releases) };
}
async function fetchJson(url: string): Promise<unknown> {
try {
const headers: Record<string, string> = {
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'iptvnator-website-build',
};
const token = process.env.GITHUB_TOKEN;
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(url, { headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
if (!response.ok) {
console.warn(`[website] GitHub stats lookup failed (HTTP ${response.status}); the hero shows no counts.`);
return null;
}
return await response.json();
} catch (error) {
console.warn(`[website] GitHub stats lookup failed (${error instanceof Error ? error.message : String(error)}); the hero shows no counts.`);
return null;
}
}
function readStars(payload: unknown): number | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const stars = (payload as Record<string, unknown>).stargazers_count;
return typeof stars === 'number' && Number.isFinite(stars) ? stars : null;
}
function readDownloads(payload: unknown): number | null {
if (!Array.isArray(payload) || payload.length === 0) {
return null;
}
let total = 0;
for (const release of payload) {
const assets = release && typeof release === 'object' ? (release as Record<string, unknown>).assets : null;
if (!Array.isArray(assets)) {
continue;
}
for (const asset of assets) {
const count = asset && typeof asset === 'object' ? (asset as Record<string, unknown>).download_count : null;
if (typeof count === 'number' && Number.isFinite(count)) {
total += count;
}
}
}
return total > 0 ? total : null;
}