From 0f44cc466b07a554df2038af5ef356aad2547e01 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Wed, 25 Feb 2026 12:31:39 +0100 Subject: [PATCH] Remove legacy TypeScript frontend and npm pipeline --- FAQ.md | 14 +- FRONTEND_PROGRESS.md | 1 + README.md | 13 +- RpgRoller/frontend/app.ts | 252 --------------- RpgRoller/frontend/app/actions.ts | 24 -- RpgRoller/frontend/app/dom.ts | 112 ------- RpgRoller/frontend/app/events.ts | 30 -- RpgRoller/frontend/app/loaders.ts | 74 ----- RpgRoller/frontend/app/render.ts | 117 ------- RpgRoller/frontend/app/state.ts | 71 ---- RpgRoller/frontend/app/types.ts | 19 -- RpgRoller/frontend/generated/api-client.ts | 343 -------------------- RpgRoller/wwwroot/app.js | 198 ------------ RpgRoller/wwwroot/app/actions.js | 18 -- RpgRoller/wwwroot/app/dom.js | 69 ---- RpgRoller/wwwroot/app/events.js | 23 -- RpgRoller/wwwroot/app/loaders.js | 58 ---- RpgRoller/wwwroot/app/render.js | 98 ------ RpgRoller/wwwroot/app/state.js | 58 ---- RpgRoller/wwwroot/app/types.js | 1 - RpgRoller/wwwroot/generated/api-client.js | 181 ----------- RpgRoller/wwwroot/index.html | 91 ------ TECH.md | 38 +-- package-lock.json | 29 -- package.json | 15 - scripts/ci-local.ps1 | 19 -- scripts/deploy-ftp.ps1 | 89 +---- scripts/format-check.mjs | 70 ---- scripts/generate-api-client.mjs | 360 --------------------- scripts/lint-frontend.mjs | 41 --- tsconfig.frontend.json | 20 -- 31 files changed, 20 insertions(+), 2526 deletions(-) delete mode 100644 RpgRoller/frontend/app.ts delete mode 100644 RpgRoller/frontend/app/actions.ts delete mode 100644 RpgRoller/frontend/app/dom.ts delete mode 100644 RpgRoller/frontend/app/events.ts delete mode 100644 RpgRoller/frontend/app/loaders.ts delete mode 100644 RpgRoller/frontend/app/render.ts delete mode 100644 RpgRoller/frontend/app/state.ts delete mode 100644 RpgRoller/frontend/app/types.ts delete mode 100644 RpgRoller/frontend/generated/api-client.ts delete mode 100644 RpgRoller/wwwroot/app.js delete mode 100644 RpgRoller/wwwroot/app/actions.js delete mode 100644 RpgRoller/wwwroot/app/dom.js delete mode 100644 RpgRoller/wwwroot/app/events.js delete mode 100644 RpgRoller/wwwroot/app/loaders.js delete mode 100644 RpgRoller/wwwroot/app/render.js delete mode 100644 RpgRoller/wwwroot/app/state.js delete mode 100644 RpgRoller/wwwroot/app/types.js delete mode 100644 RpgRoller/wwwroot/generated/api-client.js delete mode 100644 RpgRoller/wwwroot/index.html delete mode 100644 package-lock.json delete mode 100644 package.json delete mode 100644 scripts/format-check.mjs delete mode 100644 scripts/generate-api-client.mjs delete mode 100644 scripts/lint-frontend.mjs delete mode 100644 tsconfig.frontend.json diff --git a/FAQ.md b/FAQ.md index 1fc405c..24eaaec 100644 --- a/FAQ.md +++ b/FAQ.md @@ -1,15 +1,9 @@ # FAQ -## Why does this starter use custom frontend lint/format scripts instead of heavy npm dependencies? +## Does this project still require npm/frontend TypeScript tooling? -The kickoff scaffold is intentionally lightweight and keeps only strictly relevant tooling: - -- API client generation from the OpenAPI contract -- TypeScript compilation for frontend source files -- basic frontend contract checks -- deterministic formatting checks used by `scripts/ci-local.ps1` - -This keeps the first commit small while preserving CI discipline. Additional tooling can be introduced when the frontend stack is finalized. +No. The legacy TypeScript frontend pipeline was removed after the Blazor rewrite. +`scripts/ci-local.ps1` is now a .NET-only flow (restore, build, tests, coverage checks). ## Is frontend JavaScript handwritten? @@ -21,7 +15,7 @@ There is still small handwritten JavaScript in `RpgRoller/wwwroot/js/rpgroller-a - SSE connection/reconnect handling - per-tab session storage helpers used by the Blazor UI -The TypeScript frontend folders remain in the repo for tooling and generated API client contract checks used by CI. +There is no TypeScript runtime frontend in the current codebase. ## Where is backend state stored locally? diff --git a/FRONTEND_PROGRESS.md b/FRONTEND_PROGRESS.md index 687c919..62c8f4b 100644 --- a/FRONTEND_PROGRESS.md +++ b/FRONTEND_PROGRESS.md @@ -6,6 +6,7 @@ Tracking against `UX.md` tasks and decisions. - Branch: `feature/blazor-frontend-rebuild-ux` - Runtime frontend stack: Blazor (`RpgRoller/Components/*`) + browser JS interop (`wwwroot/js/rpgroller-api.js`) +- Legacy TypeScript frontend/runtime artifacts: removed ## UX Checklist diff --git a/README.md b/README.md index 3061bfa..59639cd 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ Fresh full-stack starter scaffold: - `RpgRoller/`: ASP.NET Core backend + Blazor frontend host (`Components` + `wwwroot`) -- `RpgRoller/frontend/`: TypeScript frontend source - `RpgRoller.Tests/`: xUnit integration-heavy test project - `RpgRoller.sln`: solution used by local CI script - `UX.md`: frontend UX and interaction design specification (pre-implementation baseline) @@ -30,7 +29,6 @@ Frontend: - `RpgRoller/Components/Pages/Home.razor(.cs)`: main UX implementation for auth/play/management screens - `RpgRoller/wwwroot/js/rpgroller-api.js`: browser-side API + SSE + session storage interop for Blazor - `RpgRoller/wwwroot/styles.css`: responsive UX styling and theme tokens -- `RpgRoller/frontend/generated/`: generated TypeScript API client source retained for contract/tooling parity Backend state persistence: @@ -43,7 +41,6 @@ Backend state persistence: ## Prerequisites - .NET SDK 10.0+ -- Node.js 22+ and npm - PowerShell 7+ ## Local Development @@ -60,13 +57,11 @@ Backend state persistence: To use a custom SQLite database path, set `ConnectionStrings__RpgRoller`. -## Frontend Tooling +## Frontend Runtime -- OpenAPI contract: `openapi/RpgRoller.json` -- TypeScript build output config: `tsconfig.frontend.json` -- API client generation + frontend compile: `npm run generate:api-client` -- Frontend lint checks: `npm run lint` -- Frontend format checks: `npm run format:check` +- Runtime frontend is Blazor Server with interactive components. +- Browser interop is in `RpgRoller/wwwroot/js/rpgroller-api.js`. +- OpenAPI contract source remains at `openapi/RpgRoller.json`. ## Test and Coverage diff --git a/RpgRoller/frontend/app.ts b/RpgRoller/frontend/app.ts deleted file mode 100644 index 97faa86..0000000 --- a/RpgRoller/frontend/app.ts +++ /dev/null @@ -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 { - 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): Promise { - await runAction(action, (message) => { - setStatus(message, true); - }); -} - -function setStatus(message: string, isError: boolean): void { - setMessage(elements.messageElement, message, isError); -} diff --git a/RpgRoller/frontend/app/actions.ts b/RpgRoller/frontend/app/actions.ts deleted file mode 100644 index 7fde671..0000000 --- a/RpgRoller/frontend/app/actions.ts +++ /dev/null @@ -1,24 +0,0 @@ -export async function runAction( - action: () => Promise, - onError: (message: string) => void -): Promise { - 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); -} diff --git a/RpgRoller/frontend/app/dom.ts b/RpgRoller/frontend/app/dom.ts deleted file mode 100644 index 55090d7..0000000 --- a/RpgRoller/frontend/app/dom.ts +++ /dev/null @@ -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; -} diff --git a/RpgRoller/frontend/app/events.ts b/RpgRoller/frontend/app/events.ts deleted file mode 100644 index 75a6962..0000000 --- a/RpgRoller/frontend/app/events.ts +++ /dev/null @@ -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; - } -} diff --git a/RpgRoller/frontend/app/loaders.ts b/RpgRoller/frontend/app/loaders.ts deleted file mode 100644 index 8816ac2..0000000 --- a/RpgRoller/frontend/app/loaders.ts +++ /dev/null @@ -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 { - const health = await getHealth(); - elements.healthElement.textContent = `API status: ${health.status}`; -} - -export async function ensureRulesets(state: AppState, elements: AppElements): Promise { - if (state.rulesets.length > 0) { - return; - } - - state.rulesets = await getRulesets(); - elements.campaignRulesetSelect.innerHTML = state.rulesets - .map((ruleset) => ``) - .join(""); -} - -export async function reloadSession(state: AppState): Promise { - 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 { - 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 { - if (!state.selectedCampaignId) { - state.selectedCampaign = null; - return; - } - - state.selectedCampaign = await getCampaign(state.selectedCampaignId); - syncSelectedCharacter(state); -} - -export async function reloadCampaignLog(state: AppState): Promise { - if (!state.selectedCampaignId) { - state.campaignLog = []; - return; - } - - state.campaignLog = await getCampaignLog(state.selectedCampaignId); -} diff --git a/RpgRoller/frontend/app/render.ts b/RpgRoller/frontend/app/render.ts deleted file mode 100644 index a121ceb..0000000 --- a/RpgRoller/frontend/app/render.ts +++ /dev/null @@ -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) => `
  • ${character.name} (${character.id})
  • `) - .join(""); - - const skills = state.selectedCampaign.skills - .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) - .join(""); - - elements.campaignDetailsElement.innerHTML = ` -

    GM: ${state.selectedCampaign.gm.displayName}

    -

    Characters visible to you: ${state.selectedCampaign.characters.length}

    -
      ${characters}
    -

    Skills visible to you: ${state.selectedCampaign.skills.length}

    -
      ${skills}
    - `; -} - -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 ``; - }) - .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) => ``) - .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 = "
  • No rolls yet.
  • "; - return; - } - - elements.campaignLogElement.innerHTML = state.campaignLog - .map((entry) => ` -
  • - ${entry.visibility.toUpperCase()} - ${entry.breakdown} - ${new Date(entry.timestampUtc).toLocaleString()} -
  • - `) - .join(""); -} - -function renderCampaignSelect(state: AppState, elements: AppElements): void { - elements.campaignSelect.innerHTML = state.campaigns - .map((campaign) => { - const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; - return ``; - }) - .join(""); -} diff --git a/RpgRoller/frontend/app/state.ts b/RpgRoller/frontend/app/state.ts deleted file mode 100644 index 25905e3..0000000 --- a/RpgRoller/frontend/app/state.ts +++ /dev/null @@ -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; -} diff --git a/RpgRoller/frontend/app/types.ts b/RpgRoller/frontend/app/types.ts deleted file mode 100644 index 0f2077f..0000000 --- a/RpgRoller/frontend/app/types.ts +++ /dev/null @@ -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; -}; diff --git a/RpgRoller/frontend/generated/api-client.ts b/RpgRoller/frontend/generated/api-client.ts deleted file mode 100644 index b731a1e..0000000 --- a/RpgRoller/frontend/generated/api-client.ts +++ /dev/null @@ -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; - skills: Array; -} - -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; - query?: Record; - 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; - -function withPathParams(pathTemplate: string, pathParams: Record): 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 { - 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: ApiOperation, options: RequestOptions): Promise { - const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); - - const headers: Record = { - "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; -} - -export async function activateCharacter(characterId: string): Promise { - return send(apiOperations.activateCharacter, { - pathParams: { characterId: characterId }, - query: undefined, - body: undefined - }); -} - -export async function createCampaign(body: CreateCampaignRequest): Promise { - return send(apiOperations.createCampaign, { - pathParams: {}, - query: undefined, - body: body - }); -} - -export async function createCharacter(body: CreateCharacterRequest): Promise { - return send(apiOperations.createCharacter, { - pathParams: {}, - query: undefined, - body: body - }); -} - -export async function createSkill(characterId: string, body: CreateSkillRequest): Promise { - return send(apiOperations.createSkill, { - pathParams: { characterId: characterId }, - query: undefined, - body: body - }); -} - -export async function getCampaign(campaignId: string): Promise { - return send(apiOperations.getCampaign, { - pathParams: { campaignId: campaignId }, - query: undefined, - body: undefined - }); -} - -export async function getCampaignLog(campaignId: string): Promise> { - return send>(apiOperations.getCampaignLog, { - pathParams: { campaignId: campaignId }, - query: undefined, - body: undefined - }); -} - -export async function getCampaigns(): Promise> { - return send>(apiOperations.getCampaigns, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function getCurrentCampaignCharacters(): Promise> { - return send>(apiOperations.getCurrentCampaignCharacters, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function getHealth(): Promise { - return send(apiOperations.getHealth, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function getMe(): Promise { - return send(apiOperations.getMe, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function getRulesets(): Promise> { - return send>(apiOperations.getRulesets, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function loginUser(body: LoginRequest): Promise { - return send(apiOperations.loginUser, { - pathParams: {}, - query: undefined, - body: body - }); -} - -export async function logoutUser(): Promise { - return send(apiOperations.logoutUser, { - pathParams: {}, - query: undefined, - body: undefined - }); -} - -export async function registerUser(body: RegisterRequest): Promise { - return send(apiOperations.registerUser, { - pathParams: {}, - query: undefined, - body: body - }); -} - -export async function rollSkill(skillId: string, body: RollSkillRequest): Promise { - return send(apiOperations.rollSkill, { - pathParams: { skillId: skillId }, - query: undefined, - body: body - }); -} - -export async function updateCharacter(characterId: string, body: UpdateCharacterRequest): Promise { - return send(apiOperations.updateCharacter, { - pathParams: { characterId: characterId }, - query: undefined, - body: body - }); -} - -export async function updateSkill(skillId: string, body: UpdateSkillRequest): Promise { - return send(apiOperations.updateSkill, { - pathParams: { skillId: skillId }, - query: undefined, - body: body - }); -} diff --git a/RpgRoller/wwwroot/app.js b/RpgRoller/wwwroot/app.js deleted file mode 100644 index eca8351..0000000 --- a/RpgRoller/wwwroot/app.js +++ /dev/null @@ -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); -} diff --git a/RpgRoller/wwwroot/app/actions.js b/RpgRoller/wwwroot/app/actions.js deleted file mode 100644 index bcc4e59..0000000 --- a/RpgRoller/wwwroot/app/actions.js +++ /dev/null @@ -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); -} diff --git a/RpgRoller/wwwroot/app/dom.js b/RpgRoller/wwwroot/app/dom.js deleted file mode 100644 index 7e6f8ae..0000000 --- a/RpgRoller/wwwroot/app/dom.js +++ /dev/null @@ -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; -} diff --git a/RpgRoller/wwwroot/app/events.js b/RpgRoller/wwwroot/app/events.js deleted file mode 100644 index ced884d..0000000 --- a/RpgRoller/wwwroot/app/events.js +++ /dev/null @@ -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; - } -} diff --git a/RpgRoller/wwwroot/app/loaders.js b/RpgRoller/wwwroot/app/loaders.js deleted file mode 100644 index a07f08a..0000000 --- a/RpgRoller/wwwroot/app/loaders.js +++ /dev/null @@ -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) => ``) - .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); -} diff --git a/RpgRoller/wwwroot/app/render.js b/RpgRoller/wwwroot/app/render.js deleted file mode 100644 index cb4a9c7..0000000 --- a/RpgRoller/wwwroot/app/render.js +++ /dev/null @@ -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) => `
  • ${character.name} (${character.id})
  • `) - .join(""); - const skills = state.selectedCampaign.skills - .map((skill) => `
  • ${skill.name} [${skill.diceRollDefinition}]
  • `) - .join(""); - elements.campaignDetailsElement.innerHTML = ` -

    GM: ${state.selectedCampaign.gm.displayName}

    -

    Characters visible to you: ${state.selectedCampaign.characters.length}

    -
      ${characters}
    -

    Skills visible to you: ${state.selectedCampaign.skills.length}

    -
      ${skills}
    - `; -} -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 ``; - }) - .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) => ``) - .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 = "
  • No rolls yet.
  • "; - return; - } - elements.campaignLogElement.innerHTML = state.campaignLog - .map((entry) => ` -
  • - ${entry.visibility.toUpperCase()} - ${entry.breakdown} - ${new Date(entry.timestampUtc).toLocaleString()} -
  • - `) - .join(""); -} -function renderCampaignSelect(state, elements) { - elements.campaignSelect.innerHTML = state.campaigns - .map((campaign) => { - const selected = campaign.id === state.selectedCampaignId ? " selected" : ""; - return ``; - }) - .join(""); -} diff --git a/RpgRoller/wwwroot/app/state.js b/RpgRoller/wwwroot/app/state.js deleted file mode 100644 index 5cf0162..0000000 --- a/RpgRoller/wwwroot/app/state.js +++ /dev/null @@ -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; -} diff --git a/RpgRoller/wwwroot/app/types.js b/RpgRoller/wwwroot/app/types.js deleted file mode 100644 index cb0ff5c..0000000 --- a/RpgRoller/wwwroot/app/types.js +++ /dev/null @@ -1 +0,0 @@ -export {}; diff --git a/RpgRoller/wwwroot/generated/api-client.js b/RpgRoller/wwwroot/generated/api-client.js deleted file mode 100644 index b83f2dd..0000000 --- a/RpgRoller/wwwroot/generated/api-client.js +++ /dev/null @@ -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 - }); -} diff --git a/RpgRoller/wwwroot/index.html b/RpgRoller/wwwroot/index.html deleted file mode 100644 index 8fcb494..0000000 --- a/RpgRoller/wwwroot/index.html +++ /dev/null @@ -1,91 +0,0 @@ - - - - - - RpgRoller - - - -
    -

    RpgRoller

    -

    Checking API status...

    -

    - -
    -

    Authentication

    -
    - - - - -
    -
    - - - -
    - -
    - -
    -

    Campaigns

    -
    - - - -
    -
    - - -
    -

    -
    - -
    -

    Characters

    -
    - - -
    -
    - - -
    -
    - -
    -

    Skills

    -
    - - -
    - - -
    -
    -
    - - - -
    -

    No roll yet.

    -
    - -
    -

    Campaign Details

    -
    -
    - -
    -

    Campaign Log

    -
      -
      -
      - - - - diff --git a/TECH.md b/TECH.md index de8c45d..ef82f59 100644 --- a/TECH.md +++ b/TECH.md @@ -3,20 +3,17 @@ ## 0) Current scaffold status - Root solution: `RpgRoller.sln` -- Backend/full-stack project: `RpgRoller` (Minimal API + static `wwwroot` frontend) -- Frontend source: `RpgRoller/frontend` (TypeScript) -- Frontend module split: `RpgRoller/frontend/app/*` (dom/state/loaders/render/events/actions) +- Backend/full-stack project: `RpgRoller` (Minimal API + Blazor frontend host) +- Frontend source: `RpgRoller/Components/*` + `RpgRoller/wwwroot/*` - Test project: `RpgRoller.Tests` (xUnit + `WebApplicationFactory` integration tests) - Test file split: concern-based API tests (`RpgRoller.Tests/Api/*`), service tests (`RpgRoller.Tests/Services/*`), and shared helpers (`RpgRoller.Tests/Support/*`) - Persistence: EF Core + SQLite (`RpgRoller/Data/RpgRollerDbContext.cs`) with in-memory runtime cache in `GameService` - OpenAPI source: `openapi/RpgRoller.json` -- Generated client source: `RpgRoller/frontend/generated/api-client.ts` -- Generated client output: `RpgRoller/wwwroot/generated/api-client.js` - Local CI parity entrypoint: `scripts/ci-local.ps1` - API endpoint modules: `RpgRoller/Api/*Endpoints.cs` + shared session/auth helpers - Service boundary model: API request DTOs are mapped to explicit service method parameters before workflow execution - 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. +- Current frontend features: Blazor-based authenticated campaign workspace with live log updates and full roll workflow controls. ## 1) Stack and baseline choices @@ -24,8 +21,8 @@ - EF Core with SQLite file persistence in current project (single-node deployment). - Game state is hydrated once on startup and then served from in-memory state; writes are persisted back to SQLite after successful mutations. - Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production). -- A minimal frontend framework supporting mixing 3D graphics with 2D elements, or a modern framework-less alternative (HTMl/CSS/TypeScript). -- OpenAPI generated from backend and consumed by generated client. +- Blazor frontend host with Razor components and minimal JS interop for browser APIs. +- OpenAPI generated from backend as contract documentation. - xUnit integration-heavy test suite with isolated SQLite test databases and coverage gates. ## 2) Architecture patterns to keep @@ -94,22 +91,10 @@ This pattern is a strong baseline for low to medium scale and should be the defa ### 2.6 Frontend architecture -- modules split by concern: - - API wrapper - - Data loaders - - UI composition - - Feature-specific renderers/handlers - - Shared utils and runtime dependency injection -- Single runtime state object with deliberate clear/reset logic. -- Refresh scheduler: - - Serialized refreshes (no overlap) - - Adaptive polling backoff - - SSE-triggered immediate refresh for state mutations - - Visibility-aware refresh suppression -- API client is generated from OpenAPI operation ids, not handwritten endpoints. -- Internationalization: - - translation file validation at startup - - language-specific FAQ markdown loading with fallback to default language +- Blazor component tree rooted in `Components/App.razor` and `Components/Pages/Home.razor(.cs)`. +- Browser API calls and SSE are handled via `wwwroot/js/rpgroller-api.js` interop. +- UI state is maintained server-side per circuit with session/tab persistence for campaign + screen selection. +- SSE-driven campaign refresh with reconnect backoff and explicit offline/manual-refresh fallback. ### 2.7 Testing strategy patterns @@ -127,8 +112,7 @@ This pattern is a strong baseline for low to medium scale and should be the defa ### 2.8 Tooling and contract discipline - OpenAPI generated during build (`openapi/RpgRoller.json`). -- Client generated from OpenAPI with required operation-id checks. -- Separate lint + format + tests + coverage threshold checks. +- Separate build + tests + coverage threshold checks. - Build configured with warnings as errors in CI/local script. ## 3) Concrete feature set @@ -158,7 +142,6 @@ Use this as a reusable "starter scope menu" for the new app: - trusted proxy/host settings explicit - Contract: - OpenAPI generation enabled in build - - generated client wired into frontend - operation-id stability tested - Data integrity: - enforce critical invariants both app-side and DB-side @@ -179,7 +162,6 @@ Keep: - Shared service result abstraction. - Explicit middleware order. - SSE + ETag state sync. -- Generated API client from OpenAPI. - DB-enforced invariants. - Regression tests for security-sensitive UI rendering. diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e5de1db..0000000 --- a/package-lock.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "name": "rpgroller", - "version": "0.1.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "rpgroller", - "version": "0.1.0", - "devDependencies": { - "typescript": "5.9.3" - } - }, - "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 05fa218..0000000 --- a/package.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "name": "rpgroller", - "private": true, - "version": "0.1.0", - "type": "module", - "scripts": { - "build:frontend": "tsc -p ./tsconfig.frontend.json", - "generate:api-client": "node ./scripts/generate-api-client.mjs && npm run build:frontend", - "lint": "tsc -p ./tsconfig.frontend.json --noEmit && node ./scripts/lint-frontend.mjs", - "format:check": "node ./scripts/format-check.mjs" - }, - "devDependencies": { - "typescript": "5.9.3" - } -} diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1 index e16be35..7b353ee 100644 --- a/scripts/ci-local.ps1 +++ b/scripts/ci-local.ps1 @@ -1,5 +1,4 @@ param( - [switch]$SkipNpmInstall, [switch]$SkipDotnetRestore, [switch]$SkipBuild ) @@ -25,12 +24,6 @@ $repoRoot = Split-Path -Parent $scriptDir Push-Location $repoRoot try { - if (-not $SkipNpmInstall) { - Invoke-Step -Name "Install frontend tooling (npm install)" -Action { - npm install - } - } - if (-not $SkipDotnetRestore) { Invoke-Step -Name "Restore .NET solution" -Action { dotnet restore RpgRoller.sln @@ -43,18 +36,6 @@ try { } } - Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action { - npm run generate:api-client - } - - Invoke-Step -Name "Lint frontend" -Action { - npm run lint - } - - Invoke-Step -Name "Check frontend formatting" -Action { - npm run format:check - } - Invoke-Step -Name "Run tests" -Action { if ($SkipBuild) { dotnet test RpgRoller.Tests/RpgRoller.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings RpgRoller.Tests/coverlet.runsettings diff --git a/scripts/deploy-ftp.ps1 b/scripts/deploy-ftp.ps1 index fbc3014..93452d3 100644 --- a/scripts/deploy-ftp.ps1 +++ b/scripts/deploy-ftp.ps1 @@ -54,91 +54,6 @@ function Resolve-ProfilePath { return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded)) } -function Normalize-BasePath { - param([string]$Value) - - if ([string]::IsNullOrWhiteSpace($Value)) { - return "" - } - - $normalized = $Value.Trim() - if (-not $normalized.StartsWith("/")) { - $normalized = "/$normalized" - } - - if ($normalized.Length -gt 1) { - $normalized = $normalized.TrimEnd("/") - } - - return $normalized -} - -function Infer-BasePathFromRemoteDir { - param([string]$RemoteDir) - - if ([string]::IsNullOrWhiteSpace($RemoteDir)) { - return "" - } - - $segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) - if ($segments.Count -eq 0) { - return "" - } - - $candidate = $segments[$segments.Count - 1] - if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) { - return "" - } - - return Normalize-BasePath $candidate -} - -function Resolve-AppBasePath { - param([Parameter(Mandatory = $true)][hashtable]$Config) - - if ($Config.ContainsKey("BasePath")) { - $configured = Normalize-BasePath ([string]$Config.BasePath) - if (-not [string]::IsNullOrWhiteSpace($configured)) { - return $configured - } - } - - return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir) -} - -function Set-FrontendAppBaseMeta { - param( - [Parameter(Mandatory = $true)][string]$PublishDir, - [Parameter(Mandatory = $true)][string]$BasePath - ) - - $candidatePaths = @( - (Join-Path $PublishDir "wwwroot\index.html"), - (Join-Path $PublishDir "index.html") - ) - - $indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1 - if ([string]::IsNullOrWhiteSpace($indexPath)) { - throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")." - } - - $pattern = '' - $content = Get-Content -Path $indexPath -Raw - if ($content -notmatch $pattern) { - throw "Could not find in '$indexPath'." - } - - $replacement = "" - $updated = [System.Text.RegularExpressions.Regex]::Replace( - $content, - $pattern, - [System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement }, - 1 - ) - - Set-Content -Path $indexPath -Value $updated -Encoding UTF8 -} - function Read-PlainOrPrompt { param( [string]$Value, @@ -259,9 +174,7 @@ if (-not $selfContained) { } dotnet @publishArgs -$appBasePath = Resolve-AppBasePath -Config $config -Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath -Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan +Write-Host "2) Skipping legacy index.html app-base rewrite (Blazor frontend)." -ForegroundColor Cyan if ($recycleAppPool) { Require-ConfigValue $config "AppPoolName" diff --git a/scripts/format-check.mjs b/scripts/format-check.mjs deleted file mode 100644 index 7854a88..0000000 --- a/scripts/format-check.mjs +++ /dev/null @@ -1,70 +0,0 @@ -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDirectory, ".."); - -const directoriesToScan = [ - path.join(repoRoot, "RpgRoller", "frontend"), - path.join(repoRoot, "RpgRoller", "wwwroot"), - path.join(repoRoot, "openapi") -]; - -const filesToScan = [ - path.join(repoRoot, "package.json"), - path.join(repoRoot, "tsconfig.frontend.json"), - path.join(repoRoot, "scripts", "generate-api-client.mjs"), - path.join(repoRoot, "scripts", "lint-frontend.mjs"), - path.join(repoRoot, "scripts", "format-check.mjs") -]; - -async function collectFiles(directory) { - const entries = await readdir(directory, { withFileTypes: true }); - const results = []; - for (const entry of entries) { - const fullPath = path.join(directory, entry.name); - if (entry.isDirectory()) { - const children = await collectFiles(fullPath); - results.push(...children); - } - else { - results.push(fullPath); - } - } - - return results; -} - -const allFiles = [...filesToScan]; -for (const directory of directoriesToScan) { - const directoryFiles = await collectFiles(directory); - allFiles.push(...directoryFiles); -} - -const failures = []; -for (const filePath of allFiles) { - const text = await readFile(filePath, "utf8"); - const relativePath = path.relative(repoRoot, filePath); - const lines = text.split(/\r?\n/); - - for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) { - if (/[ \t]+$/.test(lines[lineNumber])) { - failures.push(`${relativePath}:${lineNumber + 1} has trailing whitespace.`); - } - } - - if (text.includes("\t")) { - failures.push(`${relativePath} contains tab characters.`); - } - - if (text.length > 0 && !text.endsWith("\n") && !text.endsWith("\r\n")) { - failures.push(`${relativePath} is missing a trailing newline.`); - } -} - -if (failures.length > 0) { - throw new Error(failures.join("\n")); -} - -console.log("Frontend format checks passed."); diff --git a/scripts/generate-api-client.mjs b/scripts/generate-api-client.mjs deleted file mode 100644 index d099571..0000000 --- a/scripts/generate-api-client.mjs +++ /dev/null @@ -1,360 +0,0 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDirectory, ".."); -const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json"); -const outputPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts"); - -function pascalCase(value) { - return value - .replace(/[^a-zA-Z0-9]+/g, " ") - .trim() - .split(/\s+/) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(""); -} - -function escapePathSegment(segment) { - return segment.replaceAll("\\", "\\\\").replaceAll("\"", "\\\""); -} - -function mapSimpleType(schema) { - switch (schema?.type) { - case "integer": - case "number": - return "number"; - case "boolean": - return "boolean"; - case "string": - return "string"; - default: - return null; - } -} - -function schemaRefName(schema) { - if (typeof schema?.$ref !== "string") { - return null; - } - - const refPrefix = "#/components/schemas/"; - if (!schema.$ref.startsWith(refPrefix)) { - throw new Error(`Unsupported schema ref: ${schema.$ref}`); - } - - return schema.$ref.substring(refPrefix.length); -} - -function toTypeScriptType(schema, forProperty = false) { - if (!schema || typeof schema !== "object") { - return "unknown"; - } - - const refName = schemaRefName(schema); - if (refName !== null) { - return refName; - } - - const simpleType = mapSimpleType(schema); - if (simpleType !== null) { - return simpleType; - } - - if (schema.type === "array") { - const itemType = toTypeScriptType(schema.items, true); - return `Array<${itemType}>`; - } - - if (schema.type === "object") { - const propertyEntries = Object.entries(schema.properties ?? {}); - if (propertyEntries.length === 0) { - return "Record"; - } - - const required = new Set(Array.isArray(schema.required) ? schema.required : []); - const fields = propertyEntries.map(([name, propertySchema]) => { - const typeName = toTypeScriptType(propertySchema, true); - const optional = required.has(name) ? "" : "?"; - return `${name}${optional}: ${typeName};`; - }); - - if (forProperty) { - return `{ ${fields.join(" ")} }`; - } - - return `{\n${fields.map((field) => ` ${field}`).join("\n")}\n}`; - } - - if (Array.isArray(schema.oneOf) && schema.oneOf.length > 0) { - return schema.oneOf.map((option) => toTypeScriptType(option, true)).join(" | "); - } - - if (Array.isArray(schema.anyOf) && schema.anyOf.length > 0) { - return schema.anyOf.map((option) => toTypeScriptType(option, true)).join(" | "); - } - - return "unknown"; -} - -function schemaToInterface(name, schema) { - const required = new Set(Array.isArray(schema.required) ? schema.required : []); - const fields = Object.entries(schema.properties ?? {}).map(([propertyName, propertySchema]) => { - const typeName = toTypeScriptType(propertySchema, true); - const optional = required.has(propertyName) ? "" : "?"; - return ` ${propertyName}${optional}: ${typeName};`; - }); - - if (fields.length === 0) { - return `export type ${name} = Record;`; - } - - return `export interface ${name} {\n${fields.join("\n")}\n}`; -} - -function collectParameters(pathItem, operation) { - const rawParameters = [...(pathItem.parameters ?? []), ...(operation.parameters ?? [])]; - const deduped = []; - const keys = new Set(); - - for (const parameter of rawParameters) { - if (!parameter || typeof parameter !== "object" || typeof parameter.name !== "string") { - continue; - } - - 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}`); - } - - 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({ - operationId: operation.operationId, - method: method.toUpperCase(), - path: pathKey, - pathParameters, - queryParameters, - requestBody, - response - }); - } - } - - if (operations.length === 0) { - throw new Error("OpenAPI document does not define any operations."); - } - - return operations.sort((left, right) => left.operationId.localeCompare(right.operationId)); -} - -function buildSchemaTypes(document) { - const schemas = document.components?.schemas ?? {}; - const schemaEntries = Object.entries(schemas).sort(([left], [right]) => left.localeCompare(right)); - - const declarations = schemaEntries.map(([name, schema]) => { - if (!schema || typeof schema !== "object") { - return `export type ${name} = unknown;`; - } - - return schemaToInterface(name, schema); - }); - - 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) { - const operationEntries = operations - .map((operation) => ` ${operation.operationId}: { method: "${operation.method}", path: "${escapePathSegment(operation.path)}", expectsJson: ${operation.response.expectsJson ? "true" : "false"} }`) - .join(",\n"); - - const helper = ` -type ApiOperation = { - method: string; - path: string; - expectsJson: boolean; -}; - -type RequestOptions = { - pathParams?: Record; - query?: Record; - body?: unknown; -}; - -export const apiOperations = { -${operationEntries} -} as const satisfies Record; -`.trim(); - - const sendFunction = ` -function withPathParams(pathTemplate: string, pathParams: Record): 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 { - 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: ApiOperation, options: RequestOptions): Promise { - const resolvedPath = withQuery(withPathParams(operation.path, options.pathParams ?? {}), options.query ?? {}); - - const headers: Record = { - "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; -} -`.trim(); - - const exports = operations.map(buildFunctionSource).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`; -} - -const openApiText = await readFile(openApiPath, "utf8"); -const document = JSON.parse(openApiText); -const operations = collectOperations(document); -const schemaTypes = buildSchemaTypes(document); -const clientSource = buildClientSource(operations, schemaTypes); - -await mkdir(path.dirname(outputPath), { recursive: true }); -await writeFile(outputPath, clientSource, "utf8"); -console.log(`Generated API client: ${path.relative(repoRoot, outputPath)}`); diff --git a/scripts/lint-frontend.mjs b/scripts/lint-frontend.mjs deleted file mode 100644 index 4a1321a..0000000 --- a/scripts/lint-frontend.mjs +++ /dev/null @@ -1,41 +0,0 @@ -import { readFile } from "node:fs/promises"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; - -const scriptDirectory = path.dirname(fileURLToPath(import.meta.url)); -const repoRoot = path.resolve(scriptDirectory, ".."); -const openApiPath = path.join(repoRoot, "openapi", "RpgRoller.json"); -const appTsPath = path.join(repoRoot, "RpgRoller", "frontend", "app.ts"); -const generatedClientPath = path.join(repoRoot, "RpgRoller", "frontend", "generated", "api-client.ts"); - -const openApi = JSON.parse(await readFile(openApiPath, "utf8")); -const generatedClient = await readFile(generatedClientPath, "utf8"); -const appSource = await readFile(appTsPath, "utf8"); -const errors = []; - -if (!appSource.includes("from \"./generated/api-client.js\"")) { - errors.push("Frontend app.ts must import the generated api-client module."); -} - -for (const [pathKey, pathItem] of Object.entries(openApi.paths ?? {})) { - for (const [method, operation] of Object.entries(pathItem ?? {})) { - if (operation === null || typeof operation !== "object") { - continue; - } - - if (typeof operation.operationId !== "string" || operation.operationId.length === 0) { - errors.push(`Missing operationId for ${method.toUpperCase()} ${pathKey}`); - continue; - } - - if (!generatedClient.includes(`apiOperations.${operation.operationId}`)) { - errors.push(`Generated client is missing operation export for ${operation.operationId}`); - } - } -} - -if (errors.length > 0) { - throw new Error(errors.join("\n")); -} - -console.log("Frontend lint checks passed."); diff --git a/tsconfig.frontend.json b/tsconfig.frontend.json deleted file mode 100644 index 6b32790..0000000 --- a/tsconfig.frontend.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "Bundler", - "lib": [ - "ES2022", - "DOM", - "DOM.Iterable" - ], - "strict": true, - "noEmitOnError": true, - "rootDir": "./RpgRoller/frontend", - "outDir": "./RpgRoller/wwwroot", - "newLine": "lf" - }, - "include": [ - "RpgRoller/frontend/**/*.ts" - ] -}