131 lines
4.9 KiB
TypeScript
131 lines
4.9 KiB
TypeScript
import { useMemo } from "react";
|
|
import { enterRandomGmReview } from "../app/gmManastormRuntime";
|
|
import { useShellStore } from "../app/shellStore";
|
|
import {
|
|
configWithGmReviewNote,
|
|
configWithGmReviewStatus,
|
|
describeGmReviewTarget,
|
|
gmReviewQueue,
|
|
} from "../game/gmContentReview";
|
|
import {
|
|
GM_REVIEW_NOTE_MAX_LENGTH,
|
|
isGameMasterSession,
|
|
type GmReadinessStatus,
|
|
} from "../game/manastormAdminConfig";
|
|
import { useManastormAdminStore } from "../game/manastormAdminStore";
|
|
import { useGameStore } from "../game/store";
|
|
|
|
const STATUS_LABELS: Readonly<Record<GmReadinessStatus, string>> = {
|
|
ready: "Ready",
|
|
"needs-tested": "Needs tested",
|
|
"not-ready": "Not ready",
|
|
};
|
|
|
|
const KIND_LABELS = {
|
|
dungeon: "Dungeon review",
|
|
boss: "Boss review",
|
|
manastorm: "Manastorm review",
|
|
} as const;
|
|
|
|
export function GmReviewCard() {
|
|
const session = useShellStore((state) => state.session);
|
|
const target = useGameStore((state) => state.activeGmReviewTarget);
|
|
const config = useManastormAdminStore((state) => state.config);
|
|
const draft = useManastormAdminStore((state) => state.draft);
|
|
const dirty = useManastormAdminStore((state) => state.dirty);
|
|
const saving = useManastormAdminStore((state) => state.saving);
|
|
const error = useManastormAdminStore((state) => state.error);
|
|
const replaceDraft = useManastormAdminStore((state) => state.replaceDraft);
|
|
const publish = useManastormAdminStore((state) => state.publish);
|
|
const descriptor = useMemo(
|
|
() => target ? describeGmReviewTarget(target, draft) : null,
|
|
[draft, target],
|
|
);
|
|
const nextCount = useMemo(
|
|
() => target ? gmReviewQueue(target.kind, config, target).length : 0,
|
|
[config, target],
|
|
);
|
|
|
|
if (!target || !descriptor || !isGameMasterSession(session)) return null;
|
|
|
|
const setStatus = (status: GmReadinessStatus) => {
|
|
if (descriptor.status === status) return;
|
|
const next = configWithGmReviewStatus(draft, target, status);
|
|
if (next) replaceDraft(next);
|
|
};
|
|
|
|
const setNote = (note: string) => {
|
|
const next = configWithGmReviewNote(draft, target, note);
|
|
if (next) replaceDraft(next);
|
|
};
|
|
|
|
const targetKey = target.kind === "dungeon" ? target.dungeonId : target.bossKey;
|
|
const notePreview = descriptor.note.replace(/\s+/g, " ").trim();
|
|
const reviewMetadata = descriptor.reviewedAt && descriptor.reviewedBy
|
|
? `${descriptor.reviewedBy} · ${new Date(descriptor.reviewedAt).toLocaleString()}`
|
|
: null;
|
|
|
|
return (
|
|
<section
|
|
className={`gm-review-card is-${descriptor.status}`}
|
|
aria-label={`${KIND_LABELS[target.kind]} for ${descriptor.name}`}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
onKeyDown={(event) => event.stopPropagation()}
|
|
onKeyUp={(event) => event.stopPropagation()}
|
|
>
|
|
<header>
|
|
<span>{KIND_LABELS[target.kind]}</span>
|
|
<b>{dirty ? "Unsaved" : STATUS_LABELS[descriptor.status]}</b>
|
|
</header>
|
|
<strong>{descriptor.name}</strong>
|
|
<small>{descriptor.dungeonTitle} · Map {descriptor.mapId}</small>
|
|
<div className="gm-review-card__statuses" aria-label="Readiness">
|
|
{(["ready", "needs-tested", "not-ready"] as const).map((status) => (
|
|
<button
|
|
key={status}
|
|
type="button"
|
|
className={descriptor.status === status ? "is-active" : ""}
|
|
disabled={saving || (status === "ready" && !descriptor.runnable)}
|
|
onClick={() => setStatus(status)}
|
|
>{STATUS_LABELS[status]}</button>
|
|
))}
|
|
</div>
|
|
<details key={`${target.kind}:${targetKey}`} className="gm-review-card__note">
|
|
<summary>
|
|
<span>Review note</span>
|
|
<small title={notePreview || undefined}>{notePreview || "Add context"}</small>
|
|
</summary>
|
|
<textarea
|
|
aria-label={`Review note for ${descriptor.name}`}
|
|
value={descriptor.note}
|
|
maxLength={GM_REVIEW_NOTE_MAX_LENGTH}
|
|
rows={3}
|
|
disabled={saving}
|
|
placeholder="What needs testing or fixing?"
|
|
onChange={(event) => setNote(event.currentTarget.value)}
|
|
/>
|
|
<div>
|
|
<span>{descriptor.note.length} / {GM_REVIEW_NOTE_MAX_LENGTH}</span>
|
|
<button type="button" disabled={saving || !descriptor.note} onClick={() => setNote("")}>Clear note</button>
|
|
</div>
|
|
{reviewMetadata ? <p>Last reviewed by {reviewMetadata}</p> : <p>Not yet reviewed.</p>}
|
|
</details>
|
|
{error ? <p role="alert">{error}</p> : null}
|
|
<footer>
|
|
<button
|
|
type="button"
|
|
disabled={!dirty || saving}
|
|
onClick={() => { void publish(session); }}
|
|
>{saving ? "Publishing…" : "Save & publish"}</button>
|
|
<button
|
|
type="button"
|
|
disabled={dirty || saving || nextCount === 0}
|
|
title={nextCount === 0 ? `No remaining ${target.kind} review targets.` : undefined}
|
|
onClick={() => enterRandomGmReview(target.kind)}
|
|
>Test another</button>
|
|
</footer>
|
|
</section>
|
|
);
|
|
}
|