Files
healer-man/src/ui/CharacterSelectScreen.tsx
T

150 lines
9.0 KiB
TypeScript

import { useMemo, useState, type CSSProperties } from "react";
import { CLASSES, PLAYABLE_RACES, classById, contentCategoryForClass, FACTIONS, raceById } from "../app/characterCatalog";
import { contentCategoryById } from "../app/contentCategories";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { DualDisplayFrame } from "../components/DualDisplayFrame";
import { useMenuController, type MenuAction } from "../input/useMenuController";
import { CharacterPreview } from "./CharacterPreview";
import { ControllerButton } from "./ControllerButton";
import { BrandMark, ControllerLegend, FrontSurface } from "./FrontSurface";
import { LazyLocalCompanionPanel } from "./LazyLocalCompanionPanel";
function formatLastPlayed(timestamp: number): string {
const seconds = Math.max(0, Math.round((Date.now() - timestamp) / 1000));
if (seconds < 60) return "Just now";
const minutes = Math.round(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.round(minutes / 60);
if (hours < 48) return `${hours}h ago`;
return new Date(timestamp).toLocaleDateString();
}
export function CharacterSelectScreen() {
const characters = useShellStore((state) => state.characters);
const session = useShellStore((state) => state.session);
const selectedId = useShellStore((state) => state.selectedCharacterId);
const selected = useShellStore(selectedCharacter);
const notice = useShellStore((state) => state.notice);
const select = useShellStore((state) => state.selectCharacter);
const openCreator = useShellStore((state) => state.openCreator);
const openMainMenu = useShellStore((state) => state.openMainMenu);
const deleteSelected = useShellStore((state) => state.deleteSelectedCharacter);
const signOut = useShellStore((state) => state.signOut);
const [confirmDelete, setConfirmDelete] = useState(false);
const continueWithCharacter = () => { openMainMenu(); };
const actions = useMemo<MenuAction[]>(() => confirmDelete ? [
{ id: "cancel-delete", run: () => setConfirmDelete(false) },
{ id: "confirm-delete", run: () => { deleteSelected(); setConfirmDelete(false); } },
] : [
...characters.map((character) => ({ id: `character-${character.id}`, run: () => select(character.id) })),
{ id: "new-character", run: openCreator },
{ id: "continue-character", run: continueWithCharacter, enabled: Boolean(selected) },
{ id: "delete-character", run: () => setConfirmDelete(true), enabled: Boolean(selected) },
{ id: "sign-out", run: signOut },
], [characters, confirmDelete, selected, selectedId]);
const controller = useMenuController(actions, { initialId: selectedId ? `character-${selectedId}` : "new-character" });
const race = selected ? raceById(selected.raceId) : null;
const characterClass = selected ? classById(selected.classId) : null;
return (
<DualDisplayFrame
contextLabel="Roster details"
top={
<FrontSurface className="character-select-screen" ariaLabel="Healer Man character selection">
<header className="roster-header">
<BrandMark compact />
<div><span>{session?.kind === "offline" ? "Offline expedition" : `Local account / ${session?.displayName}`}</span><strong>Choose your adventurer</strong></div>
<button type="button" onClick={signOut}>Sign out</button>
</header>
<aside className="roster-list" aria-label="Saved characters">
<div className="roster-list__heading"><span>Characters</span><b>{characters.length} / 10</b></div>
{characters.map((character) => {
const raceDefinition = raceById(character.raceId);
const classDefinition = classById(character.classId);
const categoryDefinition = contentCategoryById(character.categoryId ?? contentCategoryForClass(character.classId));
return (
<ControllerButton
key={character.id}
controlId={`character-${character.id}`}
selectedId={controller.selectedId}
select={controller.select}
className={`roster-character ${character.id === selectedId ? "is-active" : ""}`}
type="button"
onClick={() => select(character.id)}
>
<span style={{ "--portrait-accent": raceDefinition.accent } as CSSProperties}>{raceDefinition.sigil}</span>
<i><strong>{character.name}</strong><small>{categoryDefinition.label} / Level {character.level} {classDefinition.name}{character.secondaryClassId ? ` + ${classById(character.secondaryClassId).name}` : ""}</small></i>
<em>{formatLastPlayed(character.lastPlayedAt)}</em>
</ControllerButton>
);
})}
{!characters.length && <div className="roster-empty"><span>+</span><strong>No adventurers yet</strong><small>Create one to enter a dungeon.</small></div>}
<ControllerButton controlId="new-character" selectedId={controller.selectedId} select={controller.select} className="roster-new" type="button" onClick={openCreator}>
<b>+</b><span><strong>Create character</strong><small>{PLAYABLE_RACES.length} races / {CLASSES.length} classes</small></span>
</ControllerButton>
</aside>
<main className="roster-stage">
<CharacterPreview character={selected} />
{selected && race && characterClass && (
<>
<section className="roster-character-info">
<p>{FACTIONS[race.faction].name} / {selected.gender}</p>
<h1>{selected.name}</h1>
<strong style={{ color: characterClass.color }}>{race.name} {characterClass.name}{selected.secondaryClassId ? ` / ${classById(selected.secondaryClassId).name}` : ""}</strong>
<small>{characterClass.description}</small>
</section>
<ControllerButton
controlId="continue-character"
selectedId={controller.selectedId}
select={controller.select}
className="front-button front-button--primary roster-stage-continue"
type="button"
onClick={continueWithCharacter}
>Continue as {selected.name}</ControllerButton>
</>
)}
</main>
<aside className="roster-details">
<p className="front-kicker">Expedition record</p>
{selected && race && characterClass ? (
<>
<h2>{selected.location}</h2>
<dl>
<div><dt>Level</dt><dd>{selected.level}</dd></div>
<div><dt>Content</dt><dd>{contentCategoryById(selected.categoryId ?? contentCategoryForClass(selected.classId)).name}</dd></div>
<div><dt>Faction</dt><dd>{FACTIONS[race.faction].name}</dd></div>
<div><dt>Class role</dt><dd>{characterClass.role}</dd></div>
<div><dt>Next step</dt><dd>Choose a game mode</dd></div>
</dl>
<ControllerButton controlId="continue-character" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary roster-enter" type="button" onClick={continueWithCharacter}>
<span>Continue</span><small>Open the main menu</small>
</ControllerButton>
<ControllerButton controlId="delete-character" selectedId={controller.selectedId} select={controller.select} className="text-action text-action--danger" type="button" onClick={() => setConfirmDelete(true)}>Delete character</ControllerButton>
</>
) : (
<><h2>New expedition</h2><p>Create a character using the options loaded from your wow335a client.</p></>
)}
{notice && <p className="front-notice" role="status">{notice}</p>}
</aside>
<ControllerLegend />
{confirmDelete && selected && (
<div className="front-modal-backdrop">
<section className="front-confirm" role="alertdialog" aria-modal="true" aria-labelledby="delete-title">
<p className="front-kicker">Permanent local action</p>
<h2 id="delete-title">Delete {selected.name}?</h2>
<p>This removes the character from this device. It cannot be undone.</p>
<div>
<ControllerButton controlId="cancel-delete" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--primary" type="button" onClick={() => setConfirmDelete(false)}>Keep character</ControllerButton>
<ControllerButton controlId="confirm-delete" selectedId={controller.selectedId} select={controller.select} className="front-button front-button--danger" type="button" onClick={() => { deleteSelected(); setConfirmDelete(false); }}>Delete permanently</ControllerButton>
</div>
</section>
</div>
)}
</FrontSurface>
}
bottom={<LazyLocalCompanionPanel />}
/>
);
}