mirror of
https://github.com/horizon-rust/horizon-site.git
synced 2026-11-07 10:05:58 -10:00
Add the hub's People tab and the launcher's download count (#47)
People (/hub#people): the Discord server's staff roles top to bottom, read from Kira's GET /staff.json (cached 10 minutes), with each person's Discord name, confirmed GitHub login and job; a message when Kira can't be read. The hero's launcher line shows the launcher's downloads over every release (GitHub's counts, updates included), read on the server through Kira's GitHub proxy and kept half an hour; hidden when GitHub can't be read. Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
16e2cd91e3
commit
f6db7f55dd
8 files changed
+198
-6
No files matched your search
@@ -274,6 +274,7 @@ body {
|
||||
.hub[data-tab="board"] [data-panel]:not([data-panel="board"]),
|
||||
.hub[data-tab="journal"] [data-panel]:not([data-panel="journal"]),
|
||||
.hub[data-tab="contribute"] [data-panel]:not([data-panel="contribute"]),
|
||||
.hub[data-tab="people"] [data-panel]:not([data-panel="people"]),
|
||||
.hub:not([data-detail="dev"]) .hub-dev,
|
||||
.hub[data-detail="dev"] .hub-plain {
|
||||
display: none;
|
||||
|
||||
+6
-2
@@ -3,7 +3,8 @@ import { getContributors } from "@/components/contributors";
|
||||
import { getReleases } from "@/components/github-releases";
|
||||
import { getBoard, getHubIssues, getRoadmap } from "@/components/hub-data";
|
||||
import { journalAnchor, journalFrom, REPO } from "@/components/hub-logic";
|
||||
import { BoardPanel, ContributePanel, JournalPanel, OverviewPanel, RoadmapPanel } from "@/components/hub-panels";
|
||||
import { BoardPanel, ContributePanel, JournalPanel, OverviewPanel, PeoplePanel, RoadmapPanel } from "@/components/hub-panels";
|
||||
import { getOrgChart } from "@/components/org-chart";
|
||||
import { HubShell } from "@/components/hub-shell";
|
||||
import { PixelAurora } from "@/components/pixel-aurora";
|
||||
import { pageMetadata } from "@/components/site";
|
||||
@@ -18,18 +19,20 @@ export const metadata = pageMetadata({
|
||||
const delay = (ms: number) => ({ "--delay": `${ms}ms` }) as CSSProperties;
|
||||
|
||||
export default async function HubPage() {
|
||||
const [roadmap, issues, board, releases, contributors] = await Promise.all([
|
||||
const [roadmap, issues, board, releases, contributors, people] = await Promise.all([
|
||||
getRoadmap(),
|
||||
getHubIssues(),
|
||||
getBoard(),
|
||||
getReleases(),
|
||||
getContributors(),
|
||||
getOrgChart(),
|
||||
]);
|
||||
const live = new Map((issues ?? []).map((i) => [i.n, i]));
|
||||
const areas = roadmap ? roadmap.areas.filter((a) => roadmap.items.some((i) => i.area === a.id)) : [];
|
||||
const journal = roadmap ? journalFrom(releases ?? [], issues ?? [], roadmap) : [];
|
||||
|
||||
const anchors: Record<string, string> = { "hub-contributors": "contribute", report: "contribute" };
|
||||
for (const t of people ?? []) anchors[`people-${t.key}`] = "people";
|
||||
for (const s of roadmap?.statuses ?? []) anchors[s.id] = "roadmap";
|
||||
for (const i of roadmap?.items ?? []) anchors[i.id] = "roadmap";
|
||||
for (const e of journal) anchors[journalAnchor(e)] = "journal";
|
||||
@@ -73,6 +76,7 @@ export default async function HubPage() {
|
||||
<BoardPanel columns={board} roadmap={roadmap} />
|
||||
<JournalPanel entries={journal} roadmap={roadmap} />
|
||||
<ContributePanel roadmap={roadmap} contributors={contributors} live={live} version={version} />
|
||||
<PeoplePanel tiers={people} />
|
||||
</HubShell>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pb-24 lg:px-10">
|
||||
|
||||
@@ -90,3 +90,32 @@ export function launcherBuild(releases: Release[] | null): LauncherBuild | null
|
||||
}
|
||||
return { version: release.tag.replace(/^launcher@/, ""), files };
|
||||
}
|
||||
|
||||
// The launcher's files, as published on every release (its own launcher@ releases, and the game releases that carried
|
||||
// it before them); not the game's files, SHA256SUMS or signatures.
|
||||
const LAUNCHER_ASSET = /^horizon-launcher-(windows|linux|macos)-[\w.-]+\.(zip|tar\.gz|dmg|AppImage)$/;
|
||||
const MAX_RELEASE_PAGES = 10;
|
||||
|
||||
// The launcher's downloads over all releases (GitHub's download counts, so self-updates count too), or null when
|
||||
// GitHub can't be read: callers hide the number then. Every page of releases is read on the server, through Kira's
|
||||
// proxy (components/github-api.ts), and kept half an hour; a release refreshes it through the releases tag.
|
||||
export async function getLauncherDownloads(): Promise<number | null> {
|
||||
"use cache";
|
||||
cacheTag(TAGS.releases);
|
||||
let total = 0;
|
||||
try {
|
||||
for (let page = 1; page <= MAX_RELEASE_PAGES; page++) {
|
||||
const res = await githubFetch(`/repos/${REPO}/releases?per_page=100&page=${page}`);
|
||||
if (!res.ok) throw new Error(`GitHub responded ${res.status} ${res.statusText}`);
|
||||
const batch: { assets: { name: string; download_count: number }[] }[] = await res.json();
|
||||
for (const r of batch) for (const a of r.assets) if (LAUNCHER_ASSET.test(a.name)) total += a.download_count;
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Couldn't count the launcher's downloads for ${REPO}:`, error);
|
||||
cacheLife("minutes");
|
||||
return null;
|
||||
}
|
||||
cacheLife({ stale: 300, revalidate: 1800, expire: 86400 });
|
||||
return total > 0 ? total : null;
|
||||
}
|
||||
+11
-2
@@ -3,7 +3,7 @@ import type { CSSProperties } from "react";
|
||||
import { FrameRateComparison } from "@/components/frame-rate-comparison";
|
||||
import { HorizonScene } from "@/components/horizon-scene";
|
||||
import { DownloadIcon, GitHubIcon, LinuxIcon, RustIcon, WindowsIcon } from "@/components/icons";
|
||||
import { getReleases, launcherBuild } from "@/components/github-releases";
|
||||
import { getLauncherDownloads, getReleases, launcherBuild } from "@/components/github-releases";
|
||||
import { LauncherLink, LauncherMeta } from "@/components/launcher-download";
|
||||
import { GITHUB_URL, RUST_WIKI_URL } from "@/components/links";
|
||||
|
||||
@@ -18,7 +18,8 @@ function Dot({ className = "mx-2" }: { className?: string }) {
|
||||
}
|
||||
|
||||
export async function Hero() {
|
||||
const launcher = launcherBuild(await getReleases());
|
||||
const [releases, downloads] = await Promise.all([getReleases(), getLauncherDownloads()]);
|
||||
const launcher = launcherBuild(releases);
|
||||
return (
|
||||
<section className="relative isolate flex min-h-svh flex-col overflow-hidden">
|
||||
<HorizonScene className="absolute inset-0 -z-10" />
|
||||
@@ -93,6 +94,14 @@ export async function Hero() {
|
||||
<span className="basis-full whitespace-nowrap sm:basis-auto">
|
||||
<Dot className="mr-2 hidden sm:inline" />
|
||||
<LauncherMeta build={launcher} dot={<Dot />} />
|
||||
{downloads !== null && (
|
||||
<>
|
||||
{launcher && <Dot />}
|
||||
<span title="Downloads of the launcher from GitHub, all versions, updates included">
|
||||
<span className="tabular-nums">{downloads.toLocaleString("en-US")}</span> downloads
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import type { ReactNode } from "react";
|
||||
import { ExternalLinkIcon } from "@/components/icons";
|
||||
import { DiscordIcon, ExternalLinkIcon, GitHubIcon } from "@/components/icons";
|
||||
import {
|
||||
areaName,
|
||||
COLUMNS,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/components/hub-logic";
|
||||
import { BoardFilter, BugReport, CopyCommand, JournalFilter } from "@/components/hub-tools";
|
||||
import { DISCORD_URL } from "@/components/links";
|
||||
import type { OrgPerson, OrgTier } from "@/components/org-chart";
|
||||
|
||||
type Live = Map<number, HubIssue>;
|
||||
|
||||
@@ -622,3 +623,97 @@ export function ContributePanel({
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
// The People tab: the Discord server's staff roles, top to bottom, as Kira reads them (components/org-chart.ts).
|
||||
const TIER_TEXT: Record<string, { title: string; about: string; empty?: string }> = {
|
||||
owner: { title: "👑 Owner", about: "Started Horizon and owns the Discord server." },
|
||||
team: { title: "🌌 Horizon Team", about: "Maintainers and core contributors: they review and merge pull requests, and moderate too." },
|
||||
chiefmod: { title: "🛡️ Chief of Mods", about: "Leads the moderators: the first person to reach for moderation questions." },
|
||||
mod: { title: "🛡️ Mods", about: "Keep the Discord server friendly: they time out members and delete messages. Bans go through the Horizon Team." },
|
||||
support: {
|
||||
title: "🤝 Support",
|
||||
about: "Help players in #horizon-support and mark their posts solved.",
|
||||
empty: "Nobody yet. Want to help players? Run /apply on Discord.",
|
||||
},
|
||||
};
|
||||
|
||||
function PersonCard({ person }: { person: OrgPerson }) {
|
||||
return (
|
||||
<li className="flex items-center gap-3 rounded-xl bg-white/[0.04] p-4">
|
||||
{person.avatar ? (
|
||||
<Image src={person.avatar} alt="" width={40} height={40} className="size-10 shrink-0 rounded-full bg-white/[0.06]" />
|
||||
) : (
|
||||
<span aria-hidden className="grid size-10 shrink-0 place-items-center rounded-full bg-white/[0.08] text-sm font-medium text-white/70">
|
||||
{[...person.name][0]?.toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate text-[15px] font-medium text-white">{person.name}</span>
|
||||
{person.job && <span className={`${CHIP} shrink-0 bg-[#6b4bff]/20 text-[#d9ceff]`}>{person.job}</span>}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-white/45">
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5">
|
||||
<DiscordIcon className="size-3.5 shrink-0" />
|
||||
<span className="sr-only">Discord: </span>
|
||||
<span className="truncate">{person.username}</span>
|
||||
</span>
|
||||
{person.github && (
|
||||
<a
|
||||
href={`https://github.com/${person.github}`}
|
||||
className="inline-flex min-w-0 items-center gap-1.5 text-white/60 transition-colors hover:text-white"
|
||||
>
|
||||
<GitHubIcon className="size-3.5 shrink-0" />
|
||||
<span className="sr-only">GitHub: </span>
|
||||
<span className="truncate">{person.github}</span>
|
||||
</a>
|
||||
)}
|
||||
</span>
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PeoplePanel({ tiers }: { tiers: OrgTier[] | null }) {
|
||||
return (
|
||||
<Panel id="people">
|
||||
<Block title="Who runs Horizon">
|
||||
<p className="mt-2 max-w-3xl text-[15px] leading-relaxed text-white/60">
|
||||
The roles on Horizon's Discord server, from the top down, updated from Discord every few minutes. A GitHub account shows when
|
||||
it is confirmed: run <code className="text-white/75">/link-github <your login> credit:True</code> on Discord to add yours.
|
||||
</p>
|
||||
{tiers ? (
|
||||
<ol className="mt-8 ml-1 border-l border-white/[0.1]">
|
||||
{tiers.map((tier) => {
|
||||
const t = TIER_TEXT[tier.key] ?? { title: tier.post, about: "" };
|
||||
return (
|
||||
<li key={tier.key} id={`people-${tier.key}`} className="relative scroll-mt-40 pb-10 pl-6 last:pb-0 sm:pl-8">
|
||||
<span aria-hidden className="absolute top-2 -left-[5px] size-2.5 rounded-full bg-[#a98fff] ring-4 ring-background" />
|
||||
<h3 className="text-[17px] font-medium text-white">{t.title}</h3>
|
||||
{t.about && <p className="mt-1 max-w-3xl text-sm leading-relaxed text-white/55">{t.about}</p>}
|
||||
{tier.people.length > 0 ? (
|
||||
<ul className="mt-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{tier.people.map((p) => (
|
||||
<PersonCard key={p.username || p.name} person={p} />
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-white/45">{t.empty ?? "Nobody right now."}</p>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
) : (
|
||||
<p className="mt-5 max-w-xl text-[15px] leading-relaxed text-white/60">
|
||||
The list couldn't be loaded from Discord just now. The roles are on{" "}
|
||||
<a href={DISCORD_URL} className="text-[#a98fff] underline underline-offset-4">
|
||||
Horizon's Discord server
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)}
|
||||
</Block>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export const TABS = [
|
||||
{ id: "board", label: "Kanban" },
|
||||
{ id: "journal", label: "Journal" },
|
||||
{ id: "contribute", label: "Contribute" },
|
||||
{ id: "people", label: "People" },
|
||||
] as const;
|
||||
|
||||
type Tab = (typeof TABS)[number]["id"];
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cacheLife } from "next/cache";
|
||||
|
||||
// The hub's People tab: who runs Horizon, as the Discord server's roles say, top to bottom. Kira builds it from its
|
||||
// member cache and serves it as GET /staff.json (kira's src/org-chart.js: names, roles, Discord avatars, and GitHub
|
||||
// logins only where they are confirmed). A role given on Discord shows here within about ten minutes, no deploy.
|
||||
const KIRA = (process.env.KIRA_URL || "https://kira-production-500e.up.railway.app").replace(/\/+$/, "");
|
||||
|
||||
export type OrgPerson = { name: string; username: string; avatar: string | null; github: string | null; job: string | null };
|
||||
export type OrgTier = { key: string; post: string; people: OrgPerson[] };
|
||||
|
||||
const TIER_KEYS = ["owner", "team", "chiefmod", "mod", "support"];
|
||||
// next.config.ts lets next/image load these two forms only.
|
||||
const AVATAR = /^https:\/\/cdn\.discordapp\.com\/(avatars\/\d{5,25}\/\w{1,64}\.png\?size=128|embed\/avatars\/\d\.png)$/;
|
||||
const LOGIN = /^[a-z\d](?:[a-z\d-]{0,38})$/i;
|
||||
const text = (v: unknown, max: number) => (typeof v === "string" ? v.trim().slice(0, max) : "");
|
||||
|
||||
export async function getOrgChart(): Promise<OrgTier[] | null> {
|
||||
"use cache";
|
||||
try {
|
||||
const res = await fetch(`${KIRA}/staff.json`, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) throw new Error(`Kira responded ${res.status} ${res.statusText}`);
|
||||
const body: { tiers?: { key?: unknown; post?: unknown; people?: Record<string, unknown>[] }[] } = await res.json();
|
||||
const tiers = (body.tiers ?? [])
|
||||
.filter((t) => TIER_KEYS.includes(String(t.key)))
|
||||
.map((t) => ({
|
||||
key: String(t.key),
|
||||
post: text(t.post, 40),
|
||||
people: (t.people ?? []).map((p) => {
|
||||
const avatar = text(p.avatar, 200);
|
||||
const github = text(p.github, 39);
|
||||
return {
|
||||
name: text(p.name, 64) || text(p.username, 64),
|
||||
username: text(p.username, 64),
|
||||
avatar: AVATAR.test(avatar) ? avatar : null,
|
||||
github: LOGIN.test(github) ? github : null,
|
||||
job: text(p.job, 80) || null,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
if (!tiers.length) throw new Error("no tiers");
|
||||
cacheLife({ stale: 300, revalidate: 600, expire: 86400 });
|
||||
return tiers;
|
||||
} catch (error) {
|
||||
console.error("Couldn't load the org chart from Kira:", error);
|
||||
cacheLife("minutes");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+6
-1
@@ -4,7 +4,12 @@ const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
allowedDevOrigins: ["202.61.226.46"],
|
||||
images: {
|
||||
remotePatterns: [{ protocol: "https", hostname: "avatars.githubusercontent.com", pathname: "/u/**", search: "?v=4" }],
|
||||
remotePatterns: [
|
||||
{ protocol: "https", hostname: "avatars.githubusercontent.com", pathname: "/u/**", search: "?v=4" },
|
||||
// The hub's People tab: Discord avatars, as Kira's /staff.json gives them (components/org-chart.ts checks the form).
|
||||
{ protocol: "https", hostname: "cdn.discordapp.com", pathname: "/avatars/**", search: "?size=128" },
|
||||
{ protocol: "https", hostname: "cdn.discordapp.com", pathname: "/embed/avatars/**", search: "" },
|
||||
],
|
||||
},
|
||||
experimental: {
|
||||
agentFeedback: true,
|
||||
|
||||
Reference in new issue
Block a user