Wire full OpenAPI client and TypeScript app workflow

This commit is contained in:
2026-02-24 22:19:40 +01:00
parent e54b9d2ce8
commit d73ae16e76
10 changed files with 2692 additions and 223 deletions

View File

@@ -1,25 +1,509 @@
import { getHealth, rollDice } from "./generated/api-client.js";
import {
activateCharacter,
type CampaignDetails,
type CampaignLogEntry,
type CampaignSummary,
createCampaign,
createCharacter,
createSkill,
getCampaign,
getCampaignLog,
getCampaigns,
getHealth,
getMe,
getRulesets,
loginUser,
logoutUser,
registerUser,
rollSkill,
type RulesetDefinition,
type SkillSummary,
type UserSummary,
updateSkill
} from "./generated/api-client.js";
const healthElementRaw = document.getElementById("health");
const resultElementRaw = document.getElementById("result");
const formElementRaw = document.getElementById("roll-form");
const sidesInputRaw = document.getElementById("sides");
const healthElement = mustElement("health");
const messageElement = mustElement("message");
const campaignMetaElement = mustElement("campaign-meta");
const campaignDetailsElement = mustElement("campaign-details");
const rollResultElement = mustElement("roll-result");
const campaignLogElement = mustElement("campaign-log");
if (
!(healthElementRaw instanceof HTMLElement) ||
!(resultElementRaw instanceof HTMLElement) ||
!(formElementRaw instanceof HTMLFormElement) ||
!(sidesInputRaw instanceof HTMLInputElement)
) {
throw new Error("Required UI elements are missing from index.html.");
const registerForm = mustForm("register-form");
const loginForm = mustForm("login-form");
const logoutButton = mustButton("logout-button");
const registerUsername = mustInput("register-username");
const registerDisplayName = mustInput("register-display-name");
const registerPassword = mustInput("register-password");
const loginUsername = mustInput("login-username");
const loginPassword = mustInput("login-password");
const campaignForm = mustForm("campaign-form");
const campaignNameInput = mustInput("campaign-name");
const campaignRulesetSelect = mustSelect("campaign-ruleset");
const campaignSelect = mustSelect("campaign-select");
const refreshCampaignButton = mustButton("refresh-campaign-button");
const characterForm = mustForm("character-form");
const characterNameInput = mustInput("character-name");
const characterSelect = mustSelect("character-select");
const activateCharacterButton = mustButton("activate-character-button");
const skillForm = mustForm("skill-form");
const skillNameInput = mustInput("skill-name");
const skillExpressionInput = mustInput("skill-expression");
const createSkillButton = mustButton("create-skill-button");
const updateSkillButton = mustButton("update-skill-button");
const skillSelect = mustSelect("skill-select");
const rollForm = mustForm("roll-form");
const rollVisibilitySelect = mustSelect("roll-visibility");
type AppState = {
user: UserSummary | null;
campaigns: CampaignSummary[];
selectedCampaignId: string | null;
selectedCampaign: CampaignDetails | null;
campaignLog: CampaignLogEntry[];
rulesets: RulesetDefinition[];
eventSource: EventSource | null;
};
const state: AppState = {
user: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await registerUser({
username: registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(),
password: registerPassword.value
});
registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false);
});
});
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await loginUser({
username: loginUsername.value.trim(),
password: loginPassword.value
});
loginPassword.value = "";
await reloadAll();
setMessage("Logged in.", false);
});
});
logoutButton.addEventListener("click", async () => {
await runAction(async () => {
await logoutUser();
resetStateAfterLogout();
renderAll();
setMessage("Logged out.", false);
});
});
campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value
});
campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id);
setMessage("Campaign created.", false);
});
});
campaignSelect.addEventListener("change", async () => {
await runAction(async () => {
const selected = campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign();
connectStateEvents();
renderAll();
});
});
refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => {
await reloadSelectedCampaign();
renderAll();
setMessage("Campaign refreshed.", false);
});
});
characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
characterNameInput.value = "";
await reloadSelectedCampaign();
await reloadCampaignLog();
setMessage("Character created.", false);
});
});
activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => {
const characterId = characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
await reloadAll();
setMessage("Active character updated.", false);
});
});
skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const characterId = characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
});
skillNameInput.value = "";
skillExpressionInput.value = "";
await reloadSelectedCampaign();
setMessage("Skill created.", false);
});
});
updateSkillButton.addEventListener("click", async () => {
await runAction(async () => {
const selectedSkillId = skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
});
await reloadSelectedCampaign();
setMessage("Skill updated.", false);
});
});
rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const selectedSkillId = skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog();
setMessage("Roll recorded.", false);
});
});
await runAction(async () => {
await refreshHealth();
await reloadAll();
setMessage("Ready.", false);
});
async function refreshHealth(): Promise<void> {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
const healthElement = healthElementRaw;
const resultElement = resultElementRaw;
const formElement = formElementRaw;
const sidesInput = sidesInputRaw;
async function reloadAll(): Promise<void> {
await ensureRulesets();
await reloadSession();
if (state.user) {
await reloadCampaigns(state.selectedCampaignId);
await reloadSelectedCampaign();
await reloadCampaignLog();
connectStateEvents();
}
else {
resetAuthenticatedState();
}
function errorMessage(error: unknown): string {
renderAll();
}
async function reloadSession(): Promise<void> {
try {
const me = await getMe();
state.user = me.user;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
}
}
async function ensureRulesets(): Promise<void> {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
async function reloadCampaigns(preferredCampaignId: string | null): Promise<void> {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
async function reloadSelectedCampaign(): Promise<void> {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
}
async function reloadCampaignLog(): Promise<void> {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}
function connectStateEvents(): void {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents();
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents();
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
void runAction(async () => {
await reloadSelectedCampaign();
await reloadCampaignLog();
renderAll();
});
});
state.eventSource.onerror = () => {
closeStateEvents();
};
}
function closeStateEvents(): void {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}
function resetStateAfterLogout(): void {
state.user = null;
resetAuthenticatedState();
closeStateEvents();
}
function resetAuthenticatedState(): void {
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
function renderAll(): void {
renderCampaignSelect();
renderCampaignMeta();
renderCampaignDetails();
renderCharacterSelect();
renderSkillSelect();
renderCampaignLog();
}
function renderCampaignSelect(): void {
campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}
function renderCampaignMeta(): void {
if (!state.user) {
campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}`;
}
function renderCampaignDetails(): void {
if (!state.selectedCampaign) {
campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
function renderCharacterSelect(): void {
if (!state.selectedCampaign) {
characterSelect.innerHTML = "";
return;
}
const options = state.selectedCampaign.characters
.map((character) => `<option value="${character.id}">${character.name}</option>`)
.join("");
characterSelect.innerHTML = options;
}
function renderSkillSelect(): void {
if (!state.selectedCampaign) {
skillSelect.innerHTML = "";
return;
}
const options = state.selectedCampaign.skills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign();
if (selectedSkill) {
skillNameInput.value = selectedSkill.name;
skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
function selectedSkillFromCampaign(): SkillSummary | null {
if (!state.selectedCampaign) {
return null;
}
const selectedSkillId = skillSelect.value;
return state.selectedCampaign.skills.find((skill) => skill.id === selectedSkillId) ?? null;
}
function renderCampaignLog(): void {
if (state.campaignLog.length === 0) {
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
async function runAction(action: () => Promise<void>): Promise<void> {
try {
await action();
}
catch (error: unknown) {
setMessage(formatError(error), true);
}
}
function setMessage(message: string, isError: boolean): void {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
@@ -27,27 +511,47 @@ function errorMessage(error: unknown): string {
return String(error);
}
async function refreshHealth(): Promise<void> {
try {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
catch (error: unknown) {
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
function mustElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
formElement.addEventListener("submit", async (event: SubmitEvent) => {
event.preventDefault();
const sides = Number.parseInt(sidesInput.value, 10);
try {
const roll = await rollDice(sides);
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
function mustInput(id: string): HTMLInputElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
catch (error: unknown) {
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
}
});
await refreshHealth();
return element;
}
function mustSelect(id: string): HTMLSelectElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
return element;
}
function mustForm(id: string): HTMLFormElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id: string): HTMLButtonElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -4,36 +4,190 @@ export interface ApiError {
error: string;
}
export interface CampaignDetails {
id: string;
name: string;
rulesetId: string;
gm: UserSummary;
characters: Array<CharacterSummary>;
skills: Array<SkillSummary>;
}
export interface CampaignLogEntry {
rollId: string;
campaignId: string;
characterId: string;
skillId: string;
rollerUserId: string;
visibility: string;
result: number;
breakdown: string;
timestampUtc: string;
}
export interface CampaignSummary {
id: string;
name: string;
rulesetId: string;
gmUserId: string;
}
export interface CharacterSummary {
id: string;
name: string;
ownerUserId: string;
campaignId: string;
}
export interface CreateCampaignRequest {
name: string;
rulesetId: string;
}
export interface CreateCharacterRequest {
name: string;
campaignId: string;
}
export interface CreateSkillRequest {
name: string;
diceRollDefinition: string;
}
export interface HealthResponse {
status: string;
}
export interface RollResponse {
sides: number;
value: number;
export interface LoginRequest {
username: string;
password: string;
}
export interface MeResponse {
user: UserSummary;
activeCharacterId?: string;
currentCampaignId?: string;
}
export interface RegisterRequest {
username: string;
password: string;
displayName: string;
}
export interface RollResult {
rollId: string;
campaignId: string;
characterId: string;
skillId: string;
rollerUserId: string;
visibility: string;
result: number;
breakdown: string;
timestampUtc: string;
}
export interface RollSkillRequest {
visibility: string;
}
export interface RulesetDefinition {
id: string;
name: string;
diceSyntax: string;
}
export interface SkillSummary {
id: string;
characterId: string;
name: string;
diceRollDefinition: string;
}
export interface UpdateCharacterRequest {
name: string;
campaignId: string;
}
export interface UpdateSkillRequest {
name: string;
diceRollDefinition: string;
}
export interface UserSummary {
id: string;
username: string;
displayName: string;
}
type ApiOperation = {
method: string;
path: string;
expectsJson: boolean;
};
type RequestOptions = {
pathParams?: Record<string, string | number | boolean>;
query?: Record<string, string | number | boolean | undefined>;
body?: unknown;
};
export const apiOperations = {
getHealth: { method: "GET", path: "/api/health" },
rollDice: { method: "GET", path: "/api/roll/{sides}" }
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
getMe: { method: "GET", path: "/api/me", expectsJson: true },
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
} as const satisfies Record<string, ApiOperation>;
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
let resolvedPath = operation.path;
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue: string, query: Record<string, string | number | boolean | undefined>): string {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
}
async function send<TResult>(operation: ApiOperation, options: RequestOptions): Promise<TResult> {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers: Record<string, string> = {
"Accept": "application/json"
};
let body: string | undefined;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers: {
"Accept": "application/json"
}
headers,
body
});
if (!response.ok) {
@@ -45,13 +199,145 @@ async function send<TResult>(operation: ApiOperation, pathParams: Record<string,
throw new Error(`Request failed with status ${response.status}`);
}
if (!operation.expectsJson) {
return undefined as TResult;
}
return response.json() as Promise<TResult>;
}
export async function getHealth(): Promise<HealthResponse> {
return send<HealthResponse>(apiOperations.getHealth, {});
export async function activateCharacter(characterId: string): Promise<boolean> {
return send<boolean>(apiOperations.activateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: undefined
});
}
export async function rollDice(sides: number): Promise<RollResponse> {
return send<RollResponse>(apiOperations.rollDice, { sides: sides });
export async function createCampaign(body: CreateCampaignRequest): Promise<CampaignSummary> {
return send<CampaignSummary>(apiOperations.createCampaign, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createCharacter(body: CreateCharacterRequest): Promise<CharacterSummary> {
return send<CharacterSummary>(apiOperations.createCharacter, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createSkill(characterId: string, body: CreateSkillRequest): Promise<SkillSummary> {
return send<SkillSummary>(apiOperations.createSkill, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function getCampaign(campaignId: string): Promise<CampaignDetails> {
return send<CampaignDetails>(apiOperations.getCampaign, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaignLog(campaignId: string): Promise<Array<CampaignLogEntry>> {
return send<Array<CampaignLogEntry>>(apiOperations.getCampaignLog, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaigns(): Promise<Array<CampaignSummary>> {
return send<Array<CampaignSummary>>(apiOperations.getCampaigns, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getCurrentCampaignCharacters(): Promise<Array<CharacterSummary>> {
return send<Array<CharacterSummary>>(apiOperations.getCurrentCampaignCharacters, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getHealth(): Promise<HealthResponse> {
return send<HealthResponse>(apiOperations.getHealth, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getMe(): Promise<MeResponse> {
return send<MeResponse>(apiOperations.getMe, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getRulesets(): Promise<Array<RulesetDefinition>> {
return send<Array<RulesetDefinition>>(apiOperations.getRulesets, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function loginUser(body: LoginRequest): Promise<UserSummary> {
return send<UserSummary>(apiOperations.loginUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function logoutUser(): Promise<void> {
return send<void>(apiOperations.logoutUser, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function registerUser(body: RegisterRequest): Promise<UserSummary> {
return send<UserSummary>(apiOperations.registerUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function rollSkill(skillId: string, body: RollSkillRequest): Promise<RollResult> {
return send<RollResult>(apiOperations.rollSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}
export async function updateCharacter(characterId: string, body: UpdateCharacterRequest): Promise<CharacterSummary> {
return send<CharacterSummary>(apiOperations.updateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function updateSkill(skillId: string, body: UpdateSkillRequest): Promise<SkillSummary> {
return send<SkillSummary>(apiOperations.updateSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}

View File

@@ -1,42 +1,429 @@
import { getHealth, rollDice } from "./generated/api-client.js";
const healthElementRaw = document.getElementById("health");
const resultElementRaw = document.getElementById("result");
const formElementRaw = document.getElementById("roll-form");
const sidesInputRaw = document.getElementById("sides");
if (!(healthElementRaw instanceof HTMLElement) ||
!(resultElementRaw instanceof HTMLElement) ||
!(formElementRaw instanceof HTMLFormElement) ||
!(sidesInputRaw instanceof HTMLInputElement)) {
throw new Error("Required UI elements are missing from index.html.");
import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
const healthElement = mustElement("health");
const messageElement = mustElement("message");
const campaignMetaElement = mustElement("campaign-meta");
const campaignDetailsElement = mustElement("campaign-details");
const rollResultElement = mustElement("roll-result");
const campaignLogElement = mustElement("campaign-log");
const registerForm = mustForm("register-form");
const loginForm = mustForm("login-form");
const logoutButton = mustButton("logout-button");
const registerUsername = mustInput("register-username");
const registerDisplayName = mustInput("register-display-name");
const registerPassword = mustInput("register-password");
const loginUsername = mustInput("login-username");
const loginPassword = mustInput("login-password");
const campaignForm = mustForm("campaign-form");
const campaignNameInput = mustInput("campaign-name");
const campaignRulesetSelect = mustSelect("campaign-ruleset");
const campaignSelect = mustSelect("campaign-select");
const refreshCampaignButton = mustButton("refresh-campaign-button");
const characterForm = mustForm("character-form");
const characterNameInput = mustInput("character-name");
const characterSelect = mustSelect("character-select");
const activateCharacterButton = mustButton("activate-character-button");
const skillForm = mustForm("skill-form");
const skillNameInput = mustInput("skill-name");
const skillExpressionInput = mustInput("skill-expression");
const createSkillButton = mustButton("create-skill-button");
const updateSkillButton = mustButton("update-skill-button");
const skillSelect = mustSelect("skill-select");
const rollForm = mustForm("roll-form");
const rollVisibilitySelect = mustSelect("roll-visibility");
const state = {
user: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await registerUser({
username: registerUsername.value.trim(),
displayName: registerDisplayName.value.trim(),
password: registerPassword.value
});
registerPassword.value = "";
setMessage("Registration successful. You can log in now.", false);
});
});
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
await loginUser({
username: loginUsername.value.trim(),
password: loginPassword.value
});
loginPassword.value = "";
await reloadAll();
setMessage("Logged in.", false);
});
});
logoutButton.addEventListener("click", async () => {
await runAction(async () => {
await logoutUser();
resetStateAfterLogout();
renderAll();
setMessage("Logged out.", false);
});
});
campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const createdCampaign = await createCampaign({
name: campaignNameInput.value.trim(),
rulesetId: campaignRulesetSelect.value
});
campaignNameInput.value = "";
await reloadCampaigns(createdCampaign.id);
setMessage("Campaign created.", false);
});
});
campaignSelect.addEventListener("change", async () => {
await runAction(async () => {
const selected = campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign();
connectStateEvents();
renderAll();
});
});
refreshCampaignButton.addEventListener("click", async () => {
await runAction(async () => {
await reloadSelectedCampaign();
renderAll();
setMessage("Campaign refreshed.", false);
});
});
characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
characterNameInput.value = "";
await reloadSelectedCampaign();
await reloadCampaignLog();
setMessage("Character created.", false);
});
});
activateCharacterButton.addEventListener("click", async () => {
await runAction(async () => {
const characterId = characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
await reloadAll();
setMessage("Active character updated.", false);
});
});
skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const characterId = characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
});
skillNameInput.value = "";
skillExpressionInput.value = "";
await reloadSelectedCampaign();
setMessage("Skill created.", false);
});
});
updateSkillButton.addEventListener("click", async () => {
await runAction(async () => {
const selectedSkillId = skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: skillNameInput.value.trim(),
diceRollDefinition: skillExpressionInput.value.trim()
});
await reloadSelectedCampaign();
setMessage("Skill updated.", false);
});
});
rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAction(async () => {
const selectedSkillId = skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: rollVisibilitySelect.value });
rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog();
setMessage("Roll recorded.", false);
});
});
await runAction(async () => {
await refreshHealth();
await reloadAll();
setMessage("Ready.", false);
});
async function refreshHealth() {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
const healthElement = healthElementRaw;
const resultElement = resultElementRaw;
const formElement = formElementRaw;
const sidesInput = sidesInputRaw;
function errorMessage(error) {
async function reloadAll() {
await ensureRulesets();
await reloadSession();
if (state.user) {
await reloadCampaigns(state.selectedCampaignId);
await reloadSelectedCampaign();
await reloadCampaignLog();
connectStateEvents();
}
else {
resetAuthenticatedState();
}
renderAll();
}
async function reloadSession() {
try {
const me = await getMe();
state.user = me.user;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
}
}
async function ensureRulesets() {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
async function reloadCampaigns(preferredCampaignId) {
state.campaigns = await getCampaigns();
if (state.campaigns.length === 0) {
state.selectedCampaignId = null;
return;
}
const availableIds = new Set(state.campaigns.map((campaign) => campaign.id));
if (preferredCampaignId && availableIds.has(preferredCampaignId)) {
state.selectedCampaignId = preferredCampaignId;
return;
}
if (state.selectedCampaignId && availableIds.has(state.selectedCampaignId)) {
return;
}
state.selectedCampaignId = state.campaigns[0].id;
}
async function reloadSelectedCampaign() {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
}
async function reloadCampaignLog() {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}
function connectStateEvents() {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents();
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents();
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
void runAction(async () => {
await reloadSelectedCampaign();
await reloadCampaignLog();
renderAll();
});
});
state.eventSource.onerror = () => {
closeStateEvents();
};
}
function closeStateEvents() {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}
function resetStateAfterLogout() {
state.user = null;
resetAuthenticatedState();
closeStateEvents();
}
function resetAuthenticatedState() {
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
function renderAll() {
renderCampaignSelect();
renderCampaignMeta();
renderCampaignDetails();
renderCharacterSelect();
renderSkillSelect();
renderCampaignLog();
}
function renderCampaignSelect() {
campaignSelect.innerHTML = state.campaigns
.map((campaign) => {
const selected = campaign.id === state.selectedCampaignId ? " selected" : "";
return `<option value="${campaign.id}"${selected}>${campaign.name} (${campaign.rulesetId})</option>`;
})
.join("");
}
function renderCampaignMeta() {
if (!state.user) {
campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}`;
}
function renderCampaignDetails() {
if (!state.selectedCampaign) {
campaignDetailsElement.textContent = "No details available.";
return;
}
const characters = state.selectedCampaign.characters
.map((character) => `<li>${character.name} (${character.id})</li>`)
.join("");
const skills = state.selectedCampaign.skills
.map((skill) => `<li>${skill.name} [${skill.diceRollDefinition}]</li>`)
.join("");
campaignDetailsElement.innerHTML = `
<p>GM: ${state.selectedCampaign.gm.displayName}</p>
<p>Characters visible to you: ${state.selectedCampaign.characters.length}</p>
<ul>${characters}</ul>
<p>Skills visible to you: ${state.selectedCampaign.skills.length}</p>
<ul>${skills}</ul>
`;
}
function renderCharacterSelect() {
if (!state.selectedCampaign) {
characterSelect.innerHTML = "";
return;
}
const options = state.selectedCampaign.characters
.map((character) => `<option value="${character.id}">${character.name}</option>`)
.join("");
characterSelect.innerHTML = options;
}
function renderSkillSelect() {
if (!state.selectedCampaign) {
skillSelect.innerHTML = "";
return;
}
const options = state.selectedCampaign.skills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign();
if (selectedSkill) {
skillNameInput.value = selectedSkill.name;
skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
function selectedSkillFromCampaign() {
if (!state.selectedCampaign) {
return null;
}
const selectedSkillId = skillSelect.value;
return state.selectedCampaign.skills.find((skill) => skill.id === selectedSkillId) ?? null;
}
function renderCampaignLog() {
if (state.campaignLog.length === 0) {
campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
campaignLogElement.innerHTML = state.campaignLog
.map((entry) => `
<li class="log-item">
<strong>${entry.visibility.toUpperCase()}</strong>
<span> ${entry.breakdown}</span>
<small> ${new Date(entry.timestampUtc).toLocaleString()}</small>
</li>
`)
.join("");
}
async function runAction(action) {
try {
await action();
}
catch (error) {
setMessage(formatError(error), true);
}
}
function setMessage(message, isError) {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}
async function refreshHealth() {
try {
const health = await getHealth();
healthElement.textContent = `API status: ${health.status}`;
}
catch (error) {
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
function mustElement(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
formElement.addEventListener("submit", async (event) => {
event.preventDefault();
const sides = Number.parseInt(sidesInput.value, 10);
try {
const roll = await rollDice(sides);
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
function mustInput(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
catch (error) {
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
return element;
}
function mustSelect(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
});
await refreshHealth();
return element;
}
function mustForm(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLFormElement)) {
throw new Error(`Missing HTMLFormElement: ${id}`);
}
return element;
}
function mustButton(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLButtonElement)) {
throw new Error(`Missing HTMLButtonElement: ${id}`);
}
return element;
}

View File

@@ -1,18 +1,52 @@
/* This file is generated by scripts/generate-api-client.mjs. */
export const apiOperations = {
getHealth: { method: "GET", path: "/api/health" },
rollDice: { method: "GET", path: "/api/roll/{sides}" }
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
createCampaign: { method: "POST", path: "/api/campaigns", expectsJson: true },
createCharacter: { method: "POST", path: "/api/characters", expectsJson: true },
createSkill: { method: "POST", path: "/api/characters/{characterId}/skills", expectsJson: true },
getCampaign: { method: "GET", path: "/api/campaigns/{campaignId}", expectsJson: true },
getCampaignLog: { method: "GET", path: "/api/campaigns/{campaignId}/log", expectsJson: true },
getCampaigns: { method: "GET", path: "/api/campaigns", expectsJson: true },
getCurrentCampaignCharacters: { method: "GET", path: "/api/characters/current-campaign", expectsJson: true },
getHealth: { method: "GET", path: "/api/health", expectsJson: true },
getMe: { method: "GET", path: "/api/me", expectsJson: true },
getRulesets: { method: "GET", path: "/api/rulesets", expectsJson: true },
loginUser: { method: "POST", path: "/api/auth/login", expectsJson: true },
logoutUser: { method: "POST", path: "/api/auth/logout", expectsJson: false },
registerUser: { method: "POST", path: "/api/auth/register", expectsJson: true },
rollSkill: { method: "POST", path: "/api/skills/{skillId}/roll", expectsJson: true },
updateCharacter: { method: "PUT", path: "/api/characters/{characterId}", expectsJson: true },
updateSkill: { method: "PUT", path: "/api/skills/{skillId}", expectsJson: true }
};
async function send(operation, pathParams = {}) {
let resolvedPath = operation.path;
function withPathParams(pathTemplate, pathParams) {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
resolvedPath = resolvedPath.replace(`{${key}}`, encodeURIComponent(String(value)));
pathValue = pathValue.replace(`{${key}}`, encodeURIComponent(String(value)));
}
return pathValue;
}
function withQuery(pathValue, query) {
const entries = Object.entries(query).filter(([, value]) => value !== undefined);
if (entries.length === 0) {
return pathValue;
}
const queryString = new URLSearchParams(entries.map(([key, value]) => [key, String(value)])).toString();
return queryString.length === 0 ? pathValue : `${pathValue}?${queryString}`;
}
async function send(operation, options) {
const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {});
const headers = {
"Accept": "application/json"
};
let body;
if (options.body !== undefined) {
headers["Content-Type"] = "application/json";
body = JSON.stringify(options.body);
}
const response = await fetch(resolvedPath, {
method: operation.method,
headers: {
"Accept": "application/json"
}
headers,
body
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
@@ -21,11 +55,127 @@ async function send(operation, pathParams = {}) {
}
throw new Error(`Request failed with status ${response.status}`);
}
if (!operation.expectsJson) {
return undefined;
}
return response.json();
}
export async function activateCharacter(characterId) {
return send(apiOperations.activateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: undefined
});
}
export async function createCampaign(body) {
return send(apiOperations.createCampaign, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createCharacter(body) {
return send(apiOperations.createCharacter, {
pathParams: {},
query: undefined,
body: body
});
}
export async function createSkill(characterId, body) {
return send(apiOperations.createSkill, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function getCampaign(campaignId) {
return send(apiOperations.getCampaign, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaignLog(campaignId) {
return send(apiOperations.getCampaignLog, {
pathParams: { campaignId: campaignId },
query: undefined,
body: undefined
});
}
export async function getCampaigns() {
return send(apiOperations.getCampaigns, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getCurrentCampaignCharacters() {
return send(apiOperations.getCurrentCampaignCharacters, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getHealth() {
return send(apiOperations.getHealth, {});
return send(apiOperations.getHealth, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function rollDice(sides) {
return send(apiOperations.rollDice, { sides: sides });
export async function getMe() {
return send(apiOperations.getMe, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function getRulesets() {
return send(apiOperations.getRulesets, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function loginUser(body) {
return send(apiOperations.loginUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function logoutUser() {
return send(apiOperations.logoutUser, {
pathParams: {},
query: undefined,
body: undefined
});
}
export async function registerUser(body) {
return send(apiOperations.registerUser, {
pathParams: {},
query: undefined,
body: body
});
}
export async function rollSkill(skillId, body) {
return send(apiOperations.rollSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}
export async function updateCharacter(characterId, body) {
return send(apiOperations.updateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: body
});
}
export async function updateSkill(skillId, body) {
return send(apiOperations.updateSkill, {
pathParams: { skillId: skillId },
query: undefined,
body: body
});
}

View File

@@ -10,14 +10,80 @@
<main class="layout">
<h1>RpgRoller</h1>
<p id="health" class="status">Checking API status...</p>
<p id="message" class="message"></p>
<form id="roll-form" class="panel">
<label for="sides">Sides</label>
<input id="sides" name="sides" type="number" min="2" max="1000" value="20" required>
<button type="submit">Roll</button>
</form>
<section class="panel">
<h2>Authentication</h2>
<form id="register-form" class="grid-form">
<input id="register-username" type="text" placeholder="Username" required>
<input id="register-display-name" type="text" placeholder="Display Name" required>
<input id="register-password" type="password" placeholder="Password (min 8 chars)" required>
<button type="submit">Register</button>
</form>
<form id="login-form" class="grid-form">
<input id="login-username" type="text" placeholder="Username" required>
<input id="login-password" type="password" placeholder="Password" required>
<button type="submit">Login</button>
</form>
<button id="logout-button" type="button">Logout</button>
</section>
<p id="result" class="result">No roll yet.</p>
<section class="panel">
<h2>Campaigns</h2>
<form id="campaign-form" class="grid-form">
<input id="campaign-name" type="text" placeholder="Campaign name" required>
<select id="campaign-ruleset" required></select>
<button type="submit">Create Campaign</button>
</form>
<div class="inline-controls">
<select id="campaign-select"></select>
<button id="refresh-campaign-button" type="button">Refresh</button>
</div>
<p id="campaign-meta"></p>
</section>
<section class="panel">
<h2>Characters</h2>
<form id="character-form" class="grid-form">
<input id="character-name" type="text" placeholder="Character name" required>
<button type="submit">Create Character</button>
</form>
<div class="inline-controls">
<select id="character-select"></select>
<button id="activate-character-button" type="button">Activate Character</button>
</div>
</section>
<section class="panel">
<h2>Skills</h2>
<form id="skill-form" class="grid-form">
<input id="skill-name" type="text" placeholder="Skill name" required>
<input id="skill-expression" type="text" placeholder="Dice expression" required>
<div class="inline-controls">
<button id="create-skill-button" type="submit">Create Skill</button>
<button id="update-skill-button" type="button">Update Selected Skill</button>
</div>
</form>
<form id="roll-form" class="grid-form">
<select id="skill-select"></select>
<select id="roll-visibility">
<option value="public">Public</option>
<option value="private">Private</option>
</select>
<button type="submit">Roll Skill</button>
</form>
<p id="roll-result" class="result">No roll yet.</p>
</section>
<section class="panel">
<h2>Campaign Details</h2>
<div id="campaign-details"></div>
</section>
<section class="panel">
<h2>Campaign Log</h2>
<ul id="campaign-log" class="log-list"></ul>
</section>
</main>
<script type="module" src="/app.js"></script>

View File

@@ -11,18 +11,34 @@ body {
}
.layout {
max-width: 32rem;
max-width: 56rem;
margin: 0 auto;
padding: 2.5rem 1.25rem;
display: grid;
gap: 1rem;
}
.panel {
display: grid;
gap: 0.75rem;
margin-top: 1rem;
gap: 0.6rem;
background: rgba(255, 255, 255, 0.8);
border: 1px solid #c8d2e6;
border-radius: 0.6rem;
padding: 0.9rem;
}
.grid-form {
display: grid;
gap: 0.5rem;
}
.inline-controls {
display: flex;
gap: 0.5rem;
}
input,
select,
button {
font: inherit;
padding: 0.6rem 0.75rem;
@@ -40,3 +56,23 @@ button {
.result {
font-weight: 600;
}
.message {
min-height: 1.5rem;
color: #1d4ed8;
font-weight: 600;
}
.log-list {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 0.35rem;
}
.log-item {
padding: 0.45rem 0.55rem;
border-radius: 0.35rem;
background: #f6f8fc;
}