Files
MineGeo/components/Dashboard.tsx
pavel444-byte 05854818a0
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m2s
Map admins: owners invite friends to co-manage maps
Owners can invite players by name from the map workshop; invitees accept
from their dashboard and then manage the map (details, locations,
submission, refresh, contributions, appeals) through owner-delegated
checks. Adds the map_admins table, invite/admin API endpoints, dashboard
invite box, workshop admin management, and Russian translations.
2026-10-31 17:28:05 +05:00

223 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { statusMessage } from "@/shared/i18n";
import { useI18n } from "@/components/I18nProvider";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { createBrowserClient } from "@supabase/ssr";
import { useRouter } from "next/navigation";
import { api } from "./StartGame";
import type { MapListing } from "@/lib/catalog";
import type { ProcessingJob } from "@/shared/processing-progress";
import { ProcessingProgress } from "./ProcessingProgress";
import { ImportMap } from "./MapTransfer";
import { AdminInvites } from "./AdminInvites";
type Draft = MapListing & {
access?: "owner" | "admin";
upload_jobs: ProcessingJob | null;
};
export function Dashboard() {
const { t } = useI18n();
const [maps, setMaps] = useState<Draft[]>([]),
[error, setError] = useState(""),
[tokens, setTokens] = useState<
{ id: string; label: string; revoked: boolean }[]
>([]),
[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([
api<{ maps: Draft[] }>("datasets"),
api<{ tokens: { id: string; label: string; revoked: boolean }[] }>(
"tokens",
),
]);
setMaps(m.maps);
setTokens(t.tokens);
setLoaded(true);
setError("");
} catch (e) {
setError((e as Error).message);
}
}, []);
useEffect(() => {
load();
const timer = setInterval(load, 10000);
return () => clearInterval(timer);
}, [load]);
async function newToken() {
setBusy(true);
try {
const result = await api<{ token: string }>("tokens", {
label: "Server plugin",
});
setToken(result.token);
await load();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function revoke(id: string) {
try {
await api("tokens/" + id, undefined, "DELETE");
await load();
} catch (e) {
setError((e as Error).message);
}
}
async function logout() {
const client = createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
);
const { error: signOutError } = await client.auth.signOut();
if (signOutError) {
setError("Could not sign out. Please try again.");
return;
}
router.refresh();
}
return (
<>
<div className="dashboard-toolbar">
<p className="muted">
{t(
"Your maps start privately. You decide when they’re ready for review. Contributions are hidden here after five days. Their panoramas and direct links stay available.",
)}
</p>
<div className="inline-actions">
<ImportMap signedIn />
<Link className="button" href="/settings">
{t("Account settings")}
</Link>
<button className="button" onClick={logout}>
{t("Sign out")}
</button>
</div>
</div>
{error && (
<p role="alert" className="error-message">
{t(error)}{" "}
<button
type="button"
className="text-link"
onClick={() => void load()}
>
{t("Retry")}
</button>
</p>
)}
{!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>
{t(
"Install the Fabric mod to capture while you explore, or the server plugin to export your world.",
)}
</p>
<Link className="button primary" href="/downloads">
{t("Get the capture tools →")}
</Link>
</div>
)}
<AdminInvites onChange={load} />
{maps.map((m) => (
<article className="draft-row" key={m.id}>
<div>
<span className="eyebrow">
{m.source === "plugin" ? t("SERVER EXPORT") : t("PLAYER CAPTURE")}
</span>
<h3>{m.name}</h3>
{m.access === "admin" && (
<span className="badge">{t("Map admin")}</span>
)}{" "}
<span className="badge">
{m.contribution_status
? t("Contribution: {status}", {
status: t(statusMessage(m.contribution_status)),
})
: m.update_state
? t("Update: {status}", {
status: t(statusMessage(m.update_state)),
})
: t(statusMessage(m.state))}
</span>{" "}
<span className="muted">
{m.panorama_count} {t("locations")}
</span>
{m.banned_at ? (
<p className="error-message">
{t(
"Map banned after three confirmed reports. Open your map to appeal.",
)}
</p>
) : m.report_strikes > 0 ? (
<p className="muted">
{m.report_strikes}
{t("/3 confirmed reports")}
</p>
) : null}
<ProcessingProgress job={m.upload_jobs} />
{m.rejection_reason && (
<p className="error-message">{m.rejection_reason}</p>
)}
{m.upload_jobs?.error && (
<p className="muted">{m.upload_jobs.error}</p>
)}
</div>
<Link href={"/dashboard/" + m.id} className="button">
{m.parent_dataset_id ? t("Open contribution →") : t("Open map →")}
</Link>
</article>
))}
<section className="token-box">
<span className="eyebrow">{t("FOR SERVER ADMINISTRATORS")}</span>
<h2>{t("Link your server plugin.")}</h2>
<p className="muted">
{t("Create a contributor token, then run")}{" "}
<code>/minegeo link &lt;token&gt;</code>{" "}
{t("in your server console. Fabric uses browser pairing instead.")}
</p>
<button className="button" disabled={busy} onClick={newToken}>
{t("Generate plugin token")}
</button>
{token && (
<>
<p className="success-message">
{t(
"Copy this token now. It is shown once. Keep it out of public chat.",
)}
</p>
<code>{token}</code>
</>
)}
{tokens.map((entry) => (
<div className="draft-row" key={entry.id}>
<span>
{entry.label}{" "}
<small className="muted">
{entry.revoked ? t("· revoked") : ""}
</small>
</span>
{!entry.revoked && (
<button className="button" onClick={() => revoke(entry.id)}>
{t("Revoke")}
</button>
)}
</div>
))}
</section>
</>
);
}