mirror of
https://github.com/horizon-rust/horizon-site.git
synced 2026-11-07 10:05:58 -10:00
Move the staff dashboards into the hub
The staff pages are now part of the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup (Kira's password link) and /hub/staff-help, under the hub's tab bar. In a browser that signed in, /hub shows the member's Team or Moderation tab after the public ones (a localStorage hint only; every dashboard still checks the session with Kira). /staff/* redirects to the new addresses, fragment included, so links already sent keep working. Also: an expired or used setup link now shows Kira's message instead of "This page needs the link Kira DMs you". Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
db4b406030
commit
0be2a3bbc0
10 files changed
+179
-95
No files matched your search
@@ -26,7 +26,9 @@ npm run lint
|
||||
|
||||
```
|
||||
app/ the routes: /, /hub, /mods, /mods/<id>, /mods/submit, /mods/policy, /releases,
|
||||
/benchmarks, /stats, /docs, /docs/<page>, /privacy, /staff, /apply
|
||||
/benchmarks, /stats, /docs, /docs/<page>, /privacy, /apply, and the staff's
|
||||
pages inside the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup, /hub/staff-help
|
||||
(the old /staff/* addresses redirect there)
|
||||
app/api/ the registry and players proxies, and release view counts
|
||||
app/staff/api/ the staff dashboards' proxy to Kira's service
|
||||
app/data/ /data/roadmap.json and /data/journal.json, which Kira reads
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StaffApp } from "@/components/staff/staff-app";
|
||||
|
||||
// The staff's pages inside the hub: /hub/team, /hub/mod, /hub/sign-in, /hub/setup (the link Kira DMs), /hub/staff-help.
|
||||
// The old /staff/* addresses redirect here (next.config.ts).
|
||||
const STAFF_VIEWS = ["team", "mod", "sign-in", "setup", "staff-help"];
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Staff · Horizon hub",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer",
|
||||
};
|
||||
|
||||
export function generateStaticParams() {
|
||||
return STAFF_VIEWS.map((view) => ({ view }));
|
||||
}
|
||||
|
||||
export default async function StaffPage({ params }: { params: Promise<{ view: string }> }) {
|
||||
const { view } = await params;
|
||||
if (!STAFF_VIEWS.includes(view)) notFound();
|
||||
return <StaffApp view={view} />;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import type { Metadata } from "next";
|
||||
import { notFound } from "next/navigation";
|
||||
import { StaffApp } from "@/components/staff/staff-app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Staff · Horizon",
|
||||
robots: { index: false, follow: false },
|
||||
referrer: "no-referrer",
|
||||
};
|
||||
|
||||
const VIEWS = ["team", "mod", "setup", "help"];
|
||||
|
||||
export function generateStaticParams() {
|
||||
return [{ view: [] }, ...VIEWS.map((v) => ({ view: [v] }))];
|
||||
}
|
||||
|
||||
export default async function StaffPage({ params }: { params: Promise<{ view?: string[] }> }) {
|
||||
const { view = [] } = await params;
|
||||
if (view.length > 1 || (view.length === 1 && !VIEWS.includes(view[0]))) notFound();
|
||||
return <StaffApp view={view[0] ?? ""} />;
|
||||
}
|
||||
+72
-22
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useRef, useState, useSyncExternalStore, type KeyboardEvent, type ReactNode } from "react";
|
||||
import { readStaffHint, staffDashboards, subscribeStaffHint, type Dashboard } from "@/components/staff/api";
|
||||
|
||||
export const TABS = [
|
||||
{ id: "overview", label: "Overview" },
|
||||
@@ -59,6 +61,50 @@ function setHash(id: string) {
|
||||
|
||||
const TAB_CLASS =
|
||||
"inline-flex h-9 shrink-0 items-center rounded-lg px-3.5 text-sm font-medium transition-colors outline-none focus-visible:ring-2 focus-visible:ring-[#a98fff]";
|
||||
const TAB_ON = "bg-white text-background";
|
||||
const TAB_OFF = "text-white/65 hover:bg-white/[0.08] hover:text-white";
|
||||
const BAR = "sticky top-18 z-30 border-b border-white/[0.08] bg-background/80 backdrop-blur-md";
|
||||
const BAR_ROW = "mx-auto flex w-full max-w-6xl flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center sm:justify-between lg:px-10";
|
||||
export const STAFF_TABS: Record<Dashboard, string> = { team: "Team", mod: "Moderation" };
|
||||
|
||||
// The staff's own tabs (/hub/team, /hub/mod), after the public ones: only in a browser that signed in to them.
|
||||
export function useStaffTabs(): Dashboard[] {
|
||||
const hint = useSyncExternalStore(subscribeStaffHint, readStaffHint, () => "");
|
||||
return useMemo(() => staffDashboards(hint), [hint]);
|
||||
}
|
||||
|
||||
function StaffTabs({ dashboards, current }: { dashboards: Dashboard[]; current?: string }) {
|
||||
if (!dashboards.length) return null;
|
||||
return (
|
||||
<nav aria-label="Staff" className="flex shrink-0 gap-1 border-l border-white/[0.12] pl-2">
|
||||
{dashboards.map((d) => (
|
||||
<Link key={d} href={`/hub/${d}`} aria-current={d === current ? "page" : undefined} className={`${TAB_CLASS} ${d === current ? TAB_ON : TAB_OFF}`}>
|
||||
{STAFF_TABS[d]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
// The hub's tab bar on the staff pages: the public tabs lead back to /hub.
|
||||
export function HubStaffBar({ dashboards, current }: { dashboards: Dashboard[]; current?: string }) {
|
||||
return (
|
||||
<div className={BAR}>
|
||||
<div className={BAR_ROW}>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<nav aria-label="Hub" className="-mx-1 flex min-w-0 gap-1 overflow-x-auto px-1">
|
||||
{TABS.map((t) => (
|
||||
<a key={t.id} href={`/hub#${t.id}`} className={`${TAB_CLASS} ${TAB_OFF}`}>
|
||||
{t.label}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
<StaffTabs dashboards={dashboards} current={current} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function HubShell({
|
||||
areas,
|
||||
@@ -82,6 +128,7 @@ export function HubShell({
|
||||
const hash = useSyncExternalStore(subscribeHash, readHash, () => "");
|
||||
const held = anchors[hash];
|
||||
const tab: Tab = isTab(hash) ? hash : held && isTab(held) ? held : "overview";
|
||||
const staff = useStaffTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!hash || isTab(hash)) return;
|
||||
@@ -114,28 +161,31 @@ export function HubShell({
|
||||
|
||||
return (
|
||||
<div ref={root} className="hub" data-tab={tab} data-detail={detail}>
|
||||
<div className="sticky top-18 z-30 border-b border-white/[0.08] bg-background/80 backdrop-blur-md">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col gap-3 px-6 py-3 sm:flex-row sm:items-center sm:justify-between lg:px-10">
|
||||
<div role="tablist" aria-label="Hub" className="-mx-1 flex gap-1 overflow-x-auto px-1">
|
||||
{TABS.map((t) => {
|
||||
const active = t.id === tab;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
id={`tab-${t.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={t.id}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => choose(t.id)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`${TAB_CLASS} ${active ? "bg-white text-background" : "text-white/65 hover:bg-white/[0.08] hover:text-white"}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={BAR}>
|
||||
<div className={BAR_ROW}>
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<div role="tablist" aria-label="Hub" className="-mx-1 flex gap-1 overflow-x-auto px-1">
|
||||
{TABS.map((t) => {
|
||||
const active = t.id === tab;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
id={`tab-${t.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-controls={t.id}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={() => choose(t.id)}
|
||||
onKeyDown={onKeyDown}
|
||||
className={`${TAB_CLASS} ${active ? TAB_ON : TAB_OFF}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<StaffTabs dashboards={staff} />
|
||||
</div>
|
||||
|
||||
<div role="group" aria-label="Detail" className="flex shrink-0 rounded-lg bg-white/[0.06] p-1">
|
||||
|
||||
@@ -74,6 +74,36 @@ export type Overview = {
|
||||
decisions?: Decision[];
|
||||
};
|
||||
|
||||
// Which dashboards this browser last signed in to, so the hub can show their tabs without asking Kira on every visit.
|
||||
// Only a hint for links: every dashboard checks the session itself.
|
||||
const STAFF_HINT = "hub:staff";
|
||||
const hintListeners = new Set<() => void>();
|
||||
|
||||
export function rememberStaff(dashboards: Dashboard[]) {
|
||||
try {
|
||||
if (dashboards.length) localStorage.setItem(STAFF_HINT, dashboards.join(","));
|
||||
else localStorage.removeItem(STAFF_HINT);
|
||||
} catch {}
|
||||
hintListeners.forEach((onChange) => onChange());
|
||||
}
|
||||
|
||||
export function subscribeStaffHint(onChange: () => void) {
|
||||
hintListeners.add(onChange);
|
||||
return () => {
|
||||
hintListeners.delete(onChange);
|
||||
};
|
||||
}
|
||||
|
||||
export function readStaffHint() {
|
||||
try {
|
||||
return localStorage.getItem(STAFF_HINT) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export const staffDashboards = (hint: string) => hint.split(",").filter((d): d is Dashboard => d === "team" || d === "mod");
|
||||
|
||||
let csrf = "";
|
||||
export const setCsrf = (token?: string) => {
|
||||
csrf = token || "";
|
||||
|
||||
@@ -305,7 +305,7 @@ export function Applications({ apps, people, dashboard, onDone }: { apps: Applic
|
||||
<div className="space-y-4">
|
||||
<p className="text-[15px] text-white/60">
|
||||
Verified contributors come first.{" "}
|
||||
<Link href="/staff/help#applications" className={LINK}>
|
||||
<Link href="/hub/staff-help#applications" className={LINK}>
|
||||
How the vote works
|
||||
</Link>
|
||||
.
|
||||
|
||||
@@ -113,7 +113,7 @@ export function ModDashboard({ who }: { who: Who }) {
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs tabs={tabs} active={tab} onSelect={selectTab} label="Moderation dashboard" />
|
||||
<Link href="/staff/help" className="text-sm text-white/55 hover:text-white">
|
||||
<Link href="/hub/staff-help" className="text-sm text-white/55 hover:text-white">
|
||||
How this works
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,19 @@
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useState, useSyncExternalStore, type FormEvent, type ReactNode } from "react";
|
||||
import { ApiError, api, setCsrf, type Dashboard, type Who } from "@/components/staff/api";
|
||||
import { HubStaffBar, STAFF_TABS, useStaffTabs } from "@/components/hub-shell";
|
||||
import { ApiError, api, rememberStaff, setCsrf, type Dashboard, type Who } from "@/components/staff/api";
|
||||
import { ModDashboard } from "@/components/staff/mod";
|
||||
import { TeamDashboard } from "@/components/staff/team";
|
||||
import { CARD, Field, GHOST_SM, INPUT, LINK, PRIMARY, SECONDARY_SM, Status } from "@/components/staff/ui";
|
||||
|
||||
const NAMES: Record<Dashboard, string> = { team: "Team", mod: "Moderation" };
|
||||
const NAMES = STAFF_TABS;
|
||||
const TITLES: Record<string, [string, string]> = {
|
||||
team: ["Team", "Team dashboard"],
|
||||
mod: ["Moderation", "Moderation dashboard"],
|
||||
setup: ["Staff", "Your staff password"],
|
||||
help: ["Help", "How this works"],
|
||||
"": ["Horizon Team and Mods", "Staff dashboards"],
|
||||
"staff-help": ["Staff help", "How this works"],
|
||||
"sign-in": ["Staff", "Staff sign-in"],
|
||||
};
|
||||
const isDashboard = (v: string): v is Dashboard => v === "team" || v === "mod";
|
||||
const never = () => () => {};
|
||||
@@ -23,7 +24,7 @@ function readSetupToken() {
|
||||
try {
|
||||
if (window.location.hash.length > 1) {
|
||||
sessionStorage.setItem("staff:setup", window.location.hash.slice(1));
|
||||
window.history.replaceState(null, "", "/staff/setup");
|
||||
window.history.replaceState(null, "", "/hub/setup");
|
||||
}
|
||||
return sessionStorage.getItem("staff:setup") ?? "";
|
||||
} catch {
|
||||
@@ -38,11 +39,11 @@ function forgetSetupToken() {
|
||||
}
|
||||
|
||||
function Header({ view, message }: { view: string; message?: string }) {
|
||||
const [kicker, title] = TITLES[view] ?? TITLES[""];
|
||||
const [kicker, title] = TITLES[view] ?? TITLES["sign-in"];
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-white/60">
|
||||
Staff <span className="mx-1.5 text-white/30">·</span> {kicker}
|
||||
Hub <span className="mx-1.5 text-white/30">·</span> {kicker}
|
||||
</p>
|
||||
<h1 className="mt-3 font-display text-[clamp(2rem,5vw,3.25rem)] leading-[1.05] font-semibold tracking-[-0.03em] text-white">{title}</h1>
|
||||
{message && (
|
||||
@@ -67,6 +68,7 @@ function Login({ onSignedIn }: { onSignedIn: (who: Who) => void }) {
|
||||
try {
|
||||
const who = await api<Who>("POST", "login", { name, password: password.value });
|
||||
setCsrf(who.csrf);
|
||||
rememberStaff(who.dashboards);
|
||||
form.reset();
|
||||
setStatus("");
|
||||
onSignedIn(who);
|
||||
@@ -132,8 +134,9 @@ function Setup() {
|
||||
const signed = await api<Who>("POST", "setup", { token, password });
|
||||
forgetSetupToken();
|
||||
setCsrf(signed.csrf);
|
||||
rememberStaff(signed.dashboards);
|
||||
form.reset();
|
||||
router.replace(`/staff/${signed.dashboards[0] ?? ""}`);
|
||||
router.replace(`/hub/${signed.dashboards[0] ?? "sign-in"}`);
|
||||
} catch (err) {
|
||||
setStatus((err as Error).message);
|
||||
setBusy(false);
|
||||
@@ -141,9 +144,8 @@ function Setup() {
|
||||
};
|
||||
|
||||
if (token === null) return <p className="text-sm text-white/50">Checking your link…</p>;
|
||||
const intro = !token
|
||||
? "This page needs the link Kira DMs you. Run /staff password on Discord to get one."
|
||||
: error || (!link ? "Checking your link…" : null);
|
||||
const intro =
|
||||
error || (!token ? "This page needs the link Kira DMs you. Run /staff password on Discord to get one." : !link ? "Checking your link…" : null);
|
||||
if (intro) return <p className="max-w-xl text-[15px] leading-relaxed text-white/70">{intro}</p>;
|
||||
return (
|
||||
<section aria-labelledby="set-password" className="max-w-md">
|
||||
@@ -190,7 +192,7 @@ function Help() {
|
||||
<section className="max-w-3xl">
|
||||
<p className="text-lg leading-relaxed text-white/70">
|
||||
The Horizon Team and the Mods run these two dashboards together. Here is how they work.{" "}
|
||||
<Link href="/staff" className={LINK}>
|
||||
<Link href="/hub/sign-in" className={LINK}>
|
||||
Back to the dashboards
|
||||
</Link>
|
||||
.
|
||||
@@ -292,22 +294,7 @@ function Help() {
|
||||
function Signed({ view, who, onSignedOut }: { view: Dashboard; who: Who; onSignedOut: (all: boolean) => void }) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-8 flex flex-col gap-4 rounded-2xl bg-[#18112c] px-4 py-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<nav aria-label="Dashboards" className="flex gap-1">
|
||||
{who.dashboards.length > 1 &&
|
||||
who.dashboards.map((d) => (
|
||||
<Link
|
||||
key={d}
|
||||
href={`/staff/${d}`}
|
||||
aria-current={d === view ? "page" : undefined}
|
||||
className={`inline-flex h-9 items-center rounded-lg px-3.5 text-sm font-medium transition-colors ${
|
||||
d === view ? "bg-white text-[#100a1f]" : "text-white/65 hover:bg-white/[0.08] hover:text-white"
|
||||
}`}
|
||||
>
|
||||
{NAMES[d]}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="mb-8 flex flex-col gap-4 rounded-2xl bg-[#18112c] px-4 py-3 sm:flex-row sm:items-center sm:justify-end">
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-white/60">Signed in as {who.name}</span>
|
||||
<button type="button" onClick={() => onSignedOut(false)} className={SECONDARY_SM}>
|
||||
@@ -324,7 +311,7 @@ function Signed({ view, who, onSignedOut }: { view: Dashboard; who: Who; onSigne
|
||||
{who.dashboards.map((d, i) => (
|
||||
<span key={d}>
|
||||
{i > 0 && " or "}
|
||||
<Link href={`/staff/${d}`} className={LINK}>
|
||||
<Link href={`/hub/${d}`} className={LINK}>
|
||||
Open the {NAMES[d]} dashboard
|
||||
</Link>
|
||||
</span>
|
||||
@@ -344,29 +331,34 @@ export function StaffApp({ view }: { view: string }) {
|
||||
const router = useRouter();
|
||||
const [who, setWho] = useState<Who | null | undefined>(undefined);
|
||||
const [message, setMessage] = useState("");
|
||||
const needsSession = view !== "setup" && view !== "help";
|
||||
const needsSession = view !== "setup" && view !== "staff-help";
|
||||
const hinted = useStaffTabs();
|
||||
|
||||
useEffect(() => {
|
||||
if (!needsSession) return;
|
||||
api<Who>("GET", "me").then(
|
||||
(w) => {
|
||||
setCsrf(w.csrf);
|
||||
rememberStaff(w.dashboards);
|
||||
setWho(w);
|
||||
},
|
||||
(e: ApiError) => {
|
||||
if (e.status === 401) setWho(null);
|
||||
if (e.status === 401) {
|
||||
rememberStaff([]);
|
||||
setWho(null);
|
||||
}
|
||||
else setMessage(e.message);
|
||||
},
|
||||
);
|
||||
}, [needsSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (who && !isDashboard(view) && who.dashboards[0]) router.replace(`/staff/${who.dashboards[0]}`);
|
||||
if (who && !isDashboard(view) && who.dashboards[0]) router.replace(`/hub/${who.dashboards[0]}`);
|
||||
}, [who, view, router]);
|
||||
|
||||
useEffect(() => {
|
||||
const [, title] = TITLES[view] ?? TITLES[""];
|
||||
document.title = `${title} · Horizon`;
|
||||
const [, title] = TITLES[view] ?? TITLES["sign-in"];
|
||||
document.title = `${title} · Horizon hub`;
|
||||
}, [view]);
|
||||
|
||||
const signOut = async (all: boolean) => {
|
||||
@@ -374,12 +366,13 @@ export function StaffApp({ view }: { view: string }) {
|
||||
await api("POST", all ? "logout-all" : "logout");
|
||||
} catch {}
|
||||
setCsrf("");
|
||||
rememberStaff([]);
|
||||
setWho(null);
|
||||
setMessage(all ? "Signed out on every device." : "Signed out.");
|
||||
};
|
||||
|
||||
let body: ReactNode;
|
||||
if (view === "help") body = <Help />;
|
||||
if (view === "staff-help") body = <Help />;
|
||||
else if (view === "setup") body = <Setup />;
|
||||
else if (message && who === undefined) body = null;
|
||||
else if (who === undefined) body = <p className="text-sm text-white/50">Checking your session…</p>;
|
||||
@@ -396,9 +389,12 @@ export function StaffApp({ view }: { view: string }) {
|
||||
else body = <Signed view={view} who={who} onSignedOut={signOut} />;
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-6xl px-6 pt-32 pb-24 sm:pt-36 lg:px-10">
|
||||
<Header view={view} message={message} />
|
||||
{body}
|
||||
<main>
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-32 pb-8 sm:pt-36 lg:px-10">
|
||||
<Header view={view} message={message} />
|
||||
</div>
|
||||
<HubStaffBar dashboards={who ? who.dashboards : hinted} current={view} />
|
||||
<div className="mx-auto w-full max-w-6xl px-6 pt-10 pb-24 lg:px-10">{body}</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -282,9 +282,9 @@ function Board({ ctx }: { ctx: Ctx }) {
|
||||
<h2 className="font-display text-2xl font-semibold tracking-[-0.02em] text-white">Work board</h2>
|
||||
<Note>
|
||||
Kira's board from GitHub, as on{" "}
|
||||
<a href="/hub" className={LINK}>
|
||||
<Link href="/hub" className={LINK}>
|
||||
the hub
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
(read-only here: move cards with /move on Discord or the MCP tools).
|
||||
</Note>
|
||||
<Guard what="Kira's work board">
|
||||
@@ -305,9 +305,9 @@ function Board({ ctx }: { ctx: Ctx }) {
|
||||
{cards.length > 12 && (
|
||||
<p className="mt-2 text-xs text-white/45">
|
||||
and {cards.length - 12} more on{" "}
|
||||
<a href="/hub" className={LINK}>
|
||||
<Link href="/hub" className={LINK}>
|
||||
the hub
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
@@ -463,9 +463,9 @@ function PublicItemRow({ it, ctx }: { it: PublicItem; ctx: Ctx }) {
|
||||
return (
|
||||
<li className="py-3">
|
||||
<p className="text-[15px]">
|
||||
<a href="/hub" className="text-white hover:underline">
|
||||
<Link href="/hub" className="text-white hover:underline">
|
||||
{it.title}
|
||||
</a>{" "}
|
||||
</Link>{" "}
|
||||
<span className="text-sm text-white/45">{area}</span>
|
||||
{t && (
|
||||
<span className="text-sm text-white/45">
|
||||
@@ -525,9 +525,9 @@ function Roadmap({ ctx }: { ctx: Ctx }) {
|
||||
<h2 className="font-display text-2xl font-semibold tracking-[-0.02em] text-white">Public roadmap</h2>
|
||||
<Note>
|
||||
What everyone sees on{" "}
|
||||
<a href="/hub" className={LINK}>
|
||||
<Link href="/hub" className={LINK}>
|
||||
the hub
|
||||
</a>
|
||||
</Link>
|
||||
, from roadmap/roadmap.toml{r.available === false ? " (not loaded right now)" : ""}. The Team's owner, priority and notes on each item stay
|
||||
here.
|
||||
</Note>
|
||||
@@ -852,7 +852,7 @@ export function TeamDashboard({ who }: { who: Who }) {
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Tabs tabs={tabs} active={tab} onSelect={selectTab} label="Team dashboard" />
|
||||
<Link href="/staff/help" className="text-sm text-white/55 hover:text-white">
|
||||
<Link href="/hub/staff-help" className="text-sm text-white/55 hover:text-white">
|
||||
How this works
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+5
-1
@@ -15,6 +15,10 @@ const nextConfig: NextConfig = {
|
||||
{ source: "/server", destination: "/docs/hosting", permanent: true },
|
||||
{ source: "/server.html", destination: "/docs/hosting", permanent: true },
|
||||
{ source: "/roadmap", destination: "/hub#roadmap", permanent: true },
|
||||
// The staff dashboards moved into the hub (2026-11-01). Browsers keep the #fragment of old setup links across the redirect.
|
||||
{ source: "/staff", destination: "/hub/sign-in", permanent: true },
|
||||
{ source: "/staff/help", destination: "/hub/staff-help", permanent: true },
|
||||
{ source: "/staff/:view(team|mod|setup)", destination: "/hub/:view", permanent: true },
|
||||
];
|
||||
},
|
||||
async headers() {
|
||||
@@ -27,8 +31,8 @@ const nextConfig: NextConfig = {
|
||||
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||
];
|
||||
return [
|
||||
{ source: "/staff", headers: privateHeaders },
|
||||
{ source: "/staff/:path*", headers: privateHeaders },
|
||||
{ source: "/hub/:view(team|mod|sign-in|setup|staff-help)", headers: privateHeaders },
|
||||
{ source: "/apply", headers: privateHeaders },
|
||||
{ source: "/mods/submit", headers: [{ key: "X-Frame-Options", value: "DENY" }, { key: "Content-Security-Policy", value: "frame-ancestors 'none'" }] },
|
||||
];
|
||||
|
||||
Reference in new issue
Block a user