Wire full OpenAPI client and TypeScript app workflow
This commit is contained in:
10
README.md
10
README.md
@@ -53,3 +53,13 @@ Fresh full-stack starter scaffold:
|
|||||||
- Skills: create/update with ruleset-aware dice expression validation
|
- Skills: create/update with ruleset-aware dice expression validation
|
||||||
- Rolls: public/private skill rolls with append-only campaign log
|
- Rolls: public/private skill rolls with append-only campaign log
|
||||||
- State stream: SSE endpoint for campaign version updates
|
- State stream: SSE endpoint for campaign version updates
|
||||||
|
|
||||||
|
## Implemented Frontend Scope
|
||||||
|
|
||||||
|
- TypeScript-driven UI for:
|
||||||
|
- registration, login, logout
|
||||||
|
- campaign creation and selection
|
||||||
|
- character creation and activation
|
||||||
|
- skill creation and editing
|
||||||
|
- public/private rolling and campaign log viewing
|
||||||
|
- SSE-backed live refresh for selected campaign state/log
|
||||||
|
|||||||
@@ -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 healthElement = mustElement("health");
|
||||||
const resultElementRaw = document.getElementById("result");
|
const messageElement = mustElement("message");
|
||||||
const formElementRaw = document.getElementById("roll-form");
|
const campaignMetaElement = mustElement("campaign-meta");
|
||||||
const sidesInputRaw = document.getElementById("sides");
|
const campaignDetailsElement = mustElement("campaign-details");
|
||||||
|
const rollResultElement = mustElement("roll-result");
|
||||||
|
const campaignLogElement = mustElement("campaign-log");
|
||||||
|
|
||||||
if (
|
const registerForm = mustForm("register-form");
|
||||||
!(healthElementRaw instanceof HTMLElement) ||
|
const loginForm = mustForm("login-form");
|
||||||
!(resultElementRaw instanceof HTMLElement) ||
|
const logoutButton = mustButton("logout-button");
|
||||||
!(formElementRaw instanceof HTMLFormElement) ||
|
|
||||||
!(sidesInputRaw instanceof HTMLInputElement)
|
const registerUsername = mustInput("register-username");
|
||||||
) {
|
const registerDisplayName = mustInput("register-display-name");
|
||||||
throw new Error("Required UI elements are missing from index.html.");
|
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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const healthElement = healthElementRaw;
|
await createCharacter({
|
||||||
const resultElement = resultElementRaw;
|
name: characterNameInput.value.trim(),
|
||||||
const formElement = formElementRaw;
|
campaignId: state.selectedCampaignId
|
||||||
const sidesInput = sidesInputRaw;
|
});
|
||||||
|
|
||||||
function errorMessage(error: unknown): string {
|
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}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadAll(): Promise<void> {
|
||||||
|
await ensureRulesets();
|
||||||
|
await reloadSession();
|
||||||
|
if (state.user) {
|
||||||
|
await reloadCampaigns(state.selectedCampaignId);
|
||||||
|
await reloadSelectedCampaign();
|
||||||
|
await reloadCampaignLog();
|
||||||
|
connectStateEvents();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
resetAuthenticatedState();
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
@@ -27,27 +511,47 @@ function errorMessage(error: unknown): string {
|
|||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshHealth(): Promise<void> {
|
function mustElement(id: string): HTMLElement {
|
||||||
try {
|
const element = document.getElementById(id);
|
||||||
const health = await getHealth();
|
if (!(element instanceof HTMLElement)) {
|
||||||
healthElement.textContent = `API status: ${health.status}`;
|
throw new Error(`Missing HTMLElement: ${id}`);
|
||||||
}
|
|
||||||
catch (error: unknown) {
|
|
||||||
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formElement.addEventListener("submit", async (event: SubmitEvent) => {
|
return element;
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
const sides = Number.parseInt(sidesInput.value, 10);
|
|
||||||
try {
|
|
||||||
const roll = await rollDice(sides);
|
|
||||||
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
|
|
||||||
}
|
}
|
||||||
catch (error: unknown) {
|
|
||||||
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await refreshHealth();
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,36 +4,190 @@ export interface ApiError {
|
|||||||
error: string;
|
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 {
|
export interface HealthResponse {
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RollResponse {
|
export interface LoginRequest {
|
||||||
sides: number;
|
username: string;
|
||||||
value: number;
|
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 = {
|
type ApiOperation = {
|
||||||
method: string;
|
method: string;
|
||||||
path: 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 = {
|
export const apiOperations = {
|
||||||
getHealth: { method: "GET", path: "/api/health" },
|
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
|
||||||
rollDice: { method: "GET", path: "/api/roll/{sides}" }
|
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>;
|
} as const satisfies Record<string, ApiOperation>;
|
||||||
|
|
||||||
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
|
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
|
||||||
let resolvedPath = operation.path;
|
let pathValue = pathTemplate;
|
||||||
for (const [key, value] of Object.entries(pathParams)) {
|
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, {
|
const response = await fetch(resolvedPath, {
|
||||||
method: operation.method,
|
method: operation.method,
|
||||||
headers: {
|
headers,
|
||||||
"Accept": "application/json"
|
body
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
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}`);
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!operation.expectsJson) {
|
||||||
|
return undefined as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
return response.json() as Promise<TResult>;
|
return response.json() as Promise<TResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getHealth(): Promise<HealthResponse> {
|
export async function activateCharacter(characterId: string): Promise<boolean> {
|
||||||
return send<HealthResponse>(apiOperations.getHealth, {});
|
return send<boolean>(apiOperations.activateCharacter, {
|
||||||
|
pathParams: { characterId: characterId },
|
||||||
|
query: undefined,
|
||||||
|
body: undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rollDice(sides: number): Promise<RollResponse> {
|
export async function createCampaign(body: CreateCampaignRequest): Promise<CampaignSummary> {
|
||||||
return send<RollResponse>(apiOperations.rollDice, { sides: sides });
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +1,429 @@
|
|||||||
import { getHealth, rollDice } from "./generated/api-client.js";
|
import { activateCharacter, createCampaign, createCharacter, createSkill, getCampaign, getCampaignLog, getCampaigns, getHealth, getMe, getRulesets, loginUser, logoutUser, registerUser, rollSkill, updateSkill } from "./generated/api-client.js";
|
||||||
const healthElementRaw = document.getElementById("health");
|
const healthElement = mustElement("health");
|
||||||
const resultElementRaw = document.getElementById("result");
|
const messageElement = mustElement("message");
|
||||||
const formElementRaw = document.getElementById("roll-form");
|
const campaignMetaElement = mustElement("campaign-meta");
|
||||||
const sidesInputRaw = document.getElementById("sides");
|
const campaignDetailsElement = mustElement("campaign-details");
|
||||||
if (!(healthElementRaw instanceof HTMLElement) ||
|
const rollResultElement = mustElement("roll-result");
|
||||||
!(resultElementRaw instanceof HTMLElement) ||
|
const campaignLogElement = mustElement("campaign-log");
|
||||||
!(formElementRaw instanceof HTMLFormElement) ||
|
const registerForm = mustForm("register-form");
|
||||||
!(sidesInputRaw instanceof HTMLInputElement)) {
|
const loginForm = mustForm("login-form");
|
||||||
throw new Error("Required UI elements are missing from index.html.");
|
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.");
|
||||||
}
|
}
|
||||||
const healthElement = healthElementRaw;
|
await createCharacter({
|
||||||
const resultElement = resultElementRaw;
|
name: characterNameInput.value.trim(),
|
||||||
const formElement = formElementRaw;
|
campaignId: state.selectedCampaignId
|
||||||
const sidesInput = sidesInputRaw;
|
});
|
||||||
function errorMessage(error) {
|
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}`;
|
||||||
|
}
|
||||||
|
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) {
|
if (error instanceof Error) {
|
||||||
return error.message;
|
return error.message;
|
||||||
}
|
}
|
||||||
return String(error);
|
return String(error);
|
||||||
}
|
}
|
||||||
async function refreshHealth() {
|
function mustElement(id) {
|
||||||
try {
|
const element = document.getElementById(id);
|
||||||
const health = await getHealth();
|
if (!(element instanceof HTMLElement)) {
|
||||||
healthElement.textContent = `API status: ${health.status}`;
|
throw new Error(`Missing HTMLElement: ${id}`);
|
||||||
}
|
}
|
||||||
catch (error) {
|
return element;
|
||||||
healthElement.textContent = `API status check failed: ${errorMessage(error)}`;
|
|
||||||
}
|
}
|
||||||
|
function mustInput(id) {
|
||||||
|
const element = document.getElementById(id);
|
||||||
|
if (!(element instanceof HTMLInputElement)) {
|
||||||
|
throw new Error(`Missing HTMLInputElement: ${id}`);
|
||||||
}
|
}
|
||||||
formElement.addEventListener("submit", async (event) => {
|
return element;
|
||||||
event.preventDefault();
|
|
||||||
const sides = Number.parseInt(sidesInput.value, 10);
|
|
||||||
try {
|
|
||||||
const roll = await rollDice(sides);
|
|
||||||
resultElement.textContent = `Rolled d${roll.sides}: ${roll.value}`;
|
|
||||||
}
|
}
|
||||||
catch (error) {
|
function mustSelect(id) {
|
||||||
resultElement.textContent = `Roll failed: ${errorMessage(error)}`;
|
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;
|
||||||
}
|
}
|
||||||
});
|
|
||||||
await refreshHealth();
|
|
||||||
|
|||||||
@@ -1,18 +1,52 @@
|
|||||||
/* This file is generated by scripts/generate-api-client.mjs. */
|
/* This file is generated by scripts/generate-api-client.mjs. */
|
||||||
export const apiOperations = {
|
export const apiOperations = {
|
||||||
getHealth: { method: "GET", path: "/api/health" },
|
activateCharacter: { method: "POST", path: "/api/characters/{characterId}/activate", expectsJson: true },
|
||||||
rollDice: { method: "GET", path: "/api/roll/{sides}" }
|
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 = {}) {
|
function withPathParams(pathTemplate, pathParams) {
|
||||||
let resolvedPath = operation.path;
|
let pathValue = pathTemplate;
|
||||||
for (const [key, value] of Object.entries(pathParams)) {
|
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, {
|
const response = await fetch(resolvedPath, {
|
||||||
method: operation.method,
|
method: operation.method,
|
||||||
headers: {
|
headers,
|
||||||
"Accept": "application/json"
|
body
|
||||||
}
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorPayload = await response.json().catch(() => ({ error: "Unknown API error." }));
|
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}`);
|
throw new Error(`Request failed with status ${response.status}`);
|
||||||
}
|
}
|
||||||
|
if (!operation.expectsJson) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
return response.json();
|
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() {
|
export async function getHealth() {
|
||||||
return send(apiOperations.getHealth, {});
|
return send(apiOperations.getHealth, {
|
||||||
|
pathParams: {},
|
||||||
|
query: undefined,
|
||||||
|
body: undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
export async function rollDice(sides) {
|
export async function getMe() {
|
||||||
return send(apiOperations.rollDice, { sides: sides });
|
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
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,14 +10,80 @@
|
|||||||
<main class="layout">
|
<main class="layout">
|
||||||
<h1>RpgRoller</h1>
|
<h1>RpgRoller</h1>
|
||||||
<p id="health" class="status">Checking API status...</p>
|
<p id="health" class="status">Checking API status...</p>
|
||||||
|
<p id="message" class="message"></p>
|
||||||
|
|
||||||
<form id="roll-form" class="panel">
|
<section class="panel">
|
||||||
<label for="sides">Sides</label>
|
<h2>Authentication</h2>
|
||||||
<input id="sides" name="sides" type="number" min="2" max="1000" value="20" required>
|
<form id="register-form" class="grid-form">
|
||||||
<button type="submit">Roll</button>
|
<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>
|
||||||
|
<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>
|
</main>
|
||||||
|
|
||||||
<script type="module" src="/app.js"></script>
|
<script type="module" src="/app.js"></script>
|
||||||
|
|||||||
@@ -11,18 +11,34 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.layout {
|
.layout {
|
||||||
max-width: 32rem;
|
max-width: 56rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.25rem;
|
padding: 2.5rem 1.25rem;
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.6rem;
|
||||||
margin-top: 1rem;
|
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,
|
input,
|
||||||
|
select,
|
||||||
button {
|
button {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
padding: 0.6rem 0.75rem;
|
padding: 0.6rem 0.75rem;
|
||||||
@@ -40,3 +56,23 @@ button {
|
|||||||
.result {
|
.result {
|
||||||
font-weight: 600;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
1
TECH.md
1
TECH.md
@@ -11,6 +11,7 @@
|
|||||||
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
|
- Generated client output: `RpgRoller/wwwroot/generated/api-client.js`
|
||||||
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
- Local CI parity entrypoint: `scripts/ci-local.ps1`
|
||||||
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
- Current backend features: auth/session, campaign/character/skill management, ruleset-aware rolls, filtered campaign logs, and SSE state updates.
|
||||||
|
- Current frontend features: authenticated campaign workspace with live log updates and full roll workflow controls.
|
||||||
|
|
||||||
## 1) Stack and baseline choices
|
## 1) Stack and baseline choices
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -47,7 +47,7 @@ function schemaRefName(schema) {
|
|||||||
return schema.$ref.substring(refPrefix.length);
|
return schema.$ref.substring(refPrefix.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toTypeScriptType(schema, components, forProperty = false) {
|
function toTypeScriptType(schema, forProperty = false) {
|
||||||
if (!schema || typeof schema !== "object") {
|
if (!schema || typeof schema !== "object") {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -63,7 +63,7 @@ function toTypeScriptType(schema, components, forProperty = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (schema.type === "array") {
|
if (schema.type === "array") {
|
||||||
const itemType = toTypeScriptType(schema.items, components);
|
const itemType = toTypeScriptType(schema.items, true);
|
||||||
return `Array<${itemType}>`;
|
return `Array<${itemType}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +75,7 @@ function toTypeScriptType(schema, components, forProperty = false) {
|
|||||||
|
|
||||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||||
const fields = propertyEntries.map(([name, propertySchema]) => {
|
const fields = propertyEntries.map(([name, propertySchema]) => {
|
||||||
const typeName = toTypeScriptType(propertySchema, components, true);
|
const typeName = toTypeScriptType(propertySchema, true);
|
||||||
const optional = required.has(name) ? "" : "?";
|
const optional = required.has(name) ? "" : "?";
|
||||||
return `${name}${optional}: ${typeName};`;
|
return `${name}${optional}: ${typeName};`;
|
||||||
});
|
});
|
||||||
@@ -88,20 +88,20 @@ function toTypeScriptType(schema, components, forProperty = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) {
|
||||||
return schema.oneOf.map((option) => toTypeScriptType(option, components, true)).join(" | ");
|
return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | ");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) {
|
||||||
return schema.anyOf.map((option) => toTypeScriptType(option, components, true)).join(" | ");
|
return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | ");
|
||||||
}
|
}
|
||||||
|
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function schemaToInterface(name, schema, components) {
|
function schemaToInterface(name, schema) {
|
||||||
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
|
||||||
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
|
const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => {
|
||||||
const typeName = toTypeScriptType(propertySchema, components, true);
|
const typeName = toTypeScriptType(propertySchema, true);
|
||||||
const optional = required.has(propertyName) ? "" : "?";
|
const optional = required.has(propertyName) ? "" : "?";
|
||||||
return ` ${propertyName}${optional}: ${typeName};`;
|
return ` ${propertyName}${optional}: ${typeName};`;
|
||||||
});
|
});
|
||||||
@@ -113,26 +113,98 @@ function schemaToInterface(name, schema, components) {
|
|||||||
return `export interface ${name} {\n${fields.join("\n")}\n}`;
|
return `export interface ${name} {\n${fields.join("\n")}\n}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectOperations(document, components) {
|
function collectParameters(pathItem, operation) {
|
||||||
const operations = [];
|
const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])];
|
||||||
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
const deduped = [];
|
||||||
const pathLevelParameters = Array.isArray(pathItem?.parameters) ? pathItem.parameters : [];
|
const keys = new Set();
|
||||||
|
|
||||||
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
for (const parameter of rawParameters) {
|
||||||
if (operation === null || typeof operation !== "object") {
|
if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof operation.operationId !== "string" || operation.operationId.length === 0) {
|
const key = `${parameter.in}:${parameter.name}`;
|
||||||
|
if (keys.has(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
keys.add(key);
|
||||||
|
|
||||||
|
const inferredType = mapSimpleType(parameter.schema) ?? "string";
|
||||||
|
deduped.push({
|
||||||
|
name: parameter.name,
|
||||||
|
location: parameter.in,
|
||||||
|
required: parameter.required === true,
|
||||||
|
type: inferredType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequestBodyType(operation) {
|
||||||
|
const bodySchema = operation.requestBody?.content?.["application/json"]?.schema;
|
||||||
|
if (!bodySchema) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: toTypeScriptType(bodySchema, true),
|
||||||
|
required: operation.requestBody.required === true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveResponse(operation) {
|
||||||
|
const responses = operation.responses ?? {};
|
||||||
|
const successCodes = Object.keys(responses)
|
||||||
|
.filter((code) => code.startsWith("2"))
|
||||||
|
.sort();
|
||||||
|
|
||||||
|
if (successCodes.length === 0) {
|
||||||
|
return { type: "void", expectsJson: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const code of successCodes) {
|
||||||
|
const jsonSchema = responses[code]?.content?.["application/json"]?.schema;
|
||||||
|
if (jsonSchema) {
|
||||||
|
return { type: toTypeScriptType(jsonSchema), expectsJson: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === "204") {
|
||||||
|
return { type: "void", expectsJson: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: "void", expectsJson: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectOperations(document) {
|
||||||
|
const operations = [];
|
||||||
|
|
||||||
|
for (const [pathKey, pathItem] of Object.entries(document.paths ?? {})) {
|
||||||
|
for (const [method, operation] of Object.entries(pathItem ?? {})) {
|
||||||
|
if (operation === null || typeof operation !== "object" || typeof operation.operationId !== "string") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operation.operationId.length === 0) {
|
||||||
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
|
throw new Error(`Missing operationId for ${method.toUpperCase()} ${pathKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parameters = collectParameters(pathItem, operation);
|
||||||
|
const pathParameters = parameters.filter((parameter) => parameter.location === "path");
|
||||||
|
const queryParameters = parameters.filter((parameter) => parameter.location === "query");
|
||||||
|
const requestBody = resolveRequestBodyType(operation);
|
||||||
|
const response = resolveResponse(operation);
|
||||||
|
|
||||||
operations.push({
|
operations.push({
|
||||||
operationId: operation.operationId,
|
operationId: operation.operationId,
|
||||||
method: method.toUpperCase(),
|
method: method.toUpperCase(),
|
||||||
path: pathKey,
|
path: pathKey,
|
||||||
parameters: collectPathParameters([...pathLevelParameters, ...(operation.parameters ?? [])]),
|
pathParameters,
|
||||||
responseType: resolveResponseType(operation, components)
|
queryParameters,
|
||||||
|
requestBody,
|
||||||
|
response
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,55 +216,8 @@ function collectOperations(document, components) {
|
|||||||
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
|
return operations.sort((left, right) => left.operationId.localeCompare(right.operationId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectPathParameters(parameters) {
|
|
||||||
const seen = new Set();
|
|
||||||
const result = [];
|
|
||||||
|
|
||||||
for (const parameter of parameters) {
|
|
||||||
if (!parameter || parameter.in !== "path" || typeof parameter.name !== "string") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (seen.has(parameter.name)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
seen.add(parameter.name);
|
|
||||||
|
|
||||||
const simpleType = mapSimpleType(parameter.schema);
|
|
||||||
result.push({
|
|
||||||
name: parameter.name,
|
|
||||||
type: simpleType ?? "string"
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveResponseType(operation, components) {
|
|
||||||
const responses = operation.responses ?? {};
|
|
||||||
const successCodes = Object.keys(responses)
|
|
||||||
.filter((code) => code.startsWith("2"))
|
|
||||||
.sort();
|
|
||||||
|
|
||||||
if (successCodes.length === 0) {
|
|
||||||
return "void";
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const code of successCodes) {
|
|
||||||
const content = responses[code]?.content?.["application/json"];
|
|
||||||
const schema = content?.schema;
|
|
||||||
if (schema) {
|
|
||||||
return toTypeScriptType(schema, components);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return "void";
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSchemaTypes(document) {
|
function buildSchemaTypes(document) {
|
||||||
const components = document.components ?? {};
|
const schemas = document.components?.schemas ?? {};
|
||||||
const schemas = components.schemas ?? {};
|
|
||||||
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
|
const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right));
|
||||||
|
|
||||||
const declarations = schemaEntries.map(([name, schema]) => {
|
const declarations = schemaEntries.map(([name, schema]) => {
|
||||||
@@ -200,21 +225,62 @@ function buildSchemaTypes(document) {
|
|||||||
return `export type ${name} = unknown;`;
|
return `export type ${name} = unknown;`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return schemaToInterface(name, schema, components);
|
return schemaToInterface(name, schema);
|
||||||
});
|
});
|
||||||
|
|
||||||
return declarations.join("\n\n");
|
return declarations.join("\n\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildFunctionSource(operation) {
|
||||||
|
const pathParameters = operation.pathParameters;
|
||||||
|
const queryParameters = operation.queryParameters;
|
||||||
|
const body = operation.requestBody;
|
||||||
|
|
||||||
|
const parts = [];
|
||||||
|
if (pathParameters.length > 0) {
|
||||||
|
parts.push(...pathParameters.map((parameter) => `${parameter.name}: ${parameter.type}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body) {
|
||||||
|
const optional = body.required ? "" : "?";
|
||||||
|
parts.push(`body${optional}: ${body.type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queryParameters.length > 0) {
|
||||||
|
const queryType = queryParameters
|
||||||
|
.map((parameter) => `${parameter.name}${parameter.required ? "" : "?"}: ${parameter.type};`)
|
||||||
|
.join(" ");
|
||||||
|
parts.push(`query: { ${queryType} }`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = parts.join(", ");
|
||||||
|
|
||||||
|
const pathParameterAssignment = pathParameters.length === 0
|
||||||
|
? "{}"
|
||||||
|
: `{ ${pathParameters.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
|
||||||
|
|
||||||
|
const bodyArgument = body ? "body" : "undefined";
|
||||||
|
const queryArgument = queryParameters.length > 0 ? "query" : "undefined";
|
||||||
|
|
||||||
|
return `export async function ${operation.operationId}(${signature}): Promise<${operation.response.type}> {\n return send<${operation.response.type}>(apiOperations.${operation.operationId}, {\n pathParams: ${pathParameterAssignment},\n query: ${queryArgument},\n body: ${bodyArgument}\n });\n}`;
|
||||||
|
}
|
||||||
|
|
||||||
function buildClientSource(operations, schemaTypes) {
|
function buildClientSource(operations, schemaTypes) {
|
||||||
const operationEntries = operations
|
const operationEntries = operations
|
||||||
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}" }`)
|
.map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`)
|
||||||
.join(",\n");
|
.join(",\n");
|
||||||
|
|
||||||
const helper = `
|
const helper = `
|
||||||
type ApiOperation = {
|
type ApiOperation = {
|
||||||
method: string;
|
method: string;
|
||||||
path: 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 = {
|
export const apiOperations = {
|
||||||
@@ -223,17 +289,42 @@ ${operationEntries}
|
|||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const sendFunction = `
|
const sendFunction = `
|
||||||
async function send<TResult>(operation: ApiOperation, pathParams: Record<string, string | number | boolean> = {}): Promise<TResult> {
|
function withPathParams(pathTemplate: string, pathParams: Record<string, string | number | boolean>): string {
|
||||||
let resolvedPath = operation.path;
|
let pathValue = pathTemplate;
|
||||||
for (const [key, value] of Object.entries(pathParams)) {
|
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, {
|
const response = await fetch(resolvedPath, {
|
||||||
method: operation.method,
|
method: operation.method,
|
||||||
headers: {
|
headers,
|
||||||
"Accept": "application/json"
|
body
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -245,31 +336,22 @@ async function send<TResult>(operation: ApiOperation, pathParams: Record<string,
|
|||||||
throw new Error(\`Request failed with status \${response.status}\`);
|
throw new Error(\`Request failed with status \${response.status}\`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!operation.expectsJson) {
|
||||||
|
return undefined as TResult;
|
||||||
|
}
|
||||||
|
|
||||||
return response.json() as Promise<TResult>;
|
return response.json() as Promise<TResult>;
|
||||||
}
|
}
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
const exports = operations
|
const exports = operations.map(buildFunctionSource).join("\n\n");
|
||||||
.map((operation) => {
|
|
||||||
const params = operation.parameters;
|
|
||||||
const signature = params.length === 0 ? "" : params.map((parameter) => `${parameter.name}: ${parameter.type}`).join(", ");
|
|
||||||
const pathParams = params.length === 0
|
|
||||||
? "{}"
|
|
||||||
: `{ ${params.map((parameter) => `${parameter.name}: ${parameter.name}`).join(", ")} }`;
|
|
||||||
|
|
||||||
const responseType = operation.responseType ?? "unknown";
|
|
||||||
|
|
||||||
return `export async function ${operation.operationId}(${signature}): Promise<${responseType}> {\n return send<${responseType}>(apiOperations.${operation.operationId}, ${pathParams});\n}`;
|
|
||||||
})
|
|
||||||
.join("\n\n");
|
|
||||||
|
|
||||||
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
|
return `/* This file is generated by scripts/generate-api-client.mjs. */\n\n${schemaTypes}\n\n${helper}\n\n${sendFunction}\n\n${exports}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const openApiText = await readFile(openApiPath, "utf8");
|
const openApiText = await readFile(openApiPath, "utf8");
|
||||||
const document = JSON.parse(openApiText);
|
const document = JSON.parse(openApiText);
|
||||||
const components = document.components ?? {};
|
const operations = collectOperations(document);
|
||||||
const operations = collectOperations(document, components);
|
|
||||||
const schemaTypes = buildSchemaTypes(document);
|
const schemaTypes = buildSchemaTypes(document);
|
||||||
const clientSource = buildClientSource(operations, schemaTypes);
|
const clientSource = buildClientSource(operations, schemaTypes);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user