Add developer API, Prometheus metrics, and community links

This commit is contained in:
2026-10-12 19:59:37 +05:00
parent 3cc3136762
commit 018be5049e
50 changed files with 4087 additions and 31 deletions

View File

@@ -0,0 +1,8 @@
import { developerKeys } from "@/lib/developer-key-management";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const GET = developerKeys;
export const POST = developerKeys;
export const DELETE = developerKeys;
export const PUT = developerKeys;
export const PATCH = developerKeys;

View File

@@ -0,0 +1,10 @@
import { developerOpenApi } from "@/shared/developer-openapi";
export const dynamic = "force-static";
export function GET() {
return Response.json(developerOpenApi, {
headers: {
"Cache-Control": "public, max-age=3600",
"X-Content-Type-Options": "nosniff",
},
});
}

3
app/api/dev/route.ts Normal file
View File

@@ -0,0 +1,3 @@
export function GET(request: Request) {
return Response.redirect(new URL("/dev/api", request.url), 307);
}

View File

@@ -0,0 +1,17 @@
import { developerApi } from "@/lib/developer-api";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 30;
async function handle(
request: Request,
{ params }: { params: Promise<{ path: string[] }> },
) {
return developerApi(request, (await params).path);
}
export {
handle as GET,
handle as POST,
handle as PATCH,
handle as PUT,
handle as DELETE,
};

16
app/api/metrics/route.ts Normal file
View File

@@ -0,0 +1,16 @@
import { admin, required } from "@/lib/supabase";
import { metricsResponse } from "@/lib/prometheus-response";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
export const maxDuration = 30;
export function GET(request: Request) {
return metricsResponse(request, async (tokenHash) => {
const result = await admin().rpc("read_prometheus_metrics", {
p_token_hash: tokenHash,
});
// NULL is an invalid or disabled credential, not a missing database record.
if (result.error) required(result);
return result.data;
});
}

View File

@@ -384,7 +384,7 @@ html[data-theme="light"] .site-cursor-dot span {
background: #273b21;
box-shadow: 0 0 1px #fff;
}
@media (max-width: 1000px) {
@media (max-width: 1200px) {
.site-header {
height: auto;
}
@@ -436,7 +436,7 @@ html[data-theme="light"] .site-cursor-dot span {
font-size: 17px;
}
}
@media (max-width: 380px) {
@media (max-width: 600px) {
.header-actions {
grid-row: 2;
grid-column: 1 / -1;

225
app/dev/api/page.module.css Normal file
View File

@@ -0,0 +1,225 @@
.intro {
max-width: 780px;
}
.tabs {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 24px 0 36px;
}
.stack {
display: grid;
gap: 32px;
}
.panel {
padding: clamp(20px, 4vw, 32px);
border: 1px solid var(--line);
border-radius: 18px;
background: var(--panel);
min-width: 0;
}
.panel h2 {
font-size: clamp(24px, 3vw, 32px);
}
.heading {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
}
.heading h2 {
margin: 0;
}
.fields {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(140px, 200px);
gap: 20px;
}
.fieldset {
border: 0;
padding: 0;
margin: 24px 0;
min-width: 0;
}
.fieldset legend {
font-weight: 700;
margin-bottom: 12px;
}
.scopes {
display: grid;
gap: 12px;
}
.scope {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 10px;
}
.scope input {
margin-top: 5px;
width: 18px;
height: 18px;
flex: 0 0 18px;
accent-color: var(--green);
}
.scope strong,
.scope small {
display: block;
}
.scope small {
color: var(--muted);
line-height: 1.6;
}
.actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin: 20px 0 0;
}
.token {
margin-bottom: 24px;
padding: 20px;
border: 1px solid var(--green);
border-radius: 12px;
background: var(--panel2);
}
.token input {
width: 100%;
font-family: monospace;
font-size: 13px;
}
.keyList {
display: grid;
gap: 16px;
padding: 0;
list-style: none;
margin: 24px 0 0;
}
.key {
border: 1px solid var(--line);
border-radius: 12px;
padding: 20px;
min-width: 0;
}
.key h3 {
overflow-wrap: anywhere;
margin: 0;
font-size: 20px;
}
.key dl {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 6px 18px;
margin: 16px 0;
}
.key dt {
color: var(--muted);
}
.key dd {
margin: 0;
overflow-wrap: anywhere;
}
.key code {
overflow-wrap: anywhere;
}
.notice {
padding: 14px 18px;
border-left: 3px solid var(--green);
background: var(--panel2);
margin: 20px 0;
}
.error {
color: var(--text);
border-left-color: #ed7878;
}
.docs {
scroll-margin-top: 120px;
}
.docs h3 {
margin-top: 32px;
}
.docs p,
.docs li {
line-height: 1.8;
}
.docs ul,
.docs ol {
padding-left: 24px;
}
.docs ul {
list-style: disc;
}
.docs ol {
list-style: decimal;
}
.docs a {
color: var(--green);
text-decoration: underline;
text-underline-offset: 3px;
}
.docs code {
overflow-wrap: anywhere;
font-family: monospace;
}
.code {
overflow-x: auto;
padding: 20px;
border-radius: 10px;
border: 1px solid var(--line);
background: var(--bg);
font-family: monospace;
font-size: 13px;
line-height: 1.7;
max-width: 100%;
}
.code code {
overflow-wrap: normal;
}
.tableWrap {
overflow-x: auto;
max-width: 100%;
}
.table {
width: 100%;
border-collapse: collapse;
text-align: left;
}
.table th,
.table td {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
.table th {
color: var(--muted);
}
.table td:first-child {
min-width: 150px;
}
.table td:last-child {
min-width: 200px;
}
@media (max-width: 600px) {
.fields {
grid-template-columns: minmax(0, 1fr);
}
.key dl {
grid-template-columns: minmax(0, 1fr);
gap: 2px;
}
.key dd {
margin-bottom: 10px;
}
.code {
padding: 14px;
font-size: 12px;
}
.table th,
.table td {
padding: 12px;
}
}

23
app/dev/api/page.tsx Normal file
View File

@@ -0,0 +1,23 @@
import type { Metadata } from "next";
import { Header, Footer } from "@/components/Shell";
import { DeveloperPanel } from "@/components/DeveloperPanel";
import { user } from "@/lib/supabase";
export const metadata: Metadata = {
title: "Developer API",
description: "Create and manage scoped MineGeo API keys.",
};
export const dynamic = "force-dynamic";
export default async function DeveloperApiPage() {
const account = await user();
return (
<>
<Header />
<main className="section page-content">
<DeveloperPanel signedIn={Boolean(account)} />
</main>
<Footer />
</>
);
}

View File

@@ -1,8 +1,10 @@
import Link from "next/link";
import { DeveloperDocumentation } from "@/components/DeveloperDocumentation";
import { notFound } from "next/navigation";
import { Header, Footer } from "@/components/Shell";
import guides from "@/shared/player-docs.json";
import styles from "./page.module.css";
import { getI18n } from "@/lib/i18n";
export const metadata = { title: "Player docs" };
@@ -76,9 +78,18 @@ export default async function Docs({
searchParams: Promise<{ page?: string }>;
}) {
const { page = "welcome" } = await searchParams;
const guide = guides.find((item) => item.slug === page);
const { locale } = await getI18n();
const pages = [
...guides,
{
slug: "developer-api",
title: locale === "ru" ? "API для разработчиков" : "Developer API",
markdown: "",
},
];
const guide = pages.find((item) => item.slug === page);
if (!guide) notFound();
const index = guides.indexOf(guide);
const index = pages.indexOf(guide);
return (
<>
<Header />
@@ -86,7 +97,7 @@ export default async function Docs({
<aside>
<h2>Player docs</h2>
<nav aria-label="Documentation pages" className={styles.menu}>
{guides.map((item) => (
{pages.map((item) => (
<Link
key={item.slug}
href={`/docs?page=${item.slug}`}
@@ -98,16 +109,20 @@ export default async function Docs({
</nav>
</aside>
<article className={`prose ${styles.article}`}>
{content(guide.markdown)}
{page === "developer-api" ? (
<DeveloperDocumentation locale={locale} />
) : (
content(guide.markdown)
)}
<div className={styles.paging}>
{index > 0 && (
<Link href={`/docs?page=${guides[index - 1].slug}`}>
← {guides[index - 1].title}
<Link href={`/docs?page=${pages[index - 1].slug}`}>
← {pages[index - 1].title}
</Link>
)}
{index < guides.length - 1 && (
<Link href={`/docs?page=${guides[index + 1].slug}`}>
{guides[index + 1].title} →
{index < pages.length - 1 && (
<Link href={`/docs?page=${pages[index + 1].slug}`}>
{pages[index + 1].title} →
</Link>
)}
</div>

View File

@@ -1,6 +1,13 @@
@import "tailwindcss" source(none);
@source ".";
@source "../components";
.metrics-steps {
list-style: decimal;
padding-left: 24px;
margin: 20px 0;
}
.metrics-steps li { margin: 12px 0; }
.metrics-config { font-family: monospace; font-size: 12px; overflow-wrap: normal; white-space: pre; }
:root {
--bg: #0b100f;
--panel: #121b17;

View File

@@ -6,7 +6,7 @@ export default async function Review() {
return (
<>
<Header />
<main className="section page-content">
<main className="section page-content moderation-page">
<span className="eyebrow">{t("THE COMMUNITY’S COMPASS")}</span>
<h1>{t("Map moderation.")}</h1>
<p className="lead">

View File

@@ -125,6 +125,29 @@ html[lang="ru"] .mode-row h3 span {
.auth-tabs button {
border-radius: var(--radius-control);
}
/* Keep catalog cards readable instead of squeezing long map names. */
.cards-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
}
.map-card .card-body .map-credit {
display: block;
overflow: visible;
min-height: 0;
-webkit-line-clamp: unset;
}
.map-card .card-body .eyebrow {
overflow-wrap: anywhere;
}
.search-field:focus-within {
outline: 2px solid var(--green);
outline-offset: 4px;
}
.dashboard-toolbar {
flex-wrap: wrap;
}
.dashboard-toolbar > * {
min-width: 0;
}
@media (max-width: 850px) {
.site-header {
height: auto;
@@ -209,12 +232,38 @@ html[lang="ru"] .mode-row h3 span {
.home-panel + .home-panel {
min-width: 0;
}
.map-card .card-body p {
display: -webkit-box;
}
.map-card .card-meta span:last-child {
display: flex;
}
}
.moderator-settings {
margin-bottom: 28px;
}
.moderation-page :is(.tool-card, .draft-row) {
margin-bottom: 32px;
}
.moderator-setting-row {
display: flex;
gap: 24px;
justify-content: space-between;
align-items: center;
margin-top: 24px;
}
.moderator-setting-row h3 {
margin: 0 0 8px;
}
.moderator-setting-row p {
margin: 6px 0;
}
.moderator-setting-row .button {
flex-shrink: 0;
}
.moderator-settings { margin-bottom: 28px; }
.moderator-setting-row { display: flex; gap: 24px; justify-content: space-between; align-items: center; margin-top: 24px; }
.moderator-setting-row h3 { margin: 0 0 8px; }
.moderator-setting-row p { margin: 6px 0; }
.moderator-setting-row .button { flex-shrink: 0; }
@media (max-width: 600px) {
.moderator-setting-row { align-items: stretch; flex-direction: column; }
.moderator-setting-row {
align-items: stretch;
flex-direction: column;
}
}