149 lines
8.9 KiB
TypeScript
149 lines
8.9 KiB
TypeScript
import { useMemo, useRef, useState } from "react";
|
|
import {
|
|
DUNGEON_REPORT_STATUSES, filterDungeonReports, sessionCanManageDungeonReports, updateDungeonReport,
|
|
type DungeonReport, type DungeonReportStatus,
|
|
} from "../app/dungeonReports";
|
|
import { useShellStore } from "../app/shellStore";
|
|
import { DualDisplayFrame } from "../components/DualDisplayFrame";
|
|
import { DUNGEON_CATALOG, dungeonById } from "../game/dungeonCatalog";
|
|
import { useMenuController, type MenuAction } from "../input/useMenuController";
|
|
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
|
|
import { ControllerButton } from "./ControllerButton";
|
|
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
|
|
import { useDungeonReports } from "./useDungeonReports";
|
|
|
|
function ReportCard({ report, canManage, saving, onUpdate, selectedId, select }: {
|
|
readonly report: DungeonReport;
|
|
readonly canManage: boolean;
|
|
readonly saving: boolean;
|
|
readonly onUpdate: (report: DungeonReport, status: DungeonReportStatus) => void;
|
|
readonly selectedId: string;
|
|
readonly select: (id: string) => void;
|
|
}) {
|
|
return (
|
|
<article className={`dungeon-report-card ${report.status === "Closed" ? "is-closed" : ""}`}>
|
|
<header><h2>{dungeonById(report.dungeonId)?.title ?? report.dungeonId}</h2><span className="dungeon-report-status">{report.status}</span></header>
|
|
<p className="dungeon-report-comment">{report.comment}</p>
|
|
<small>Submitted by {report.submittedBy} · <time dateTime={new Date(report.createdAt).toISOString()}>{new Date(report.createdAt).toLocaleString()}</time></small>
|
|
{report.updatedBy && <small>Updated by {report.updatedBy} · {new Date(report.updatedAt).toLocaleString()}</small>}
|
|
{canManage && (
|
|
<div className="dungeon-report-management">
|
|
<label>Status
|
|
<select aria-label={`Status for report ${report.id}`} value={report.status} disabled={saving}
|
|
className={selectedId === `report-status-${report.id}` ? "is-controller-selected" : ""}
|
|
onPointerEnter={() => select(`report-status-${report.id}`)}
|
|
onChange={(event) => onUpdate(report, event.target.value as DungeonReportStatus)}>
|
|
{DUNGEON_REPORT_STATUSES.map((status) => <option key={status} value={status}>{status}</option>)}
|
|
</select>
|
|
</label>
|
|
{report.status !== "Closed" && <ControllerButton controlId={`report-close-${report.id}`} selectedId={selectedId} select={select}
|
|
type="button" className="front-button" disabled={saving} onClick={() => onUpdate(report, "Closed")}>Close report</ControllerButton>}
|
|
</div>
|
|
)}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
export function DungeonReportsScreen({ onClose }: { readonly onClose: () => void }) {
|
|
const session = useShellStore((state) => state.session);
|
|
const { snapshot, loading, error, refresh } = useDungeonReports();
|
|
const [status, setStatus] = useState<DungeonReportStatus | "all">("all");
|
|
const [dungeonId, setDungeonId] = useState("all");
|
|
const [saving, setSaving] = useState(false);
|
|
const [updateError, setUpdateError] = useState("");
|
|
const [message, setMessage] = useState("");
|
|
const busy = useRef(false);
|
|
const canManage = sessionCanManageDungeonReports(session);
|
|
const reports = filterDungeonReports(snapshot?.reports ?? [], status, dungeonId);
|
|
const dungeonOptions = useMemo(() => [...DUNGEON_CATALOG].sort((a, b) => a.title.localeCompare(b.title)), []);
|
|
const actions = useMemo<MenuAction[]>(() => [
|
|
{ id: "reports-back", run: onClose, enabled: !saving },
|
|
{ id: "reports-filter-status", run: () => {
|
|
const options = ["all", ...DUNGEON_REPORT_STATUSES] as const;
|
|
setStatus(options[(options.indexOf(status) + 1) % options.length]);
|
|
} },
|
|
{ id: "reports-filter-dungeon", run: () => {
|
|
const options = ["all", ...dungeonOptions.map((dungeon) => dungeon.id)];
|
|
setDungeonId(options[(options.indexOf(dungeonId) + 1) % options.length]);
|
|
} },
|
|
{ id: "reports-clear-filters", run: () => { setStatus("all"); setDungeonId("all"); } },
|
|
{ id: "reports-refresh", run: () => void refresh(), enabled: !loading && !saving },
|
|
...(canManage ? reports.flatMap((report) => [
|
|
{ id: `report-status-${report.id}`, enabled: !saving, run: () => {
|
|
const nextStatus = DUNGEON_REPORT_STATUSES[(DUNGEON_REPORT_STATUSES.indexOf(report.status) + 1) % DUNGEON_REPORT_STATUSES.length];
|
|
void update(report, nextStatus);
|
|
} },
|
|
...(report.status === "Closed" ? [] : [{ id: `report-close-${report.id}`, enabled: !saving, run: () => void update(report, "Closed") }]),
|
|
]) : []),
|
|
], [canManage, dungeonId, dungeonOptions, loading, onClose, refresh, reports, saving, session, status]);
|
|
const controller = useMenuController(actions, { initialId: "reports-back", onBack: saving ? undefined : onClose });
|
|
const update = async (report: DungeonReport, nextStatus: DungeonReportStatus) => {
|
|
if (!session || !canManage || busy.current) return;
|
|
busy.current = true;
|
|
setSaving(true);
|
|
setUpdateError("");
|
|
setMessage("");
|
|
try {
|
|
await updateDungeonReport(session, report, nextStatus);
|
|
setMessage(`Report status updated to ${nextStatus}.`);
|
|
} catch (failure) {
|
|
setUpdateError(failure instanceof Error ? failure.message : "The status could not be updated.");
|
|
} finally {
|
|
await refresh();
|
|
busy.current = false;
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<DualDisplayFrame contextLabel="Dungeon reports" top={
|
|
<FrontSurface className="dungeon-reports-screen" ariaLabel="Dungeon reports">
|
|
<header className="dungeon-reports-header">
|
|
<BrandMark compact />
|
|
<div><span>Main menu / Dungeon feedback</span><h1>View Reports</h1></div>
|
|
<ControllerButton controlId="reports-back" selectedId={controller.selectedId} select={controller.select}
|
|
className="front-button" type="button" onClick={onClose} disabled={saving}>Back to main menu</ControllerButton>
|
|
</header>
|
|
<main className="dungeon-reports-main">
|
|
<div className="dungeon-report-filters">
|
|
<label>Status
|
|
<select value={status} onChange={(event) => setStatus(event.target.value as DungeonReportStatus | "all")}
|
|
className={controller.selectedId === "reports-filter-status" ? "is-controller-selected" : ""}
|
|
onPointerEnter={() => controller.select("reports-filter-status")}>
|
|
<option value="all">All statuses</option>
|
|
{DUNGEON_REPORT_STATUSES.map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</label>
|
|
<label>Dungeon
|
|
<select value={dungeonId} onChange={(event) => setDungeonId(event.target.value)}
|
|
className={controller.selectedId === "reports-filter-dungeon" ? "is-controller-selected" : ""}
|
|
onPointerEnter={() => controller.select("reports-filter-dungeon")}>
|
|
<option value="all">All dungeons</option>
|
|
{dungeonOptions.map((dungeon) => (
|
|
<option key={dungeon.id} value={dungeon.id}>{dungeon.title}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<ControllerButton controlId="reports-clear-filters" selectedId={controller.selectedId} select={controller.select}
|
|
type="button" className="front-button" onClick={() => { setStatus("all"); setDungeonId("all"); }}>Clear filters</ControllerButton>
|
|
<ControllerButton controlId="reports-refresh" selectedId={controller.selectedId} select={controller.select}
|
|
className="front-button" type="button" disabled={loading || saving} onClick={() => void refresh()}>
|
|
{loading ? "Refreshing..." : "Refresh"}
|
|
</ControllerButton>
|
|
</div>
|
|
<p className="dungeon-reports-summary">{reports.length} of {snapshot?.reports.length ?? 0} reports · {canManage ? "GM status controls enabled" : "All players can view reports. GMs manage their status."}</p>
|
|
{(error || updateError) && <p role="alert" className="front-notice">{updateError || error}{snapshot && error ? " Showing the last loaded reports." : ""}</p>}
|
|
{message && <p role="status" className="front-notice">{message}</p>}
|
|
<section className="dungeon-report-list" aria-label="Reports" aria-busy={loading || saving}>
|
|
{loading && !snapshot ? <p role="status">Loading reports...</p> : snapshot && reports.length === 0 ? (
|
|
<p>{snapshot.reports.length ? "No reports match these filters." : "No dungeon reports have been submitted yet."}</p>
|
|
) : reports.map((report) => <ReportCard key={report.id} report={report} canManage={canManage} saving={saving}
|
|
onUpdate={(item, nextStatus) => void update(item, nextStatus)} selectedId={controller.selectedId} select={controller.select} />)}
|
|
</section>
|
|
</main>
|
|
<ControllerLegend back />
|
|
</FrontSurface>
|
|
} bottom={<LazyLocalCompanionPanel />} />
|
|
);
|
|
}
|