Files
healer-man/src/ui/OnlineGroupPanel.tsx
T
2026-08-20 14:05:50 -04:00

158 lines
7.3 KiB
TypeScript

import { useEffect, useState, type FormEvent } from "react";
import { classById } from "../app/characterCatalog";
import { selectedCharacter, useShellStore } from "../app/shellStore";
import { useOnlineGroupStore } from "../app/onlineGroupStore";
import {
classCanFillPartyRole,
partyRolesForClass,
PARTY_ROLE_LABELS,
type PartyRole,
} from "../game/partyRoles";
export function OnlineGroupPanel() {
const [username, setUsername] = useState("");
const [panelOpen, setPanelOpen] = useState(false);
const session = useShellStore((state) => state.session);
const character = useShellStore(selectedCharacter);
const snapshot = useOnlineGroupStore((state) => state.snapshot);
const busy = useOnlineGroupStore((state) => state.busy);
const error = useOnlineGroupStore((state) => state.error);
const fillWithAi = useOnlineGroupStore((state) => state.fillWithAi);
const invite = useOnlineGroupStore((state) => state.invite);
const respond = useOnlineGroupStore((state) => state.respond);
const leave = useOnlineGroupStore((state) => state.leave);
const remove = useOnlineGroupStore((state) => state.remove);
const updateMember = useOnlineGroupStore((state) => state.updateMember);
const clearActivity = useOnlineGroupStore((state) => state.clearActivity);
const setFillWithAi = useOnlineGroupStore((state) => state.setFillWithAi);
useEffect(() => {
if (snapshot.invitations.length > 0) setPanelOpen(true);
}, [snapshot.invitations.length]);
if (!session || session.kind !== "account" || !session.accessToken) return null;
const group = snapshot.group;
const currentMember = group?.members.find((member) => member.accountId === session.ownerId) ?? null;
const leader = group?.leaderAccountId === session.ownerId;
const roles = character ? partyRolesForClass(character.classId) : [];
const readyCount = group?.members.filter((member) => member.character && member.role).length ?? 0;
const submitInvite = async (event: FormEvent) => {
event.preventDefault();
if (!username.trim()) return;
if (await invite(session, username.trim())) setUsername("");
};
const chooseRole = (role: PartyRole) => {
if (character && classCanFillPartyRole(character.classId, role)) void updateMember(session, character, role);
};
return (
<details className="online-group-panel" open={panelOpen} onToggle={(event) => setPanelOpen(event.currentTarget.open)}>
<summary>
<span aria-hidden="true"></span>
<strong>Online group</strong>
<small>{group ? `${group.members.length} / 5 players · ${readyCount} ready` : "Optional co-op"}</small>
</summary>
<div className="online-group-panel__body">
{snapshot.invitations.length > 0 && (
<section className="online-group-invites" aria-label="Group invitations">
<strong>Invitations</strong>
{snapshot.invitations.map((invitation) => (
<div key={invitation.id}>
<span><b>{invitation.inviterUsername}</b><small>wants you in their group</small></span>
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, true)}>Accept</button>
<button type="button" disabled={busy} onClick={() => void respond(session, invitation.id, false)}>Decline</button>
</div>
))}
</section>
)}
{group && (
<section className="online-group-roster" aria-label="Online group roster">
<header><strong>Party roster</strong><small>{leader ? "You are the leader" : "Waiting for the leader"}</small></header>
{group.members.map((member) => {
const memberClass = member.character ? classById(member.character.classId as Parameters<typeof classById>[0]) : null;
return (
<div className="online-group-member" key={member.accountId}>
<span className={member.character && member.role ? "is-ready" : ""} aria-hidden="true" />
<p>
<strong>{member.username}{group.leaderAccountId === member.accountId ? " ★" : ""}</strong>
<small>{member.character
? `${member.character.name} · Level ${member.character.level} ${memberClass?.name ?? member.character.classId}`
: "Choosing a character"}</small>
</p>
<b>{member.role ? PARTY_ROLE_LABELS[member.role] : "Not ready"}</b>
{leader && member.accountId !== session.ownerId && (
<button type="button" disabled={busy} onClick={() => void remove(session, member.accountId)}>Remove</button>
)}
</div>
);
})}
</section>
)}
{group && character && currentMember && (
<section className="online-group-role" aria-label="Your group role">
<strong>Your role</strong>
<div>
{roles.map((role) => (
<button
type="button"
key={role}
className={currentMember.role === role ? "is-active" : ""}
disabled={busy || Boolean(group.activity)}
aria-pressed={currentMember.role === role}
onClick={() => chooseRole(role)}
>
{PARTY_ROLE_LABELS[role]}
</button>
))}
</div>
</section>
)}
{group?.activity && (
<div className="online-group-activity" role="status">
<span>
<strong>Launching {group.activity.type === "dungeon" ? "dungeon" : "Manastorm"}</strong>
<small>{group.activity.fillWithAi ? `Party filled to ${group.activity.partySize} with AI` : `${group.activity.partySize} players only`}</small>
</span>
{leader && <button type="button" disabled={busy} onClick={() => void clearActivity(session)}>Reset</button>}
</div>
)}
{leader && group && !group.activity && (
<label className="online-group-ai-toggle">
<input type="checkbox" checked={fillWithAi} onChange={(event) => setFillWithAi(event.target.checked)} />
<span><strong>Fill open slots with AI</strong><small>Turn off to enter with only grouped players.</small></span>
</label>
)}
{(!group || leader) && (
<form className="online-group-invite-form" onSubmit={submitInvite}>
<label htmlFor="online-group-username">Invite account name</label>
<div>
<input
id="online-group-username"
value={username}
maxLength={20}
autoComplete="off"
placeholder="PlayerName"
onChange={(event) => setUsername(event.target.value)}
/>
<button type="submit" disabled={busy || !username.trim() || (group?.members.length ?? 0) >= 5}>Invite</button>
</div>
</form>
)}
{group && (
<button className="online-group-leave" type="button" disabled={busy} onClick={() => void leave(session)}>
{leader && group.members.length > 1 ? "Leave and transfer leadership" : "Leave group"}
</button>
)}
{error && <p className="online-group-error" role="alert">{error}</p>}
</div>
</details>
);
}