Add developer API, Prometheus metrics, and community links
This commit is contained in:
@@ -24,6 +24,7 @@ export function Dashboard() {
|
||||
[token, setToken] = useState(""),
|
||||
[busy, setBusy] = useState(false);
|
||||
const router = useRouter();
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const [m, t] = await Promise.all([
|
||||
@@ -34,6 +35,7 @@ export function Dashboard() {
|
||||
]);
|
||||
setMaps(m.maps);
|
||||
setTokens(t.tokens);
|
||||
setLoaded(true);
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
@@ -98,10 +100,22 @@ export function Dashboard() {
|
||||
</div>
|
||||
{error && (
|
||||
<p role="alert" className="error-message">
|
||||
{t(error)}
|
||||
{t(error)}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("Retry")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
{!maps.length && (
|
||||
{!loaded && !error && (
|
||||
<p role="status" className="muted">
|
||||
{t("Loading…")}
|
||||
</p>
|
||||
)}
|
||||
{loaded && !maps.length && (
|
||||
<div className="empty-state">
|
||||
<h2>{t("A blank map is a beginning.")}</h2>
|
||||
<p>
|
||||
|
||||
219
components/DeveloperDocumentation.tsx
Normal file
219
components/DeveloperDocumentation.tsx
Normal file
@@ -0,0 +1,219 @@
|
||||
import Link from "next/link";
|
||||
import { BookOpen, ShieldCheck } from "lucide-react";
|
||||
import {
|
||||
developerEndpoints,
|
||||
developerPythonExample,
|
||||
} from "@/shared/developer-docs";
|
||||
import styles from "@/app/dev/api/page.module.css";
|
||||
|
||||
export function DeveloperDocumentation({ locale }: { locale: string }) {
|
||||
const ru = locale === "ru";
|
||||
const l = (en: string, russian: string) => (ru ? russian : en);
|
||||
return (
|
||||
<section
|
||||
id="documentation"
|
||||
className={styles.docs}
|
||||
aria-labelledby="documentation-title"
|
||||
>
|
||||
<div className={styles.heading}>
|
||||
<BookOpen size={24} aria-hidden="true" />
|
||||
<h1 id="documentation-title">
|
||||
{l("API documentation", "Документация API")}
|
||||
</h1>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Build integrations with ordinary HTTPS requests. There is no MineGeo SDK; the Python examples use the requests library.",
|
||||
"Создавайте интеграции обычными HTTPS-запросами. SDK MineGeo пока нет; примеры на Python используют библиотеку requests.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
<strong>{l("Base URL", "Базовый адрес")}</strong>:{" "}
|
||||
<code>https://minegeo.vercel.app/api/dev/v1</code>
|
||||
</p>
|
||||
<p>
|
||||
<a href="/api/dev/openapi.json">
|
||||
{l(
|
||||
"Download the OpenAPI specification",
|
||||
"Открыть спецификацию OpenAPI",
|
||||
)}
|
||||
</a>
|
||||
</p>
|
||||
<h3>{l("1. Create a key", "1. Создайте ключ")}</h3>
|
||||
<p>
|
||||
<Link href="/dev/api">
|
||||
{l("Open the developer panel →", "Открыть панель разработчика →")}
|
||||
</Link>
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Open the developer panel, log in, name your integration and select its permissions. Public maps are selected by default. Access to your profile, private maps and game history is separate and optional. You can have up to 5 active keys, with an expiry of 1–365 days (90 by default).",
|
||||
"Откройте панель разработчика, войдите, назовите интеграцию и выберите права. По умолчанию выбраны только общие карты. Доступ к профилю, личным картам и истории игр выдаётся отдельно и необязательно. Можно создать до 5 активных ключей со сроком действия от 1 до 365 дней (по умолчанию — 90).",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Store the copied key in the MINEGEO_API_KEY environment variable on your computer or server. Keep it out of source code, repositories, browser JavaScript and URLs. If it leaks, revoke it and create a replacement.",
|
||||
"Сохраните скопированный ключ в переменной окружения MINEGEO_API_KEY на компьютере или сервере. Не добавляйте его в код, репозитории, JavaScript браузера или URL. При утечке отзовите ключ и создайте новый.",
|
||||
)}
|
||||
</p>
|
||||
<h3>
|
||||
{l("2. Make a request with Python", "2. Сделайте запрос на Python")}
|
||||
</h3>
|
||||
<pre className={styles.code}>
|
||||
<code>python -m pip install requests</code>
|
||||
</pre>
|
||||
<pre className={styles.code}>
|
||||
<code>{developerPythonExample}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{l(
|
||||
"Every v1 request needs Authorization: Bearer <your-key>. MineGeo developer keys start with mg_dev_. Contributor upload tokens, metrics tokens and your browser login cookie do not authenticate this API.",
|
||||
"Каждый запрос v1 требует заголовок Authorization: Bearer <ваш-ключ>. Ключи API MineGeo начинаются с mg_dev_. Токены загрузки карт, токены метрик и cookie входа в браузере не подходят для этого API.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Endpoints and permissions", "Методы и права доступа")}</h3>
|
||||
<div className={styles.tableWrap}>
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{l("GET endpoint", "Метод GET")}</th>
|
||||
<th>{l("Required permission", "Нужное право")}</th>
|
||||
<th>{l("Response", "Ответ")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{developerEndpoints.map((endpoint) => (
|
||||
<tr key={endpoint.path}>
|
||||
<td>
|
||||
<code>{endpoint.path}</code>
|
||||
</td>
|
||||
<td>
|
||||
<code>{endpoint.scope}</code>
|
||||
</td>
|
||||
<td>{endpoint.description[ru ? 1 : 0]}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Account endpoints always refer to the owner of the API key. They cannot read another player's private data. /me returns your account ID, display name, creation date and username appearance; it does not expose your email or account role.",
|
||||
"Методы аккаунта всегда относятся к владельцу ключа API. Они не позволяют читать личные данные другого игрока. /me возвращает ID аккаунта, отображаемое имя, дату создания и оформление имени; email и роль аккаунта не передаются.",
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
{l(
|
||||
"Map details include metadata, map bounds and panorama count. Raw storage paths, panorama coordinates, moderation notes and game answers are excluded. Game history exposes a score only after a game is complete.",
|
||||
"Сведения о карте включают метаданные, границы карты и число панорам. Внутренние пути к файлам, координаты панорам, заметки модераторов и ответы игры не передаются. История игр содержит итоговый счёт только после завершения игры.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Pagination and search", "Страницы и поиск")}</h3>
|
||||
<p>
|
||||
{l(
|
||||
"Lists accept limit (default 20, maximum 50) and offset (default 0, maximum 10000). /maps and /me/maps also accept q (up to 80 characters) to search map names. Unknown or repeated query parameters return HTTP 400. Use pagination.nextOffset for the next request; null means the end of the supported result window. Narrow q when you reach the offset limit.",
|
||||
"Списки принимают limit (по умолчанию 20, максимум 50) и offset (по умолчанию 0, максимум 10000). /maps и /me/maps также принимают q длиной до 80 символов для поиска по названию. Неизвестные или повторяющиеся параметры возвращают HTTP 400. Используйте pagination.nextOffset для следующего запроса; null означает конец доступного диапазона. При достижении предела offset уточните q.",
|
||||
)}
|
||||
</p>
|
||||
<pre className={styles.code}>
|
||||
<code>{`{
|
||||
"maps": [],
|
||||
"pagination": {"limit": 20, "offset": 0, "total": 0, "nextOffset": null}
|
||||
}`}</code>
|
||||
</pre>
|
||||
<p>
|
||||
{l(
|
||||
"GET /maps/{id} returns {map: {...}}. GET /me returns {account: {...}}. GET /me/maps uses the maps list envelope; GET /me/games uses {games: [...], pagination: {...}}. Map url values are relative to https://minegeo.vercel.app.",
|
||||
"GET /maps/{id} возвращает {map: {...}}, GET /me — {account: {...}}. GET /me/maps использует список maps; GET /me/games — {games: [...], pagination: {...}}. Поле url карты содержит путь относительно https://minegeo.vercel.app.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Rate limits", "Ограничения запросов")}</h3>
|
||||
<p>
|
||||
{l(
|
||||
"Each key can make 60 requests per minute. All keys belonging to one account share a limit of 180 requests per minute. Creating more keys does not bypass the account limit. These limits apply to authenticated requests, including requests that fail permission or input checks.",
|
||||
"Каждый ключ может выполнять 60 запросов в минуту. У всех ключей одного аккаунта общий лимит — 180 запросов в минуту. Создание новых ключей не отменяет общий лимит. Лимиты применяются к запросам с действительным ключом, включая ошибки прав доступа и проверки параметров.",
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>RateLimit-Limit</code>:{" "}
|
||||
{l("per-key request limit", "лимит запросов ключа")}
|
||||
</li>
|
||||
<li>
|
||||
<code>RateLimit-Remaining</code>:{" "}
|
||||
{l("requests remaining for this key", "оставшиеся запросы ключа")}
|
||||
</li>
|
||||
<li>
|
||||
<code>RateLimit-Reset</code>:{" "}
|
||||
{l(
|
||||
"seconds until the current minute window resets",
|
||||
"секунды до сброса текущего минутного окна",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>X-RateLimit-Owner-Limit</code> /{" "}
|
||||
<code>X-RateLimit-Owner-Remaining</code>:{" "}
|
||||
{l("shared account quota", "общая квота аккаунта")}
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
{l(
|
||||
"If you receive HTTP 429, wait for the number of seconds in Retry-After before retrying. Avoid tight retry loops. Cache catalog responses in your integration when appropriate.",
|
||||
"При ответе HTTP 429 подождите указанное в Retry-After число секунд перед повтором. Не повторяйте запросы в бесконечном быстром цикле. При необходимости кешируйте каталог в своей интеграции.",
|
||||
)}
|
||||
</p>
|
||||
<h3>{l("Errors", "Ошибки")}</h3>
|
||||
<pre className={styles.code}>
|
||||
<code>{`{"error":{"code":"insufficient_scope","message":"This API key does not have the required permission."}}`}</code>
|
||||
</pre>
|
||||
<ul>
|
||||
<li>
|
||||
<code>400</code> —{" "}
|
||||
{l(
|
||||
"invalid input or pagination",
|
||||
"некорректные параметры или пагинация",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>401</code> —{" "}
|
||||
{l(
|
||||
"missing, invalid, revoked or expired key",
|
||||
"ключ отсутствует, неверен, отозван или просрочен",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>403</code> —{" "}
|
||||
{l(
|
||||
"key is missing a required permission",
|
||||
"у ключа нет нужного права",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<code>404</code> —{" "}
|
||||
{l("map or endpoint unavailable", "карта или метод недоступны")}
|
||||
</li>
|
||||
<li>
|
||||
<code>429</code> —{" "}
|
||||
{l("request quota exhausted", "лимит запросов исчерпан")}
|
||||
</li>
|
||||
<li>
|
||||
<code>500</code> / <code>503</code> —{" "}
|
||||
{l(
|
||||
"temporary service failure; retry with a delay",
|
||||
"временная ошибка сервиса; повторите позже",
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
<div className={styles.notice}>
|
||||
<ShieldCheck size={20} aria-hidden="true" />
|
||||
<p>
|
||||
{l(
|
||||
"You can revoke a key at any time in the developer panel. Keys expire automatically and cannot be recovered. A new key can use different permissions; existing keys cannot gain additional permissions.",
|
||||
"Ключ можно отозвать в панели разработчика в любой момент. Ключи истекают автоматически и не подлежат восстановлению. Новый ключ может иметь другой набор прав; существующему ключу нельзя добавить права.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
496
components/DeveloperPanel.tsx
Normal file
496
components/DeveloperPanel.tsx
Normal file
@@ -0,0 +1,496 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
BookOpen,
|
||||
Code2,
|
||||
Copy,
|
||||
KeyRound,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
import { developerScopes } from "@/shared/developer-docs";
|
||||
import styles from "@/app/dev/api/page.module.css";
|
||||
import type { DeveloperKey, DeveloperScope } from "@/shared/developer-api";
|
||||
|
||||
type Scope = DeveloperScope;
|
||||
type Key = DeveloperKey;
|
||||
|
||||
async function request<T>(
|
||||
method = "GET",
|
||||
body?: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
const response = await fetch("/api/dev/keys", {
|
||||
method,
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
signal: signal ?? AbortSignal.timeout(30000),
|
||||
headers:
|
||||
body === undefined ? undefined : { "Content-Type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const result = await response.json();
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
typeof result.error === "string"
|
||||
? result.error
|
||||
: (result.error?.message ?? "Request failed. Please try again."),
|
||||
);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
export function DeveloperPanel({ signedIn }: { signedIn: boolean }) {
|
||||
const { locale } = useI18n();
|
||||
const ru = locale === "ru";
|
||||
const l = (en: string, russian: string) => (ru ? russian : en);
|
||||
const [keys, setKeys] = useState<Key[]>([]);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [loading, setLoading] = useState(signedIn);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
const [name, setName] = useState("");
|
||||
const [expiresDays, setExpiresDays] = useState(90);
|
||||
const [scopes, setScopes] = useState<Scope[]>(["maps:read"]);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!signedIn) return;
|
||||
const controller = new AbortController();
|
||||
void request<{ keys: Key[] }>("GET", undefined, controller.signal)
|
||||
.then((data) => {
|
||||
if (!controller.signal.aborted) {
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
}
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
if (!controller.signal.aborted)
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: ru
|
||||
? "Не удалось загрузить ключи API."
|
||||
: "Could not load API keys.",
|
||||
);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [signedIn, ru]);
|
||||
|
||||
async function reload() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await request<{ keys: Key[] }>();
|
||||
setKeys(data.keys);
|
||||
setLoaded(true);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not load API keys.", "Не удалось загрузить ключи API."),
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
async function create(event: React.FormEvent) {
|
||||
event.preventDefault();
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await request<{ key: Key; token: string }>("POST", {
|
||||
name: name.trim(),
|
||||
scopes,
|
||||
expiresDays,
|
||||
});
|
||||
setToken(result.token);
|
||||
setKeys((list) => [result.key, ...list]);
|
||||
setName("");
|
||||
setMessage(
|
||||
l(
|
||||
"Key created. Copy it before leaving or hiding it.",
|
||||
"Ключ создан. Скопируйте его перед уходом со страницы или скрытием.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not create the key.", "Не удалось создать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function revoke(id: string) {
|
||||
if (busyRef.current) return;
|
||||
busyRef.current = true;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
await request("DELETE", { id });
|
||||
setKeys((list) =>
|
||||
list.map((key) =>
|
||||
key.id === id ? { ...key, revokedAt: new Date().toISOString() } : key,
|
||||
),
|
||||
);
|
||||
setConfirmRevoke(null);
|
||||
setToken(null);
|
||||
setMessage(
|
||||
l(
|
||||
"Key revoked. Requests using it will no longer work.",
|
||||
"Ключ отозван. Запросы с ним больше не работают.",
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
setError(
|
||||
e instanceof Error
|
||||
? e.message
|
||||
: l("Could not revoke the key.", "Не удалось отозвать ключ."),
|
||||
);
|
||||
} finally {
|
||||
busyRef.current = false;
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage(l("Copied to clipboard.", "Скопировано в буфер обмена."));
|
||||
setError("");
|
||||
} catch {
|
||||
setError(
|
||||
l(
|
||||
"Could not copy. Select the key below and copy it manually.",
|
||||
"Не удалось скопировать. Выделите ключ ниже и скопируйте вручную.",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
const activeCount = keys.filter(
|
||||
(key) => !key.revokedAt && new Date(key.expiresAt).getTime() > Date.now(),
|
||||
).length;
|
||||
const date = (value: string) =>
|
||||
new Date(value).toLocaleString(ru ? "ru-RU" : "en-US");
|
||||
|
||||
return (
|
||||
<div className={styles.stack}>
|
||||
<div className={styles.intro}>
|
||||
<span className="eyebrow">
|
||||
{l("BUILD WITH MINEGEO", "СОЗДАВАЙТЕ С MINEGEO")}
|
||||
</span>
|
||||
<h1>{l("Developer API.", "API для разработчиков.")}</h1>
|
||||
<p className="lead">
|
||||
{l(
|
||||
"Use MineGeo maps and your own account data in scripts and integrations. Choose exactly what each key can read.",
|
||||
"Используйте карты MineGeo и данные своего аккаунта в скриптах и интеграциях. Выберите, что именно сможет читать каждый ключ.",
|
||||
)}
|
||||
</p>
|
||||
<nav
|
||||
className={styles.tabs}
|
||||
aria-label={l("Developer sections", "Разделы для разработчиков")}
|
||||
>
|
||||
<a className="button" href="#api-keys">
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{l("API keys", "Ключи API")}
|
||||
</a>
|
||||
<Link className="button" href="/docs?page=developer-api">
|
||||
<BookOpen size={16} aria-hidden="true" />
|
||||
{l("Documentation", "Документация")}
|
||||
</Link>
|
||||
<a className="button" href="/api/dev/openapi.json">
|
||||
<Code2 size={16} aria-hidden="true" />
|
||||
OpenAPI JSON
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<section
|
||||
id="api-keys"
|
||||
className={styles.panel}
|
||||
aria-labelledby="api-keys-title"
|
||||
>
|
||||
<div className={styles.heading}>
|
||||
<KeyRound size={24} aria-hidden="true" />
|
||||
<h2 id="api-keys-title">{l("Your API keys", "Ваши ключи API")}</h2>
|
||||
</div>
|
||||
<p>
|
||||
{l(
|
||||
"Keys belong to your account. Keep them private and grant only the access your integration needs. This API is read-only: keys cannot upload, publish, change maps or use moderation tools.",
|
||||
"Ключи принадлежат вашему аккаунту. Храните их в секрете и выдавайте только необходимые права. Этот API доступен только для чтения: ключи не позволяют загружать, публиковать, изменять карты или использовать инструменты модерации.",
|
||||
)}
|
||||
</p>
|
||||
{!signedIn ? (
|
||||
<div className={styles.notice}>
|
||||
<p>
|
||||
{l(
|
||||
"Everyone can read the documentation. Log in to create and manage your own keys; no Admin permission is required.",
|
||||
"Документация доступна всем. Войдите, чтобы создавать свои ключи и управлять ими; права администратора не нужны.",
|
||||
)}
|
||||
</p>
|
||||
<Link className="button primary" href="/log-in?next=%2Fdev%2Fapi">
|
||||
{l("Log in to manage keys", "Войти для управления ключами")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{error && (
|
||||
<p role="alert" className={`${styles.notice} ${styles.error}`}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{message && (
|
||||
<p role="status" className={styles.notice}>
|
||||
{message}
|
||||
</p>
|
||||
)}
|
||||
{token && (
|
||||
<div className={styles.token}>
|
||||
<h3>
|
||||
{l("Copy your new key now", "Скопируйте новый ключ сейчас")}
|
||||
</h3>
|
||||
<p>
|
||||
{l(
|
||||
"This is the only time the full key is shown. Hiding it or leaving this page removes it from view. MineGeo stores only its hash.",
|
||||
"Полный ключ показывается только один раз. После скрытия или ухода со страницы его нельзя посмотреть снова. MineGeo хранит только хеш ключа.",
|
||||
)}
|
||||
</p>
|
||||
<label className="field">
|
||||
{l("New API key", "Новый ключ API")}
|
||||
<input
|
||||
readOnly
|
||||
value={token}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
/>
|
||||
</label>
|
||||
<div className={styles.actions}>
|
||||
<button className="button" onClick={() => void copy(token)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{l("Copy key", "Копировать ключ")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
<X size={16} aria-hidden="true" />
|
||||
{l("Hide key", "Скрыть ключ")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<p role="status">{l("Loading keys…", "Загрузка ключей…")}</p>
|
||||
)}
|
||||
{!loading && !loaded && (
|
||||
<button className="button" onClick={() => void reload()}>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Try again", "Повторить")}
|
||||
</button>
|
||||
)}
|
||||
{loaded && (
|
||||
<>
|
||||
<form onSubmit={(event) => void create(event)}>
|
||||
<h3>{l("Create a key", "Создать ключ")}</h3>
|
||||
<p className="muted">
|
||||
{l(
|
||||
`${activeCount} of 5 active keys. Each key expires after the number of days you choose.`,
|
||||
`Активных ключей: ${activeCount} из 5. Срок действия каждого ключа задаётся в днях.`,
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.fields}>
|
||||
<label className="field">
|
||||
{l("Key name", "Название ключа")}
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
minLength={1}
|
||||
maxLength={60}
|
||||
placeholder={l(
|
||||
"My Python project",
|
||||
"Мой проект на Python",
|
||||
)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
<label className="field">
|
||||
{l("Expires in days", "Срок действия, дней")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
required
|
||||
value={expiresDays}
|
||||
onChange={(e) => setExpiresDays(Number(e.target.value))}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<fieldset className={styles.fieldset} disabled={busy}>
|
||||
<legend>{l("Permissions", "Права доступа")}</legend>
|
||||
<div className={styles.scopes}>
|
||||
{developerScopes.map((scope) => (
|
||||
<label className={styles.scope} key={scope.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={scopes.includes(scope.id)}
|
||||
onChange={(e) =>
|
||||
setScopes((selected) =>
|
||||
e.target.checked
|
||||
? [...selected, scope.id]
|
||||
: selected.filter((id) => id !== scope.id),
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span>
|
||||
<strong>{scope.name[ru ? 1 : 0]}</strong>
|
||||
<small>
|
||||
<code>{scope.id}</code> ·{" "}
|
||||
{scope.description[ru ? 1 : 0]}
|
||||
</small>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={
|
||||
busy ||
|
||||
loading ||
|
||||
scopes.length === 0 ||
|
||||
activeCount >= 5 ||
|
||||
token !== null
|
||||
}
|
||||
>
|
||||
<KeyRound size={16} aria-hidden="true" />
|
||||
{busy
|
||||
? l("Working…", "Подождите…")
|
||||
: l("Create API key", "Создать ключ API")}
|
||||
</button>
|
||||
{token && (
|
||||
<p className="muted">
|
||||
{l(
|
||||
"Copy and hide the current key before creating another.",
|
||||
"Скопируйте и скройте текущий ключ перед созданием следующего.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</form>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={loading || busy}
|
||||
onClick={() => void reload()}
|
||||
>
|
||||
<RefreshCw size={16} aria-hidden="true" />
|
||||
{l("Refresh keys", "Обновить ключи")}
|
||||
</button>
|
||||
</div>
|
||||
{keys.length === 0 && (
|
||||
<p className={styles.notice}>
|
||||
{l(
|
||||
"You have no API keys yet.",
|
||||
"У вас пока нет ключей API.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ul className={styles.keyList}>
|
||||
{keys.map((key) => {
|
||||
const expired =
|
||||
new Date(key.expiresAt).getTime() <= Date.now();
|
||||
return (
|
||||
<li key={key.id} className={styles.key}>
|
||||
<div className={styles.heading}>
|
||||
<h3>{key.name}</h3>
|
||||
<span className="badge">
|
||||
{key.revokedAt
|
||||
? l("Revoked", "Отозван")
|
||||
: expired
|
||||
? l("Expired", "Истёк")
|
||||
: l("Active", "Активен")}
|
||||
</span>
|
||||
</div>
|
||||
<code>{key.prefix}…</code>
|
||||
<dl>
|
||||
<dt>{l("Permissions", "Права")}</dt>
|
||||
<dd>{key.scopes.join(", ")}</dd>
|
||||
<dt>{l("Created", "Создан")}</dt>
|
||||
<dd>{date(key.createdAt)}</dd>
|
||||
<dt>{l("Expires", "Истекает")}</dt>
|
||||
<dd>{date(key.expiresAt)}</dd>
|
||||
<dt>{l("Last used", "Последнее использование")}</dt>
|
||||
<dd>
|
||||
{key.lastUsedAt
|
||||
? date(key.lastUsedAt)
|
||||
: l("Not used yet", "Ещё не использован")}
|
||||
</dd>
|
||||
</dl>
|
||||
{!key.revokedAt &&
|
||||
(confirmRevoke === key.id ? (
|
||||
<div>
|
||||
<p>
|
||||
{l(
|
||||
"Revoke this key? Every integration using it will lose access immediately.",
|
||||
"Отозвать этот ключ? Все интеграции с ним сразу потеряют доступ.",
|
||||
)}
|
||||
</p>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => void revoke(key.id)}
|
||||
>
|
||||
{l("Confirm revocation", "Подтвердить отзыв")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(null)}
|
||||
>
|
||||
{l("Cancel", "Отмена")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirmRevoke(key.id)}
|
||||
>
|
||||
<Trash2 size={16} aria-hidden="true" />
|
||||
{l("Revoke key", "Отозвать ключ")}
|
||||
</button>
|
||||
))}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { ModeratorSettings } from "./ModeratorSettings";
|
||||
import { ModeratorPrefixes } from "./ModeratorPrefixes";
|
||||
import { isAdministrator, type StaffRole } from "@/shared/permissions";
|
||||
import { DiscordIntegration } from "./DiscordIntegration";
|
||||
import { PrometheusIntegration } from "./PrometheusIntegration";
|
||||
type ReviewMap = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -124,6 +125,7 @@ export function Moderation() {
|
||||
{administrator && <ModeratorSettings />}
|
||||
{administrator && <ModeratorPrefixes />}
|
||||
{administrator && <DiscordIntegration />}
|
||||
{administrator && <PrometheusIntegration />}
|
||||
{role === "moderator" && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
|
||||
271
components/PrometheusIntegration.tsx
Normal file
271
components/PrometheusIntegration.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Activity, Copy, Download } from "lucide-react";
|
||||
import { api } from "@/lib/client-api";
|
||||
import { prometheusConfig, type PrometheusStatus } from "@/shared/prometheus";
|
||||
import { grafanaDashboard } from "@/shared/grafana-dashboard";
|
||||
import { useI18n } from "./I18nProvider";
|
||||
|
||||
export function PrometheusIntegration() {
|
||||
const { t, locale } = useI18n();
|
||||
const [status, setStatus] = useState<PrometheusStatus | null>(null);
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
const [confirm, setConfirm] = useState<"rotate" | "disable" | null>(null);
|
||||
async function load() {
|
||||
try {
|
||||
setStatus(await api<PrometheusStatus>("moderation/prometheus"));
|
||||
setError("");
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
async function configure(action: "rotate" | "disable") {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await api<PrometheusStatus & { token: string | null }>(
|
||||
"moderation/prometheus",
|
||||
{ action },
|
||||
);
|
||||
const { token: nextToken, ...nextStatus } = result;
|
||||
setStatus(nextStatus);
|
||||
setToken(nextToken);
|
||||
setConfirm(null);
|
||||
setMessage(
|
||||
action === "rotate"
|
||||
? "New metrics token generated. Copy the config now; it cannot be recovered later."
|
||||
: "Metrics access disabled. Existing scrape credentials no longer work.",
|
||||
);
|
||||
} catch (e) {
|
||||
setError((e as Error).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
async function copy(value: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage("Copied to clipboard.");
|
||||
setError("");
|
||||
} catch {
|
||||
setError("Could not copy. Select the text and copy it manually.");
|
||||
}
|
||||
}
|
||||
function dashboard() {
|
||||
const url = URL.createObjectURL(
|
||||
new Blob([JSON.stringify(grafanaDashboard(), null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = "minegeo-grafana-dashboard.json";
|
||||
link.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
const config =
|
||||
status && token && status.endpoint.startsWith("https:")
|
||||
? prometheusConfig(status.endpoint, token)
|
||||
: "";
|
||||
return (
|
||||
<section className="tool-card" aria-labelledby="prometheus-title">
|
||||
<div className="settings-heading">
|
||||
<Activity size={24} aria-hidden="true" />
|
||||
<h2 id="prometheus-title">{t("Prometheus & Grafana")}</h2>
|
||||
</div>
|
||||
<p>
|
||||
{t(
|
||||
"Track games, maps, multiplayer, uploads, and reports in your own Grafana dashboard. Admin permission is required to manage access.",
|
||||
)}
|
||||
</p>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Only aggregate counts are exported. Usernames, messages, emails, and map identifiers are excluded.",
|
||||
)}
|
||||
</p>
|
||||
{!status && !error && <p role="status">{t("Loading metrics setup…")}</p>}
|
||||
{status && (
|
||||
<>
|
||||
<p>
|
||||
<span className="badge">
|
||||
{status.enabled ? t("Metrics enabled") : t("Metrics disabled")}
|
||||
</span>
|
||||
</p>
|
||||
<label className="field">
|
||||
{t("Metrics endpoint")}
|
||||
<input readOnly value={status.endpoint} />
|
||||
</label>
|
||||
<button className="button" onClick={() => void copy(status.endpoint)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{t("Copy endpoint")}
|
||||
</button>
|
||||
<p className="muted">
|
||||
{t("Last successful scrape: {time}", {
|
||||
time: status.lastScrapedAt
|
||||
? new Date(status.lastScrapedAt).toLocaleString(
|
||||
locale === "ru" ? "ru-RU" : "en-GB",
|
||||
)
|
||||
: t("Not scraped yet"),
|
||||
})}
|
||||
</p>
|
||||
{!status.endpoint.startsWith("https:") && (
|
||||
<p className="error-message">
|
||||
{t(
|
||||
"Use the hosted HTTPS site to generate a production scrape config.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<ol className="metrics-steps">
|
||||
<li>
|
||||
{t(
|
||||
"Generate a token below, then copy the config into prometheus.yml. If scrape_configs already exists, add only the MineGeo job beneath it.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"Reload or restart Prometheus. Open its Targets page and check that the minegeo job is UP.",
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
{t(
|
||||
"In Grafana, add your Prometheus server as a data source. Import the downloaded dashboard JSON and select that data source.",
|
||||
)}
|
||||
</li>
|
||||
</ol>
|
||||
<p className="muted">
|
||||
{t(
|
||||
"History starts with the first scrape. Counts are cached for 30 seconds; room states are not online presence. This integration does not export page views or Vercel CPU statistics.",
|
||||
)}
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
status.enabled ? setConfirm("rotate") : void configure("rotate")
|
||||
}
|
||||
>
|
||||
{status.enabled
|
||||
? t("Rotate metrics token")
|
||||
: t("Generate metrics token")}
|
||||
</button>
|
||||
{status.enabled && (
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm("disable")}
|
||||
>
|
||||
{t("Disable metrics access")}
|
||||
</button>
|
||||
)}
|
||||
<button className="button" onClick={dashboard}>
|
||||
<Download size={16} aria-hidden="true" />
|
||||
{t("Download Grafana dashboard")}
|
||||
</button>
|
||||
<button
|
||||
className="text-link"
|
||||
disabled={busy}
|
||||
onClick={() => void load()}
|
||||
>
|
||||
{t("Refresh status")}
|
||||
</button>
|
||||
</div>
|
||||
{confirm && (
|
||||
<div
|
||||
className="token-box"
|
||||
role="group"
|
||||
aria-label={t("Confirm metrics access change")}
|
||||
>
|
||||
<p>
|
||||
{confirm === "rotate"
|
||||
? t(
|
||||
"Rotating immediately invalidates the old token. Update Prometheus with the new config afterward.",
|
||||
)
|
||||
: t(
|
||||
"Disabling immediately stops all authenticated scrapes. Prometheus will report this target as DOWN.",
|
||||
)}
|
||||
</p>
|
||||
<div className="inline-actions">
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => void configure(confirm)}
|
||||
>
|
||||
{t("Confirm change")}
|
||||
</button>
|
||||
<button
|
||||
className="button"
|
||||
disabled={busy}
|
||||
onClick={() => setConfirm(null)}
|
||||
>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{token && (
|
||||
<div className="token-box">
|
||||
<p>
|
||||
{t(
|
||||
"Keep this config private. The token is shown once and grants read-only access to aggregate metrics. Rotating or disabling it revokes access.",
|
||||
)}
|
||||
</p>
|
||||
{config ? (
|
||||
<>
|
||||
<label className="field">
|
||||
{t("Prometheus scrape config")}
|
||||
<textarea
|
||||
className="metrics-config"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={config}
|
||||
rows={15}
|
||||
/>
|
||||
</label>
|
||||
<button className="button" onClick={() => void copy(config)}>
|
||||
<Copy size={16} aria-hidden="true" />
|
||||
{t("Copy Prometheus config")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<label className="field">
|
||||
{t("Metrics token")}
|
||||
<textarea readOnly value={token} />
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
className="text-link"
|
||||
onClick={() => {
|
||||
setToken(null);
|
||||
setMessage("");
|
||||
}}
|
||||
>
|
||||
{t("Hide generated credentials")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status.enabled && !token && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
"The existing token cannot be retrieved. Use your saved config, or rotate the token to generate a new one.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="error-message" role="alert">
|
||||
{t(error)}
|
||||
</p>
|
||||
)}
|
||||
{message && <p role="status">{t(message)}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { SiteNav } from "./SiteNav";
|
||||
import { ReportBugButton } from "./ReportBugButton";
|
||||
import { ThemeToggle } from "./ThemeToggle";
|
||||
import { CommunityLinks } from "./CommunityLinks";
|
||||
import { forumUrl } from "@/lib/forum";
|
||||
export async function Header() {
|
||||
const { t } = await getI18n();
|
||||
const account = await user();
|
||||
@@ -16,7 +17,7 @@ export async function Header() {
|
||||
<header className="site-header">
|
||||
<div className="header-inner">
|
||||
<Brand />
|
||||
<SiteNav />
|
||||
<SiteNav forumUrl={forumUrl()} />
|
||||
<div className="header-actions">
|
||||
<ThemeToggle />
|
||||
<ReportBugButton />
|
||||
@@ -33,6 +34,7 @@ export async function Header() {
|
||||
}
|
||||
export async function Footer() {
|
||||
const { t } = await getI18n();
|
||||
const forum = forumUrl();
|
||||
return (
|
||||
<footer className="site-footer">
|
||||
<div>
|
||||
@@ -45,6 +47,18 @@ export async function Footer() {
|
||||
<LanguageSwitcher />
|
||||
<Link href="/downloads">{t("Tools & downloads")}</Link>
|
||||
<Link href="/docs">{t("Docs")}</Link>
|
||||
{forum && (
|
||||
<a href={forum} target="_blank" rel="noopener noreferrer">
|
||||
{t("Forum")}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href="https://minegeo.instatus.com/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t("Status")}
|
||||
</a>
|
||||
<Link href="/leaderboard">{t("Leaderboard")}</Link>
|
||||
<Link href="/moderation">{t("Moderation")}</Link>
|
||||
</div>
|
||||
|
||||
@@ -12,7 +12,7 @@ const links = [
|
||||
["/docs", "Docs"],
|
||||
] as const;
|
||||
|
||||
export function SiteNav() {
|
||||
export function SiteNav({ forumUrl }: { forumUrl?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
@@ -30,6 +30,11 @@ export function SiteNav() {
|
||||
{t(label)}
|
||||
</Link>
|
||||
))}
|
||||
{forumUrl && (
|
||||
<a href={forumUrl} target="_blank" rel="noopener noreferrer">
|
||||
{t("Forum")}
|
||||
</a>
|
||||
)}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/I18nProvider";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ArrowRight, Timer, ZoomIn, Users } from "lucide-react";
|
||||
@@ -22,7 +22,10 @@ export function StartGame({
|
||||
[loading, setLoading] = useState(false),
|
||||
[error, setError] = useState("");
|
||||
const router = useRouter();
|
||||
const starting = useRef(false);
|
||||
async function start() {
|
||||
if (starting.current) return;
|
||||
starting.current = true;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
@@ -36,6 +39,7 @@ export function StartGame({
|
||||
);
|
||||
router.push((mode === "solo" ? "/play/" : "/multiplayer/") + session.id);
|
||||
} catch (e) {
|
||||
starting.current = false;
|
||||
setError((e as Error).message);
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -68,6 +72,7 @@ export function StartGame({
|
||||
key={key}
|
||||
aria-pressed={difficulty === key}
|
||||
className={difficulty === key ? "selected" : ""}
|
||||
disabled={loading}
|
||||
onClick={() => setDifficulty(key as Difficulty)}
|
||||
>
|
||||
<strong>{t(value.label)}</strong>
|
||||
|
||||
@@ -37,19 +37,26 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
[name, setName] = useState(""),
|
||||
[file, setFile] = useState<File | null>(null);
|
||||
const nonce = useRef<string | null>(null);
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
useEffect(() => {
|
||||
let stopped = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
api<Inbox>("bug-reports")
|
||||
.then((value) => {
|
||||
if (!stopped) setInbox(value);
|
||||
})
|
||||
.catch((failure) => {
|
||||
if (!stopped) setError(failure.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!stopped) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
stopped = true;
|
||||
};
|
||||
}, []);
|
||||
}, [attempt]);
|
||||
async function submit() {
|
||||
if (busy) return;
|
||||
const clientId = (nonce.current ??= crypto.randomUUID());
|
||||
@@ -109,7 +116,7 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
</span>
|
||||
<h1>{t("Found a bug?")}</h1>
|
||||
<p className="lead">{t("Tell us what happened. We’ll reply here.")}</p>
|
||||
{!inbox?.signedIn && (
|
||||
{inbox && !inbox.signedIn && (
|
||||
<p className="muted">
|
||||
{t(
|
||||
"Guest reports stay in this browser. Sign in to keep your conversation in your account.",
|
||||
@@ -120,6 +127,19 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
{error && (
|
||||
<p className="error-message" role="alert">
|
||||
{t(error)}
|
||||
{!inbox && (
|
||||
<>
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="text-link"
|
||||
disabled={loading}
|
||||
onClick={() => setAttempt((value) => value + 1)}
|
||||
>
|
||||
{t("Retry")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{inbox && !inbox.ready && (
|
||||
@@ -135,7 +155,7 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
void submit();
|
||||
}}
|
||||
>
|
||||
{!inbox?.signedIn && (
|
||||
{inbox && !inbox.signedIn && (
|
||||
<>
|
||||
<label htmlFor="report-name">{t("Your player name")}</label>
|
||||
<input
|
||||
@@ -199,7 +219,10 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
<small>
|
||||
{t("PNG, JPG, or WebP, up to 5 MB. Shared privately with support.")}
|
||||
</small>
|
||||
<button className="button primary" disabled={busy || !inbox?.ready}>
|
||||
<button
|
||||
className="button primary"
|
||||
disabled={busy || loading || !inbox?.ready}
|
||||
>
|
||||
<Send size={17} />
|
||||
{busy ? t("Sending…") : t("Send bug report")}
|
||||
</button>
|
||||
@@ -207,9 +230,9 @@ export function SupportInbox({ pagePath }: { pagePath: string }) {
|
||||
</section>
|
||||
<section className="support-inbox">
|
||||
<h2>{t("Your bug reports")}</h2>
|
||||
{!inbox ? (
|
||||
{!inbox && loading ? (
|
||||
<p className="muted">{t("Loading…")}</p>
|
||||
) : inbox.reports.length === 0 ? (
|
||||
) : !inbox ? null : inbox.reports.length === 0 ? (
|
||||
<p className="muted">
|
||||
{t("Your reports and support replies will appear here.")}
|
||||
</p>
|
||||
|
||||
Reference in New Issue
Block a user