mirror of
https://github.com/horizon-rust/horizon-site.git
synced 2026-11-07 10:05:58 -10:00
Merge pull request #15 from horizon-rust/feat/staff-pages
Staff dashboards: /staff sign-in, password setup, sign-out; the API proxy to Kira
This commit is contained in:
8 files changed
+361
-4
No files matched your search
@@ -47,6 +47,24 @@
|
||||
handle /api/mods/* {
|
||||
reverse_proxy {$REGISTRY_UPSTREAM:127.0.0.1:9}
|
||||
}
|
||||
# The staff dashboards' API: Kira's service (src/staff-http.js in horizon-rust/kira), reached only through here,
|
||||
# with the key both sides hold and the browser's address. Unset, the pages say the dashboards are closed.
|
||||
handle /staff/api/* {
|
||||
reverse_proxy {$STAFF_UPSTREAM:http://127.0.0.1:9} {
|
||||
header_up Host {upstream_hostport}
|
||||
header_up X-Staff-Proxy-Key "{$STAFF_PROXY_KEY}"
|
||||
header_up X-Staff-Client-Ip {http.request.header.X-Real-IP}
|
||||
}
|
||||
}
|
||||
# The staff pages (one page, its script picks the view) and the application form: never indexed or cached.
|
||||
@staff path /staff /staff/ /staff/team /staff/mod /staff/setup
|
||||
rewrite @staff /staff.html
|
||||
@private path /staff /staff/* /staff.html
|
||||
header @private {
|
||||
X-Robots-Tag "noindex, nofollow"
|
||||
Cache-Control "no-store"
|
||||
Referrer-Policy "no-referrer"
|
||||
}
|
||||
# The registry: /mods, /mods/submit and /mods/<id> baked at build time, else the list page, whose script shows a project
|
||||
# published since.
|
||||
rewrite /mods /mods.html
|
||||
|
||||
@@ -286,9 +286,9 @@ await writeFile(join(dist, "favicon.ico"), Buffer.concat([ico, png32]));
|
||||
|
||||
// The page's modules, fingerprinted; each import of a shared module is rewritten to its name.
|
||||
const names = {};
|
||||
const rewrite = (code) => code.replace(/"\.\/(\w+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
|
||||
const rewrite = (code) => code.replace(/"\.\/([\w-]+\.js)"/g, (all, file) => (names[file] ? `"./${names[file]}"` : all));
|
||||
// Dependencies before what imports them.
|
||||
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js"];
|
||||
const modules = ["markdown.js", "versions.js", "chrome.js", "hardware.js", "estimate.js", "releases.js", "contributors.js", "bench.js", "hub.js", "main.js", "releases-page.js", "benchmarks-page.js", "docs-page.js", "hub-page.js", "mods.js", "mods-page.js", "mods-submit.js", "staff-api.js", "staff-page.js"];
|
||||
for (const file of modules) {
|
||||
const code = rewrite(await readFile(join(site, "src", file), "utf8"));
|
||||
names[file] = `${file.replace(".js", "")}.${fingerprint(code)}.js`;
|
||||
@@ -412,6 +412,7 @@ const values = {
|
||||
CONTRIBUTORS_HIDDEN: contributors.length ? "" : " hidden",
|
||||
BAKED: JSON.stringify(baked).replace(/</g, "\\u003c"),
|
||||
HUB_JS: `/assets/${names["hub-page.js"]}`,
|
||||
STAFF_JS: `/assets/${names["staff-page.js"]}`,
|
||||
HUB_OVERVIEW: overviewHtml(roadmap, hubLinked),
|
||||
HUB_ROADMAP: roadmapHtml(roadmap, hubLinked),
|
||||
HUB_AREAS: areaFilterHtml(roadmap),
|
||||
@@ -521,7 +522,8 @@ const docPages = renderedDocs.map((d, i) => {
|
||||
};
|
||||
});
|
||||
|
||||
const pages = ["index.html", "404.html", "privacy.html", "releases.html", "benchmarks.html", "hub.html"];
|
||||
// staff.html: the staff dashboards (/staff, /staff/team, /staff/mod, /staff/setup), never in the sitemap.
|
||||
const pages = ["index.html", "404.html", "privacy.html", "releases.html", "benchmarks.html", "hub.html", "staff.html"];
|
||||
const currentPath = { "releases.html": "/releases", "benchmarks.html": "/benchmarks", "hub.html": "/hub" };
|
||||
for (const page of pages) {
|
||||
let html = await readFile(join(site, "src", page), "utf8");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Disallow: /staff
|
||||
|
||||
Sitemap: https://playhorizon.dev/sitemap.xml
|
||||
@@ -1,6 +1,6 @@
|
||||
// A dependency-free static server for dist/, for local checks (`npm run dev`).
|
||||
// Railway uses Caddy instead (see Dockerfile).
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, request } from "node:http";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import { join, extname, normalize } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -9,8 +9,31 @@ const root = join(fileURLToPath(new URL(".", import.meta.url)), "dist");
|
||||
const types = { ".html": "text/html; charset=utf-8", ".css": "text/css", ".js": "text/javascript", ".webp": "image/webp", ".png": "image/png", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8", ".md": "text/plain; charset=utf-8", ".json": "application/json", ".xml": "application/xml" };
|
||||
const port = Number(process.env.PORT) || 4173;
|
||||
|
||||
// The staff dashboards' API, as the Caddyfile proxies it: STAFF_UPSTREAM (Kira, or `node scripts/staff-dev.mjs` in
|
||||
// horizon-rust/kira for fake data) and STAFF_PROXY_KEY. Unset, the pages say the dashboards are closed.
|
||||
function staffApi(req, res) {
|
||||
const upstream = process.env.STAFF_UPSTREAM;
|
||||
if (!upstream) {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
return res.end("{}");
|
||||
}
|
||||
const url = new URL(req.url, upstream);
|
||||
const headers = { ...req.headers, host: url.host, "x-staff-proxy-key": process.env.STAFF_PROXY_KEY || "", "x-staff-client-ip": req.socket.remoteAddress };
|
||||
const out = request(url, { method: req.method, headers }, (up) => {
|
||||
res.writeHead(up.statusCode, up.headers);
|
||||
up.pipe(res);
|
||||
});
|
||||
out.on("error", () => {
|
||||
res.writeHead(502, { "Content-Type": "application/json" });
|
||||
res.end("{}");
|
||||
});
|
||||
req.pipe(out);
|
||||
}
|
||||
|
||||
createServer(async (req, res) => {
|
||||
if (req.url.startsWith("/staff/api/")) return staffApi(req, res);
|
||||
let path = normalize(decodeURIComponent(new URL(req.url, "http://x").pathname)).replace(/^(\.\.[/\\])+/, "");
|
||||
if (/^\/staff(\/(team|mod|setup))?\/?$/.test(path)) path = "/staff.html";
|
||||
if (path === "/docs/api/") {
|
||||
res.writeHead(301, { Location: "/docs/api" });
|
||||
return res.end();
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// What the staff pages share: calls to /staff/api (Kira's API, proxied by the Caddyfile), escaping, dates. Every
|
||||
// string from the API is text a person wrote: it goes into the page only through escape() or textContent.
|
||||
import { escape } from "./markdown.js";
|
||||
|
||||
export { escape };
|
||||
let csrf = "";
|
||||
export const setCsrf = (token) => (csrf = token || "");
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
// GET or POST /staff/api/<path>; the CSRF token on writes. Throws ApiError with the API's message.
|
||||
export async function api(method, path, body) {
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(`/staff/api/${path}`, {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
headers: { ...(body ? { "Content-Type": "application/json" } : {}), ...(method === "POST" && csrf ? { "X-Staff-Csrf": csrf } : {}) },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
} catch {
|
||||
throw new ApiError(0, "The dashboards can't be reached right now. Try again in a minute.");
|
||||
}
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
const closed = res.status === 404 || res.status === 502 || res.status === 503 && !data.error;
|
||||
throw new ApiError(res.status, data.error && !(res.status === 404 && data.error === "not found") ? data.error : closed ? "The dashboards are closed right now." : `Error ${res.status}.`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
// "30 Oct 14:05" in the reader's time; with the year when it isn't this one.
|
||||
export function when(value) {
|
||||
if (!value) return "";
|
||||
const d = new Date(value);
|
||||
const month = d.toLocaleString("en", { month: "short" });
|
||||
return `${d.getDate()} ${month}${d.getFullYear() !== new Date().getFullYear() ? ` ${d.getFullYear()}` : ""} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
// "in 5 h", "3 d ago".
|
||||
export function relative(value, now = Date.now()) {
|
||||
const ms = new Date(value).getTime() - now;
|
||||
const abs = Math.abs(ms);
|
||||
const text = abs < 3_600_000 ? `${Math.max(1, Math.round(abs / 60_000))} min` : abs < 172_800_000 ? `${Math.round(abs / 3_600_000)} h` : `${Math.round(abs / 86_400_000)} d`;
|
||||
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
||||
}
|
||||
|
||||
// A link people typed: only https on github.com or playhorizon.dev, "#123" (horizon's issue or pull request) or a path
|
||||
// in horizon's repository become links; anything else is shown as text.
|
||||
export function linkHtml(link) {
|
||||
const s = String(link || "");
|
||||
if (!s) return "";
|
||||
if (/^#\d+$/.test(s)) return `<a href="https://github.com/horizon-rust/horizon/issues/${s.slice(1)}">${escape(s)}</a>`;
|
||||
if (/^https:\/\/(github\.com|playhorizon\.dev)\//.test(s)) return `<a href="${escape(s)}" rel="noreferrer">${escape(s.replace(/^https:\/\//, ""))}</a>`;
|
||||
if (/^[A-Za-z0-9_.\/-]+$/.test(s) && !s.includes("..")) return `<a href="https://github.com/horizon-rust/horizon/blob/main/${escape(s)}">${escape(s)}</a>`;
|
||||
return escape(s);
|
||||
}
|
||||
|
||||
// Text with line breaks kept, escaped.
|
||||
export const paragraphs = (text) => escape(String(text || "")).replace(/\n/g, "<br>");
|
||||
|
||||
// Moves the .tab-pill of a .tabs group under its selected tab.
|
||||
export function placePill(tabs) {
|
||||
const pill = tabs.querySelector(".tab-pill");
|
||||
const on = tabs.querySelector('[aria-selected="true"], [aria-current="page"]');
|
||||
if (!pill || !on) return;
|
||||
pill.style.width = `${on.offsetWidth}px`;
|
||||
pill.style.height = `${on.offsetHeight}px`;
|
||||
pill.style.transform = `translate(${on.offsetLeft}px, ${on.offsetTop}px)`;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
// The staff pages (/staff, /staff/setup, /staff/team, /staff/mod): signing in, setting one's password with the link
|
||||
// Kira DMs, signing out, and the dashboard switch. Each dashboard's content is its own module, loaded when opened.
|
||||
// The session is an HttpOnly cookie this script never sees; the API says who is signed in (GET me).
|
||||
import { setUpTheme, setUpMenu } from "./chrome.js";
|
||||
import { api, setCsrf, escape, placePill } from "./staff-api.js";
|
||||
|
||||
const NAMES = { team: "Team", mod: "Moderation" };
|
||||
const TITLES = { team: "Team dashboard", mod: "Moderation dashboard" };
|
||||
const LOADERS = {
|
||||
team: null,
|
||||
mod: null,
|
||||
};
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const show = (sel) => {
|
||||
for (const s of ["[data-staff-login]", "[data-staff-setup]", "[data-staff-app]"]) $(s).hidden = s !== sel;
|
||||
};
|
||||
const message = (text) => {
|
||||
const m = $("[data-staff-message]");
|
||||
m.textContent = text || "";
|
||||
m.hidden = !text;
|
||||
};
|
||||
const where = () => location.pathname.replace(/\/+$/, "").split("/")[2] || "";
|
||||
|
||||
function busy(form, on, text = "") {
|
||||
form.querySelector("button[type=submit]").disabled = on;
|
||||
form.querySelector("[data-status]").textContent = text;
|
||||
}
|
||||
|
||||
async function me() {
|
||||
try {
|
||||
const who = await api("GET", "me");
|
||||
setCsrf(who.csrf);
|
||||
return who;
|
||||
} catch (e) {
|
||||
if (e.status === 401) return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
function login() {
|
||||
show("[data-staff-login]");
|
||||
$("[data-staff-title]").textContent = "Staff dashboards";
|
||||
$("[data-login-form] input[name=username]").focus();
|
||||
}
|
||||
|
||||
async function open(who) {
|
||||
const page = where();
|
||||
if (!NAMES[page]) return location.replace(`/staff/${who.dashboards[0]}`);
|
||||
show("[data-staff-app]");
|
||||
$("[data-who]").textContent = `Signed in as ${who.name}`;
|
||||
const nav = $("[data-dashboards]");
|
||||
nav.innerHTML = `<span class="tab-pill" aria-hidden="true"></span>` + who.dashboards.map((d) => `<a href="/staff/${d}"${d === page ? ' aria-current="page"' : ""}>${escape(NAMES[d])}</a>`).join("");
|
||||
nav.hidden = who.dashboards.length < 2;
|
||||
requestAnimationFrame(() => placePill(nav));
|
||||
$("[data-staff-kicker]").textContent = NAMES[page];
|
||||
$("[data-staff-title]").textContent = TITLES[page];
|
||||
document.title = `${TITLES[page]} · Horizon`;
|
||||
const content = $("[data-staff-content]");
|
||||
if (!who.dashboards.includes(page)) {
|
||||
content.innerHTML = `<p class="lede">This dashboard is for the ${page === "team" ? "🌌 Horizon Team" : "🛡️ Mods"}. ${who.dashboards.map((d) => `<a href="/staff/${d}">Open the ${escape(NAMES[d])} dashboard</a>`).join(" or ")}.</p>`;
|
||||
return;
|
||||
}
|
||||
const load = LOADERS[page];
|
||||
if (!load) {
|
||||
content.innerHTML = `<p class="lede">You are signed in. This dashboard's content comes in the next update.</p>`;
|
||||
return;
|
||||
}
|
||||
const mod = await load();
|
||||
await mod.render(content, who);
|
||||
}
|
||||
|
||||
async function route() {
|
||||
message("");
|
||||
try {
|
||||
if (where() === "setup") return setup();
|
||||
const who = await me();
|
||||
if (!who) return login();
|
||||
await open(who);
|
||||
} catch (e) {
|
||||
show(null);
|
||||
message(e.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
show("[data-staff-setup]");
|
||||
$("[data-staff-title]").textContent = "Your staff password";
|
||||
// The token is the link's #fragment: never sent to a server in a URL, and taken out of the address bar at once.
|
||||
const token = sessionStorage.getItem("staff:setup") || location.hash.slice(1);
|
||||
if (location.hash) {
|
||||
sessionStorage.setItem("staff:setup", location.hash.slice(1));
|
||||
history.replaceState(null, "", "/staff/setup");
|
||||
}
|
||||
const who = $("[data-setup-who]");
|
||||
const form = $("[data-setup-form]");
|
||||
try {
|
||||
if (!token) throw new Error("This page needs the link Kira DMs you. Run /staff password on Discord to get one.");
|
||||
const link = await api("POST", "setup/peek", { token });
|
||||
who.innerHTML = `For <strong>${escape(link.name)}</strong>: the link works once and expires ${escape(new Date(link.expiresAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }))}.`;
|
||||
form.username.value = link.name;
|
||||
form.hidden = false;
|
||||
form.password.focus();
|
||||
} catch (e) {
|
||||
sessionStorage.removeItem("staff:setup");
|
||||
who.textContent = e.message;
|
||||
}
|
||||
form.onsubmit = async (event) => {
|
||||
event.preventDefault();
|
||||
if (form.password.value !== form.again.value) return busy(form, false, "The two passwords differ.");
|
||||
busy(form, true, "Saving…");
|
||||
try {
|
||||
const signed = await api("POST", "setup", { token, password: form.password.value });
|
||||
sessionStorage.removeItem("staff:setup");
|
||||
setCsrf(signed.csrf);
|
||||
form.reset();
|
||||
location.replace(`/staff/${signed.dashboards[0]}`);
|
||||
} catch (e) {
|
||||
busy(form, false, e.message);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function setUpForms() {
|
||||
const form = $("[data-login-form]");
|
||||
form.addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
busy(form, true, "Signing in…");
|
||||
try {
|
||||
const who = await api("POST", "login", { name: form.elements.username.value, password: form.password.value });
|
||||
setCsrf(who.csrf);
|
||||
form.reset();
|
||||
busy(form, false);
|
||||
if (!NAMES[where()]) return location.replace(`/staff/${who.dashboards[0]}`);
|
||||
await open(who);
|
||||
} catch (e) {
|
||||
busy(form, false, e.message);
|
||||
form.password.select();
|
||||
}
|
||||
});
|
||||
const out = (all) => async () => {
|
||||
try {
|
||||
await api("POST", all ? "logout-all" : "logout");
|
||||
} catch {}
|
||||
setCsrf("");
|
||||
$("[data-staff-content]").innerHTML = "";
|
||||
message(all ? "Signed out on every device." : "Signed out.");
|
||||
login();
|
||||
};
|
||||
$("[data-logout]").addEventListener("click", out(false));
|
||||
$("[data-logout-all]").addEventListener("click", out(true));
|
||||
}
|
||||
|
||||
setUpTheme();
|
||||
setUpMenu();
|
||||
setUpForms();
|
||||
route();
|
||||
@@ -0,0 +1,65 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="no-js">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>Staff · Horizon</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<meta name="theme-color" content="#ece7f7" media="(prefers-color-scheme: light)">
|
||||
<meta name="theme-color" content="#0f0d17" media="(prefers-color-scheme: dark)">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
|
||||
<link rel="icon" href="/favicon-32.png" sizes="32x32" type="image/png">
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
||||
<style>{{STYLE}}</style>
|
||||
<script>document.documentElement.classList.replace("no-js", "js"); try { const t = localStorage.getItem("theme"); if (t === "light" || t === "dark") document.documentElement.dataset.theme = t } catch {}</script>
|
||||
<script type="module" src="{{STAFF_JS}}"></script>
|
||||
</head>
|
||||
<body class="subpage">
|
||||
<a class="skip" href="#main">Skip to the content</a>
|
||||
{{SITE_NAV}}
|
||||
<main id="main" class="section page-main staff">
|
||||
<div class="container">
|
||||
<p class="kicker"><span>Staff</span><span data-staff-kicker>Horizon Team and Mods</span></p>
|
||||
<h1 class="display page-title" data-staff-title>Staff dashboards</h1>
|
||||
<noscript><p class="lede">The dashboards need JavaScript.</p></noscript>
|
||||
<p class="lede staff-message" data-staff-message role="status" hidden></p>
|
||||
|
||||
<section class="staff-auth" data-staff-login hidden aria-labelledby="h-login">
|
||||
<h2 class="sr-only" id="h-login">Sign in</h2>
|
||||
<p class="hub-intro">For the 🌌 Horizon Team and the 🛡️ Mods. Your username is your Discord username. No password yet, or forgot it? Run <code>/staff password</code> on Discord: Kira DMs you a link to set one.</p>
|
||||
<form class="hub-form staff-form" data-login-form>
|
||||
<label class="hub-field"><span>Discord username</span><input name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required maxlength="64"></label>
|
||||
<label class="hub-field"><span>Password</span><input name="password" type="password" autocomplete="current-password" required maxlength="200"></label>
|
||||
<p class="hub-form-foot"><button type="submit" class="button primary">Sign in</button><span class="meta" data-status role="status"></span></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="staff-auth" data-staff-setup hidden aria-labelledby="h-setup">
|
||||
<h2 class="sr-only" id="h-setup">Set your password</h2>
|
||||
<p class="hub-intro" data-setup-who>Checking your link…</p>
|
||||
<form class="hub-form staff-form" data-setup-form hidden>
|
||||
<input name="username" autocomplete="username" hidden>
|
||||
<label class="hub-field"><span>New password (12 characters or more)</span><input name="password" type="password" autocomplete="new-password" required minlength="12" maxlength="200"></label>
|
||||
<label class="hub-field"><span>The same again</span><input name="again" type="password" autocomplete="new-password" required minlength="12" maxlength="200"></label>
|
||||
<p class="hub-form-foot"><button type="submit" class="button primary">Set it and sign in</button><span class="meta" data-status role="status"></span></p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="staff-app" data-staff-app hidden>
|
||||
<div class="staff-bar">
|
||||
<nav class="tabs staff-switch" aria-label="Dashboards" data-dashboards></nav>
|
||||
<div class="staff-who">
|
||||
<span class="meta" data-who></span>
|
||||
<button type="button" class="button secondary small" data-logout>Sign out</button>
|
||||
<button type="button" class="button ghost small" data-logout-all>Sign out everywhere</button>
|
||||
</div>
|
||||
</div>
|
||||
<div data-staff-content></div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
{{SITE_FOOTER}}
|
||||
</body>
|
||||
</html>
|
||||
@@ -945,3 +945,18 @@ code { border-radius: 0; background: var(--well); box-shadow: 0 0 0 1px var(--ou
|
||||
.mod-icon { width: 40px; height: 40px; }
|
||||
.mod-head { grid-template-columns: 1fr; }
|
||||
}
|
||||
|
||||
/* The staff dashboards (/staff): the sign-in forms, the bar with the dashboard switch and the account. */
|
||||
.staff-message { margin-bottom: 24px; color: var(--ink); }
|
||||
.staff-auth { margin-top: 24px; max-width: 34rem; }
|
||||
.staff-form { max-width: 26rem; }
|
||||
.staff-bar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 24px; margin: 8px 0 32px; }
|
||||
.staff-who { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.staff-who .meta { margin: 0 8px 0 0; }
|
||||
.staff-switch { margin-bottom: 0; }
|
||||
.staff-switch a {
|
||||
position: relative; z-index: 1; display: inline-flex; align-items: center; height: var(--segment); padding: 0 14px;
|
||||
color: var(--muted); font: 500 15px / 1 var(--pixel); transition: color var(--quick) var(--ease-quick);
|
||||
}
|
||||
.staff-switch a:hover { color: var(--ink); }
|
||||
.staff-switch a[aria-current="page"] { color: var(--on-face); }
|
||||
Reference in new issue
Block a user