forked from phenom/healer-man
150 lines
9.0 KiB
TypeScript
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 />}
|
|
/>
|
|
);
|
|
}
|