Release Healer Man 0.1.38

This commit is contained in:
phenom committed 2026-09-12 23:55:24 -04:00
1 parent 361734bc41
commit b417b7c3c3
48 files changed
+1982 -90

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
import { useMemo, useRef, useState, type FormEvent } from "react";
import { useShellStore } from "../app/shellStore";
import { DUNGEON_REPORT_COMMENT_MAX_LENGTH, submitDungeonReport } from "../app/dungeonReports";
import { dungeonById } from "../game/dungeonCatalog";
import { useGameStore } from "../game/store";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { ControllerButton } from "./ControllerButton";
export function DungeonIssueReportPanel() {
const dungeonId = useGameStore((state) => state.activeDungeonId);
const openOverlay = useGameStore((state) => state.openOverlay);
const session = useShellStore((state) => state.session);
const [comment, setComment] = useState("");
const [submitting, setSubmitting] = useState(false);
const [submitted, setSubmitted] = useState(false);
const [error, setError] = useState("");
const busy = useRef(false);
const form = useRef<HTMLFormElement>(null);
const back = () => { if (!busy.current) openOverlay("pause"); };
const canSubmit = Boolean(comment.trim()) && comment.length <= DUNGEON_REPORT_COMMENT_MAX_LENGTH && !submitting;
const actions = useMemo<MenuAction[]>(() => [
{ id: "report-back", run: back, enabled: !submitting },
{ id: "report-submit", run: () => form.current?.requestSubmit(), enabled: canSubmit && !submitted },
], [canSubmit, openOverlay, submitted, submitting]);
const controller = useMenuController(actions, { initialId: "report-back", onBack: back });
const submit = async (event: FormEvent) => {
event.preventDefault();
if (!canSubmit || busy.current || submitted) return;
busy.current = true;
setSubmitting(true);
setError("");
try {
await submitDungeonReport(session, dungeonId, comment);
setSubmitted(true);
} catch (failure) {
setError(failure instanceof Error ? failure.message : "Your report could not be submitted. Please try again.");
} finally {
busy.current = false;
setSubmitting(false);
}
};
return (
<div className="modal-backdrop" role="presentation">
<section className="pause-menu dungeon-issue-report" role="dialog" aria-modal="true" aria-labelledby="issue-report-title">
<p className="eyebrow">Dungeon feedback</p>
<h2 id="issue-report-title">Report an issue</h2>
<p>{dungeonById(dungeonId)?.title ?? dungeonId}</p>
{submitted ? <p role="status">Report submitted. You can follow its status in View Reports on the main menu.</p> : (
<form ref={form} onSubmit={(event) => void submit(event)}>
<label htmlFor="dungeon-issue-comment">Describe the issue</label>
<textarea id="dungeon-issue-comment" autoFocus required maxLength={DUNGEON_REPORT_COMMENT_MAX_LENGTH}
value={comment} disabled={submitting} onChange={(event) => setComment(event.target.value)}
aria-describedby="dungeon-issue-count" placeholder="What happened, and what were you doing when it happened?" />
<small id="dungeon-issue-count">{comment.length} / {DUNGEON_REPORT_COMMENT_MAX_LENGTH} characters</small>
{error && <p role="alert" className="dungeon-report-error">{error}</p>}
<ControllerButton controlId="report-submit" selectedId={controller.selectedId} select={controller.select}
className="button button--primary" type="submit" disabled={!canSubmit}>
{submitting ? "Submitting..." : "Submit report"}
</ControllerButton>
</form>
)}
<ControllerButton controlId="report-back" selectedId={controller.selectedId} select={controller.select}
className="button" type="button" onClick={back} disabled={submitting}>Back to pause menu</ControllerButton>
</section>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
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 />} />
);
}
+11 -2
View File
@@ -26,6 +26,8 @@ import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import { specializationById } from "../game/specializationCatalog";
import { DUNGEON_RELEASE_STAGE_LABELS } from "../game/dungeonReleaseStage";
import { useDungeonReports } from "./useDungeonReports";
installDungeonShellRuntime();
@@ -50,6 +52,7 @@ interface PendingDungeonDownload {
}
export function DungeonSelectScreen() {
const reports = useDungeonReports();
const contentState = useContentManagerSnapshot();
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
@@ -253,19 +256,24 @@ export function DungeonSelectScreen() {
controlId={`dungeon-${dungeon.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""}`}
className={`dungeon-select-row ${dungeon.id === selectedDungeon?.id ? "is-active" : ""} ${(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 ? "has-open-reports" : ""}`}
type="button"
aria-pressed={dungeon.id === selectedDungeon?.id}
onClick={() => selectDungeon(dungeon.id)}
>
<b aria-hidden="true">{String(index + 1).padStart(2, "0")}</b>
<span><strong>{dungeon.title}</strong><small>{dungeon.entranceName}</small></span>
<span><strong>{dungeon.title}</strong><small>{DUNGEON_RELEASE_STAGE_LABELS[dungeon.releaseStage]} · {dungeon.entranceName}</small></span>
{(reports.snapshot?.openCounts[dungeon.id] ?? 0) > 0 && (
<sup className="dungeon-open-report-count" aria-label={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}
title={`${reports.snapshot!.openCounts[dungeon.id]} open reports`}>{reports.snapshot!.openCounts[dungeon.id]}</sup>
)}
<em>{dungeon.available
? dungeonContentIsUsable(dungeon.id) ? "Ready" : "Download required"
: "Coming soon"}</em>
</ControllerButton>
))}
</div>
{reports.error && <small role="status" className="dungeon-report-load-error">Report counts unavailable. {reports.snapshot ? "Showing the last loaded counts." : ""}</small>}
<ControllerButton
controlId="dungeon-back"
selectedId={controller.selectedId}
@@ -295,6 +303,7 @@ export function DungeonSelectScreen() {
<section className="dungeon-select-copy">
<p className="front-kicker">{canStart ? "Playable expedition" : selectedDungeon.available ? "Content required" : "In development"}</p>
<h2>{selectedDungeon.title}</h2>
<span className="dungeon-release-stage">{DUNGEON_RELEASE_STAGE_LABELS[selectedDungeon.releaseStage]}</span>
<p>{selectedDungeon.summary}</p>
{!canStart && selectedDungeon.availabilityReason && (
<p className="front-notice" role="status">{selectedDungeon.availabilityReason}</p>
+8 -2
View File
@@ -13,7 +13,7 @@ import { useShellStore } from "../app/shellStore";
import { abilityAtLevel, resourceProfileForClass } from "../game/abilityCatalog";
import { ACTION_CONTROLS, actionBindingId } from "../game/actionBindings";
import { resolvedAbilityCosts, useCombatStore } from "../game/combatStore";
import { respawnDefeatedPlayer } from "../game/deathRespawn";
import { partyFightStillActive, respawnDefeatedPlayer } from "../game/deathRespawn";
import { requireDungeonDefinition } from "../game/dungeonRegistry";
import { resurrectManastormSession } from "../game/manastormSession";
import { resolveStagePresentation } from "../game/manastormStagePresentation";
@@ -86,12 +86,14 @@ export function DefeatedPrompt({
onReturn,
sharedOnline = false,
checkpoints = [],
partyStillFighting = false,
}: {
gameMode: "dungeon" | "manastorm";
resurrectionCharges: number;
onReturn: (checkpointId?: string) => void;
sharedOnline?: boolean;
checkpoints?: readonly ResurrectionCheckpoint[];
partyStillFighting?: boolean;
}) {
const [requestedCheckpoint, setRequestedCheckpoint] = useState("entrance");
const checkpointId = checkpoints.some((checkpoint) => checkpoint.id === requestedCheckpoint) ? requestedCheckpoint : "entrance";
@@ -126,7 +128,9 @@ export function DefeatedPrompt({
<small>No shared resurrection charges remain. A living ally must resurrect you.</small>
)}
{gameMode === "dungeon" && !sharedOnline ? (
<small>Respawning resets the current encounter and restores the party.</small>
<small>{partyStillFighting
? "Your party will keep fighting where they are. Use Recall if you want to regroup."
: "Respawning resets the current encounter and restores the party."}</small>
) : null}
</div>
</section>
@@ -204,6 +208,7 @@ export function Hud() {
: null);
const selfSelected = usePartyStore((state) => state.selfSelected);
const partyMembers = usePartyStore((state) => state.members);
const partyStillFighting = useCombatStore((state) => partyFightStillActive(partyMembers, state.mobs, state.actors));
const playerRole = usePartyStore((state) => state.playerRole);
const settings = useCombatStore((state) => state.settings);
const onlineSession = useOnlineSessionStore((state) => state.snapshot);
@@ -516,6 +521,7 @@ export function Hud() {
resurrectionCharges={resurrectionCharges}
onReturn={returnFromDefeat}
checkpoints={resurrectionCheckpoints}
partyStillFighting={partyStillFighting}
sharedOnline={onlineSession !== null}
/>
) : null}
+21 -5
View File
@@ -10,6 +10,7 @@ import { ContentManagerScreen } from "./ContentManagerScreen";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { OnlineGroupPanel } from "./OnlineGroupPanel";
import { sessionCanUseShelvedGmTools, SHELVED_MODES_ENABLED } from "../app/releaseFeatures";
import { DungeonReportsScreen } from "./DungeonReportsScreen";
const GAME_MODES = [
{
@@ -64,6 +65,16 @@ const GAME_MODES = [
export function MainMenuScreen() {
const [contentManagerOpen, setContentManagerOpen] = useState(false);
const [reportsOpen, setReportsOpen] = useState(false);
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
if (reportsOpen) return <DungeonReportsScreen onClose={() => setReportsOpen(false)} />;
return <MainMenuDestinations onOpenDownloads={() => setContentManagerOpen(true)} onOpenReports={() => setReportsOpen(true)} />;
}
function MainMenuDestinations({ onOpenDownloads, onOpenReports }: {
readonly onOpenDownloads: () => void;
readonly onOpenReports: () => void;
}) {
const activeCharacter = useShellStore((state) => state.activeCharacter);
const rosterCharacter = useShellStore(selectedCharacter);
const openDungeons = useShellStore((state) => state.openDungeons);
@@ -95,7 +106,7 @@ export function MainMenuScreen() {
}] : []),
{
id: "mode-downloads",
run: () => setContentManagerOpen(true),
run: onOpenDownloads,
neighbors: { left: SHELVED_MODES_ENABLED ? "mode-manastorms" : "mode-dungeons", down: "back-to-roster" },
},
{
@@ -105,7 +116,8 @@ export function MainMenuScreen() {
},
...(character ? [{ id: "mode-class-hall", run: openClassHall }] : []),
...(isGm ? [{ id: "mode-gm-tools", run: openGmAdmin }] : []),
], [character, isGm, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
{ id: "mode-reports", run: onOpenReports },
], [character, isGm, onOpenDownloads, onOpenReports, openArenas, openClassHall, openDungeons, openGmAdmin, openManastorms, returnToCharacters]);
const controller = useMenuController(actions, {
initialId: "mode-dungeons",
onBack: returnToCharacters,
@@ -114,8 +126,6 @@ export function MainMenuScreen() {
const race = character ? raceById(character.raceId) : null;
const characterClass = character ? classById(character.classId) : null;
if (contentManagerOpen) return <ContentManagerScreen onClose={() => setContentManagerOpen(false)} />;
return (
<DualDisplayFrame
contextLabel="Main menu details"
@@ -163,6 +173,12 @@ export function MainMenuScreen() {
</header>
<OnlineGroupPanel />
<nav className="main-menu-mode-grid" aria-label="Game modes">
<ControllerButton controlId="mode-reports" selectedId={controller.selectedId} select={controller.select}
className="front-button main-menu-mode main-menu-mode--available" type="button" onClick={onOpenReports}>
<b aria-hidden="true">!</b>
<span><strong>View Reports</strong><small>Browse dungeon issues and follow their status.</small></span>
<em>Open</em>
</ControllerButton>
{isGm && (
<ControllerButton
controlId="mode-gm-tools"
@@ -204,7 +220,7 @@ export function MainMenuScreen() {
: mode.id === "arenas"
? openArenas
: mode.id === "downloads"
? () => setContentManagerOpen(true)
? onOpenDownloads
: openDungeons}
>
<b aria-hidden="true">{mode.number}</b>
+6 -2
View File
@@ -6,6 +6,7 @@ import { ControllerButton } from "./ControllerButton";
import { sessionCanUseShelvedGmTools } from "../app/releaseFeatures";
import { useInputBindingLabels } from "../input/useInputBindingLabels";
import { INPUT_ACTION_BY_ID } from "../input/keybinds";
import { DungeonIssueReportPanel } from "./DungeonIssueReportPanel";
function PauseDialog() {
const bindingLabel = useInputBindingLabels();
@@ -47,6 +48,7 @@ function PauseDialog() {
{ id: "pause-keybinds", run: () => openOverlay("keybinds") },
{ id: "pause-options", run: () => openOverlay("options") },
{ id: "pause-ui-layout", run: () => openOverlay("ui-layout") },
...(gameMode === "dungeon" ? [{ id: "pause-report-issue", run: () => openOverlay("report-issue") }] : []),
...(isGm ? [{ id: "pause-gm", run: () => openOverlay("gm") }] : []),
...(gameMode === "manastorm"
? [{ id: "pause-abandon-manastorm", run: abandonManastorm }]
@@ -84,6 +86,7 @@ function PauseDialog() {
{menuButton("pause-keybinds", "Keybinds", () => openOverlay("keybinds"))}
{menuButton("pause-options", "Options", () => openOverlay("options"))}
{menuButton("pause-ui-layout", "Edit UI & templates", () => openOverlay("ui-layout"))}
{gameMode === "dungeon" ? menuButton("pause-report-issue", "Report an issue", () => openOverlay("report-issue")) : null}
{isGm ? menuButton("pause-gm", "GM placement editor", () => openOverlay("gm")) : null}
{gameMode === "manastorm"
? menuButton("pause-abandon-manastorm", "Abandon Manastorm run", abandonManastorm)
@@ -103,6 +106,7 @@ function PauseDialog() {
}
export function PauseMenu() {
const open = useGameStore((state) => state.overlay === "pause");
return open ? <PauseDialog /> : null;
const overlay = useGameStore((state) => state.overlay);
if (overlay === "report-issue") return <DungeonIssueReportPanel />;
return overlay === "pause" ? <PauseDialog /> : null;
}
+11
View File
@@ -14,6 +14,17 @@ describe("defeated prompt", () => {
expect(markup).toContain("wait for a party member to resurrect you");
expect(markup).toContain("Respawn at entrance");
expect(markup).toContain("Respawning resets the current encounter and restores the party.");
});
it("explains player-only resurrection and optional Recall during an ongoing party fight", () => {
const markup = renderToStaticMarkup(createElement(DefeatedPrompt, {
gameMode: "dungeon", resurrectionCharges: 0, partyStillFighting: true, onReturn: () => undefined,
}));
expect(markup).toContain("Your party will keep fighting where they are.");
expect(markup).toContain("Use Recall if you want to regroup.");
expect(markup).toContain("Respawn at entrance");
expect(markup).not.toContain("resets the current encounter");
});
it("does not offer a Manastorm resurrection when no shared charges remain", () => {
+89
View File
@@ -0,0 +1,89 @@
:root { --report-orange: #f1a044; }
.dungeon-select-row { position: relative; }
.dungeon-select-row.has-open-reports { border-color: var(--report-orange); }
.dungeon-select-row.has-open-reports.is-controller-selected { box-shadow: 0 0 0 2px rgba(241,160,68,.5) !important; }
.dungeon-select-row.has-open-reports.is-controller-selected, .dungeon-select-row.has-open-reports:focus-visible { outline-color: var(--report-orange) !important; }
.dungeon-open-report-count {
position: absolute;
top: 3px;
right: 4px;
display: grid;
min-width: 19px;
height: 19px;
padding: 0 4px;
place-items: center;
border-radius: 10px;
background: var(--report-orange);
color: #211608;
font: 700 11px/1 sans-serif;
}
.dungeon-release-stage {
display: inline-block;
margin-bottom: 9px;
padding: 3px 7px;
border: 1px solid var(--front-line);
border-radius: 5px;
color: var(--front-gold);
font-size: 10px;
}
.dungeon-report-load-error { margin: 0 0 5px; color: var(--report-orange); font-size: 10px; }
.dungeon-issue-report form { display: grid; gap: 10px; margin-bottom: 12px; }
.dungeon-issue-report textarea {
width: 100%;
min-height: 140px;
max-height: 30vh;
padding: 12px;
resize: vertical;
border: 1px solid var(--edge);
border-radius: 8px;
background: rgba(0,0,0,.25);
color: var(--ink);
font: inherit;
line-height: 1.5;
}
.dungeon-issue-report form > small { color: var(--muted); text-align: right; }
.dungeon-issue-report > .button { width: 100%; }
.dungeon-report-error { margin: 0; color: var(--report-orange); }
.dungeon-reports-screen { display: grid; grid-template-rows: auto minmax(0,1fr) auto; gap: 14px; }
.dungeon-reports-header { display: flex; align-items: center; gap: 20px; padding-bottom: 12px; border-bottom: 1px solid var(--front-line); }
.dungeon-reports-header > div:nth-child(2) { flex: 1; }
.dungeon-reports-header > div > span { color: var(--front-dim); font-size: 10px; }
.dungeon-reports-header h1 { margin: 3px 0 0; font: 26px Georgia, serif; }
.dungeon-reports-main { display: flex; min-height: 0; flex-direction: column; }
.dungeon-report-filters { display: flex; flex-wrap: wrap; align-items: end; gap: 10px; }
.dungeon-report-filters label, .dungeon-report-management label { display: grid; min-width: 0; gap: 5px; color: var(--front-dim); font-size: 12px; }
.dungeon-report-filters select, .dungeon-report-management select {
width: 100%;
max-width: 360px;
min-height: 40px;
padding: 7px 10px;
border: 1px solid var(--front-line);
border-radius: 7px;
background: #182015;
color: var(--front-cream);
font: inherit;
}
.dungeon-report-filters > label:nth-child(2) { flex: 1; }
.dungeon-reports-summary { color: var(--front-dim); font-size: 12px; }
.dungeon-report-list { display: grid; min-height: 0; align-content: start; gap: 12px; overflow: auto; padding: 2px 4px 10px 0; }
.dungeon-report-card { padding: 16px; border: 1px solid var(--report-orange); border-radius: 9px; background: rgba(255,255,255,.025); }
.dungeon-report-card.is-closed { border-color: var(--front-line); }
.dungeon-report-card header { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
.dungeon-report-card h2 { flex: 1; margin: 0; font: 19px Georgia, serif; }
.dungeon-report-status { padding: 4px 8px; border-radius: 5px; background: rgba(241,160,68,.12); color: var(--report-orange); font-size: 12px; }
.is-closed .dungeon-report-status { color: var(--front-dim); background: rgba(255,255,255,.05); }
.dungeon-report-comment { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; line-height: 1.5; }
.dungeon-report-card > small { display: block; margin-top: 5px; color: var(--front-dim); font-size: 11px; }
.dungeon-report-management { display: flex; flex-wrap: wrap; align-items: end; gap: 12px; margin-top: 14px; }
.dungeon-reports-screen > .controller-legend { position: static; justify-self: end; }
.dungeon-issue-report textarea:focus-visible, .dungeon-reports-screen select:focus-visible { outline: 2px solid var(--report-orange); outline-offset: 2px; }
.dungeon-reports-screen select.is-controller-selected { outline: 2px solid var(--front-gold); outline-offset: 2px; }
@media (max-width: 600px) {
.dungeon-reports-header { gap: 10px; }
.dungeon-reports-header > .front-brand { display: none; }
.dungeon-reports-header h1 { font-size: 21px; }
.dungeon-report-filters { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr); }
.dungeon-report-card { padding: 12px; }
.dungeon-report-card h2 { font-size: 17px; }
.dungeon-reports-screen > .controller-legend { display: none; }
}
+40
View File
@@ -0,0 +1,40 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { fetchDungeonReports, type DungeonReportSnapshot } from "../app/dungeonReports";
export function useDungeonReports() {
const [snapshot, setSnapshot] = useState<DungeonReportSnapshot | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const sequence = useRef(0);
const refresh = useCallback(async () => {
const request = ++sequence.current;
setLoading(true);
try {
const next = await fetchDungeonReports();
if (request !== sequence.current) return;
setSnapshot(next);
setError("");
} catch (failure) {
if (request === sequence.current) setError(failure instanceof Error ? failure.message : "Reports could not be loaded.");
} finally {
if (request === sequence.current) setLoading(false);
}
}, []);
useEffect(() => {
const refreshVisible = () => { if (document.visibilityState === "visible") void refresh(); };
void refresh();
const timer = window.setInterval(refreshVisible, 30_000);
window.addEventListener("focus", refreshVisible);
document.addEventListener("visibilitychange", refreshVisible);
return () => {
sequence.current += 1;
window.clearInterval(timer);
window.removeEventListener("focus", refreshVisible);
document.removeEventListener("visibilitychange", refreshVisible);
};
}, [refresh]);
return { snapshot, loading, error, refresh };
}