mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-11 11:06:16 -08:00
* 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>
101 lines
3.4 KiB
TypeScript
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;
|
|
}
|