Remove legacy TypeScript frontend and npm pipeline

This commit is contained in:
2026-02-25 12:31:39 +01:00
parent 35c60c4ea2
commit 0f44cc466b
31 changed files with 20 additions and 2526 deletions

View File

@@ -1,252 +0,0 @@
import {
activateCharacter,
createCampaign,
createCharacter,
createSkill,
loginUser,
logoutUser,
registerUser,
rollSkill,
updateSkill
} from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import {
ensureRulesets,
refreshHealth,
reloadCampaignLog,
reloadCampaigns,
reloadSelectedCampaign,
reloadSession
} from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const elements = getAppElements();
const state = createInitialState();
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await registerUser({
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await loginUser({
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
elements.loginPassword.value = "";
await reloadAll();
setStatus("Logged in.", false);
});
});
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setStatus("Active character updated.", false);
});
});
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setStatus("Ready.", false);
});
async function reloadAll(): Promise<void> {
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll(state, elements);
}
function syncEventStream(): void {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
}
async function runAppAction(action: () => Promise<void>): Promise<void> {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function setStatus(message: string, isError: boolean): void {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -1,24 +0,0 @@
export async function runAction(
action: () => Promise<void>,
onError: (message: string) => void
): Promise<void> {
try {
await action();
}
catch (error: unknown) {
onError(formatError(error));
}
}
export function setMessage(messageElement: HTMLElement, 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;
}
return String(error);
}

View File

@@ -1,112 +0,0 @@
export type AppElements = {
healthElement: HTMLElement;
messageElement: HTMLElement;
campaignMetaElement: HTMLElement;
campaignDetailsElement: HTMLElement;
rollResultElement: HTMLElement;
campaignLogElement: HTMLElement;
registerForm: HTMLFormElement;
loginForm: HTMLFormElement;
logoutButton: HTMLButtonElement;
registerUsername: HTMLInputElement;
registerDisplayName: HTMLInputElement;
registerPassword: HTMLInputElement;
loginUsername: HTMLInputElement;
loginPassword: HTMLInputElement;
campaignForm: HTMLFormElement;
campaignNameInput: HTMLInputElement;
campaignRulesetSelect: HTMLSelectElement;
campaignSelect: HTMLSelectElement;
refreshCampaignButton: HTMLButtonElement;
characterForm: HTMLFormElement;
characterNameInput: HTMLInputElement;
characterSelect: HTMLSelectElement;
activateCharacterButton: HTMLButtonElement;
skillForm: HTMLFormElement;
skillNameInput: HTMLInputElement;
skillExpressionInput: HTMLInputElement;
updateSkillButton: HTMLButtonElement;
skillSelect: HTMLSelectElement;
rollForm: HTMLFormElement;
rollVisibilitySelect: HTMLSelectElement;
};
export function getAppElements(): AppElements {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id: string): HTMLElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id: string): HTMLInputElement {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
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

@@ -1,30 +0,0 @@
import type { AppState } from "./types.js";
export function connectStateEvents(state: AppState, onStateChanged: () => void): void {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state: AppState): void {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -1,74 +0,0 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import type { AppElements } from "./dom.js";
import { syncSelectedCharacter } from "./state.js";
import type { AppState } from "./types.js";
export async function refreshHealth(elements: AppElements): Promise<void> {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state: AppState, elements: AppElements): Promise<void> {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state: AppState): Promise<void> {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state: AppState, 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;
}
export async function reloadSelectedCampaign(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state: AppState): Promise<void> {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -1,117 +0,0 @@
import type { AppElements } from "./dom.js";
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
import type { AppState } from "./types.js";
export function renderAll(state: AppState, elements: AppElements): void {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state: AppState, elements: AppElements): void {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.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("");
elements.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>
`;
}
export function renderCharacterSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state: AppState, elements: AppElements): void {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state: AppState, elements: AppElements): void {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.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("");
}
function renderCampaignSelect(state: AppState, elements: AppElements): void {
elements.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("");
}

View File

@@ -1,71 +0,0 @@
import type { SkillSummary } from "../generated/api-client.js";
import type { AppState } from "./types.js";
export function createInitialState(): AppState {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state: AppState): void {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state: AppState): void {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state: AppState): void {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state: AppState): string | null {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state: AppState, selectedSkillId: string): SkillSummary | null {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -1,19 +0,0 @@
import type {
CampaignDetails,
CampaignLogEntry,
CampaignSummary,
RulesetDefinition,
UserSummary
} from "../generated/api-client.js";
export type AppState = {
user: UserSummary | null;
activeCharacterId: string | null;
selectedCharacterId: string | null;
campaigns: CampaignSummary[];
selectedCampaignId: string | null;
selectedCampaign: CampaignDetails | null;
campaignLog: CampaignLogEntry[];
rulesets: RulesetDefinition[];
eventSource: EventSource | null;
};

View File

@@ -1,343 +0,0 @@
/* This file is generated by scripts/generate-api-client.mjs. */
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 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 = {
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>;
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
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,
body
});
if (!response.ok) {
const errorPayload: unknown = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
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 activateCharacter(characterId: string): Promise<boolean> {
return send<boolean>(apiOperations.activateCharacter, {
pathParams: { characterId: characterId },
query: undefined,
body: undefined
});
}
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,198 +0,0 @@
import { activateCharacter, createCampaign, createCharacter, createSkill, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
import { runAction, setMessage } from "./app/actions.js";
import { getAppElements } from "./app/dom.js";
import { closeStateEvents, connectStateEvents } from "./app/events.js";
import { ensureRulesets, refreshHealth, reloadCampaignLog, reloadCampaigns, reloadSelectedCampaign, reloadSession } from "./app/loaders.js";
import { renderAll, renderCampaignDetails, renderCampaignLog, renderCampaignMeta, renderCharacterSelect, renderSkillSelect } from "./app/render.js";
import { createInitialState, resetAuthenticatedState, resetStateAfterLogout, syncSelectedCharacter } from "./app/state.js";
const elements = getAppElements();
const state = createInitialState();
elements.registerForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await registerUser({
username: elements.registerUsername.value.trim(),
displayName: elements.registerDisplayName.value.trim(),
password: elements.registerPassword.value
});
elements.registerPassword.value = "";
setStatus("Registration successful. You can log in now.", false);
});
});
elements.loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
await loginUser({
username: elements.loginUsername.value.trim(),
password: elements.loginPassword.value
});
elements.loginPassword.value = "";
await reloadAll();
setStatus("Logged in.", false);
});
});
elements.logoutButton.addEventListener("click", async () => {
await runAppAction(async () => {
await logoutUser();
resetStateAfterLogout(state);
closeStateEvents(state);
renderAll(state, elements);
setStatus("Logged out.", false);
});
});
elements.campaignForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const createdCampaign = await createCampaign({
name: elements.campaignNameInput.value.trim(),
rulesetId: elements.campaignRulesetSelect.value
});
elements.campaignNameInput.value = "";
await reloadCampaigns(state, createdCampaign.id);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
renderAll(state, elements);
setStatus("Campaign created.", false);
});
});
elements.campaignSelect.addEventListener("change", async () => {
await runAppAction(async () => {
const selected = elements.campaignSelect.value;
state.selectedCampaignId = selected.length > 0 ? selected : null;
await reloadSelectedCampaign(state);
syncSelectedCharacter(state);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
syncEventStream();
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCampaignLog(state, elements);
});
});
elements.characterSelect.addEventListener("change", () => {
state.selectedCharacterId = elements.characterSelect.value.length > 0 ? elements.characterSelect.value : null;
renderSkillSelect(state, elements);
});
elements.refreshCampaignButton.addEventListener("click", async () => {
await runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Campaign refreshed.", false);
});
});
elements.characterForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
if (!state.selectedCampaignId) {
throw new Error("Select a campaign first.");
}
await createCharacter({
name: elements.characterNameInput.value.trim(),
campaignId: state.selectedCampaignId
});
elements.characterNameInput.value = "";
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
setStatus("Character created.", false);
});
});
elements.activateCharacterButton.addEventListener("click", async () => {
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character to activate.");
}
await activateCharacter(characterId);
state.activeCharacterId = characterId;
state.selectedCharacterId = characterId;
await reloadAll();
setStatus("Active character updated.", false);
});
});
elements.skillForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const characterId = elements.characterSelect.value;
if (characterId.length === 0) {
throw new Error("Select a character first.");
}
await createSkill(characterId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
elements.skillNameInput.value = "";
elements.skillExpressionInput.value = "";
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill created.", false);
});
});
elements.updateSkillButton.addEventListener("click", async () => {
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to update.");
}
await updateSkill(selectedSkillId, {
name: elements.skillNameInput.value.trim(),
diceRollDefinition: elements.skillExpressionInput.value.trim()
});
await reloadSelectedCampaign(state);
renderAll(state, elements);
setStatus("Skill updated.", false);
});
});
elements.rollForm.addEventListener("submit", async (event) => {
event.preventDefault();
await runAppAction(async () => {
const selectedSkillId = elements.skillSelect.value;
if (selectedSkillId.length === 0) {
throw new Error("Select a skill to roll.");
}
const roll = await rollSkill(selectedSkillId, { visibility: elements.rollVisibilitySelect.value });
elements.rollResultElement.textContent = `Roll ${roll.rollId}: ${roll.breakdown} (${roll.visibility})`;
await reloadCampaignLog(state);
renderCampaignLog(state, elements);
setStatus("Roll recorded.", false);
});
});
await runAppAction(async () => {
await refreshHealth(elements);
await reloadAll();
setStatus("Ready.", false);
});
async function reloadAll() {
await ensureRulesets(state, elements);
await reloadSession(state);
if (state.user) {
await reloadCampaigns(state, state.selectedCampaignId);
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
syncEventStream();
}
else {
resetAuthenticatedState(state);
closeStateEvents(state);
}
renderAll(state, elements);
}
function syncEventStream() {
connectStateEvents(state, () => {
void runAppAction(async () => {
await reloadSelectedCampaign(state);
await reloadCampaignLog(state);
renderAll(state, elements);
});
});
}
async function runAppAction(action) {
await runAction(action, (message) => {
setStatus(message, true);
});
}
function setStatus(message, isError) {
setMessage(elements.messageElement, message, isError);
}

View File

@@ -1,18 +0,0 @@
export async function runAction(action, onError) {
try {
await action();
}
catch (error) {
onError(formatError(error));
}
}
export function setMessage(messageElement, message, isError) {
messageElement.textContent = message;
messageElement.style.color = isError ? "#b91c1c" : "#1d4ed8";
}
function formatError(error) {
if (error instanceof Error) {
return error.message;
}
return String(error);
}

View File

@@ -1,69 +0,0 @@
export function getAppElements() {
return {
healthElement: mustElement("health"),
messageElement: mustElement("message"),
campaignMetaElement: mustElement("campaign-meta"),
campaignDetailsElement: mustElement("campaign-details"),
rollResultElement: mustElement("roll-result"),
campaignLogElement: mustElement("campaign-log"),
registerForm: mustForm("register-form"),
loginForm: mustForm("login-form"),
logoutButton: mustButton("logout-button"),
registerUsername: mustInput("register-username"),
registerDisplayName: mustInput("register-display-name"),
registerPassword: mustInput("register-password"),
loginUsername: mustInput("login-username"),
loginPassword: mustInput("login-password"),
campaignForm: mustForm("campaign-form"),
campaignNameInput: mustInput("campaign-name"),
campaignRulesetSelect: mustSelect("campaign-ruleset"),
campaignSelect: mustSelect("campaign-select"),
refreshCampaignButton: mustButton("refresh-campaign-button"),
characterForm: mustForm("character-form"),
characterNameInput: mustInput("character-name"),
characterSelect: mustSelect("character-select"),
activateCharacterButton: mustButton("activate-character-button"),
skillForm: mustForm("skill-form"),
skillNameInput: mustInput("skill-name"),
skillExpressionInput: mustInput("skill-expression"),
updateSkillButton: mustButton("update-skill-button"),
skillSelect: mustSelect("skill-select"),
rollForm: mustForm("roll-form"),
rollVisibilitySelect: mustSelect("roll-visibility")
};
}
function mustElement(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLElement)) {
throw new Error(`Missing HTMLElement: ${id}`);
}
return element;
}
function mustInput(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLInputElement)) {
throw new Error(`Missing HTMLInputElement: ${id}`);
}
return element;
}
function mustSelect(id) {
const element = document.getElementById(id);
if (!(element instanceof HTMLSelectElement)) {
throw new Error(`Missing HTMLSelectElement: ${id}`);
}
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,23 +0,0 @@
export function connectStateEvents(state, onStateChanged) {
if (!state.selectedCampaignId || !state.user) {
closeStateEvents(state);
return;
}
if (state.eventSource && state.eventSource.url.endsWith(`campaignId=${state.selectedCampaignId}`)) {
return;
}
closeStateEvents(state);
state.eventSource = new EventSource(`/api/events/state?campaignId=${state.selectedCampaignId}`);
state.eventSource.addEventListener("state", () => {
onStateChanged();
});
state.eventSource.onerror = () => {
closeStateEvents(state);
};
}
export function closeStateEvents(state) {
if (state.eventSource) {
state.eventSource.close();
state.eventSource = null;
}
}

View File

@@ -1,58 +0,0 @@
import { getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets } from "../generated/api-client.js";
import { syncSelectedCharacter } from "./state.js";
export async function refreshHealth(elements) {
const health = await getHealth();
elements.healthElement.textContent = `API status: ${health.status}`;
}
export async function ensureRulesets(state, elements) {
if (state.rulesets.length > 0) {
return;
}
state.rulesets = await getRulesets();
elements.campaignRulesetSelect.innerHTML = state.rulesets
.map((ruleset) => `<option value="${ruleset.id}">${ruleset.name}</option>`)
.join("");
}
export async function reloadSession(state) {
try {
const me = await getMe();
state.user = me.user;
state.activeCharacterId = me.activeCharacterId ?? null;
state.selectedCampaignId = me.currentCampaignId ?? state.selectedCampaignId;
}
catch {
state.user = null;
state.activeCharacterId = null;
}
}
export async function reloadCampaigns(state, 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;
}
export async function reloadSelectedCampaign(state) {
if (!state.selectedCampaignId) {
state.selectedCampaign = null;
return;
}
state.selectedCampaign = await getCampaign(state.selectedCampaignId);
syncSelectedCharacter(state);
}
export async function reloadCampaignLog(state) {
if (!state.selectedCampaignId) {
state.campaignLog = [];
return;
}
state.campaignLog = await getCampaignLog(state.selectedCampaignId);
}

View File

@@ -1,98 +0,0 @@
import { resolveSelectedCharacterId, selectedSkillFromCampaign } from "./state.js";
export function renderAll(state, elements) {
renderCampaignSelect(state, elements);
renderCampaignMeta(state, elements);
renderCampaignDetails(state, elements);
renderCharacterSelect(state, elements);
renderSkillSelect(state, elements);
renderCampaignLog(state, elements);
}
export function renderCampaignMeta(state, elements) {
if (!state.user) {
elements.campaignMetaElement.textContent = "Log in to manage campaigns.";
return;
}
if (!state.selectedCampaign) {
elements.campaignMetaElement.textContent = "No campaign selected.";
return;
}
const isGm = state.selectedCampaign.gm.id === state.user.id;
const activeCharacter = state.selectedCampaign.characters.find((character) => character.id === state.activeCharacterId);
const activeLabel = activeCharacter ? ` | Active: ${activeCharacter.name}` : "";
elements.campaignMetaElement.textContent = `Selected: ${state.selectedCampaign.name} (${state.selectedCampaign.rulesetId}) - ${isGm ? "You are GM" : "Player context"}${activeLabel}`;
}
export function renderCampaignDetails(state, elements) {
if (!state.selectedCampaign) {
elements.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("");
elements.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>
`;
}
export function renderCharacterSelect(state, elements) {
if (!state.selectedCampaign) {
elements.characterSelect.innerHTML = "";
state.selectedCharacterId = null;
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const options = state.selectedCampaign.characters
.map((character) => {
const selected = character.id === selectedCharacterId ? " selected" : "";
return `<option value="${character.id}"${selected}>${character.name}</option>`;
})
.join("");
elements.characterSelect.innerHTML = options;
state.selectedCharacterId = selectedCharacterId;
}
export function renderSkillSelect(state, elements) {
if (!state.selectedCampaign) {
elements.skillSelect.innerHTML = "";
return;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
const characterSkills = state.selectedCampaign.skills.filter((skill) => skill.characterId === selectedCharacterId);
const options = characterSkills
.map((skill) => `<option value="${skill.id}">${skill.name} (${skill.diceRollDefinition})</option>`)
.join("");
elements.skillSelect.innerHTML = options;
const selectedSkill = selectedSkillFromCampaign(state, elements.skillSelect.value);
if (selectedSkill) {
elements.skillNameInput.value = selectedSkill.name;
elements.skillExpressionInput.value = selectedSkill.diceRollDefinition;
}
}
export function renderCampaignLog(state, elements) {
if (state.campaignLog.length === 0) {
elements.campaignLogElement.innerHTML = "<li class=\"log-item\">No rolls yet.</li>";
return;
}
elements.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("");
}
function renderCampaignSelect(state, elements) {
elements.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("");
}

View File

@@ -1,58 +0,0 @@
export function createInitialState() {
return {
user: null,
activeCharacterId: null,
selectedCharacterId: null,
campaigns: [],
selectedCampaignId: null,
selectedCampaign: null,
campaignLog: [],
rulesets: [],
eventSource: null
};
}
export function resetAuthenticatedState(state) {
state.activeCharacterId = null;
state.selectedCharacterId = null;
state.campaigns = [];
state.selectedCampaignId = null;
state.selectedCampaign = null;
state.campaignLog = [];
}
export function resetStateAfterLogout(state) {
state.user = null;
resetAuthenticatedState(state);
}
export function syncSelectedCharacter(state) {
if (!state.selectedCampaign) {
state.selectedCharacterId = null;
return;
}
const availableIds = new Set(state.selectedCampaign.characters.map((character) => character.id));
if (state.selectedCharacterId && availableIds.has(state.selectedCharacterId)) {
return;
}
if (state.activeCharacterId && availableIds.has(state.activeCharacterId)) {
state.selectedCharacterId = state.activeCharacterId;
return;
}
state.selectedCharacterId = state.selectedCampaign.characters.length > 0
? state.selectedCampaign.characters[0].id
: null;
}
export function resolveSelectedCharacterId(state) {
if (!state.selectedCampaign) {
return null;
}
syncSelectedCharacter(state);
return state.selectedCharacterId;
}
export function selectedSkillFromCampaign(state, selectedSkillId) {
if (!state.selectedCampaign) {
return null;
}
const selectedCharacterId = resolveSelectedCharacterId(state);
return state.selectedCampaign.skills
.filter((skill) => skill.characterId === selectedCharacterId)
.find((skill) => skill.id === selectedSkillId) ?? null;
}

View File

@@ -1 +0,0 @@
export {};

View File

@@ -1,181 +0,0 @@
/* This file is generated by scripts/generate-api-client.mjs. */
export const apiOperations = {
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 }
};
function withPathParams(pathTemplate, pathParams) {
let pathValue = pathTemplate;
for (const [key, value] of Object.entries(pathParams)) {
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,
body
});
if (!response.ok) {
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
if (errorPayload && typeof errorPayload === "object" && "error" in errorPayload && typeof errorPayload.error === "string") {
throw new Error(errorPayload.error);
}
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, {
pathParams: {},
query: undefined,
body: undefined
});
}
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

@@ -1,91 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RpgRoller</title>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
<main class="layout">
<h1>RpgRoller</h1>
<p id="health" class="status">Checking API status...</p>
<p id="message" class="message"></p>
<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>
<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>
</body>
</html>