Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 6m2s
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.
223 lines
7.0 KiB
TypeScript
223 lines
7.0 KiB
TypeScript
"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 <token></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>
|
||
</>
|
||
);
|
||
}
|