diff --git a/.idea/ncc-gen.iml b/.idea/ncc-gen.iml index 24643cc..16facd0 100644 --- a/.idea/ncc-gen.iml +++ b/.idea/ncc-gen.iml @@ -5,6 +5,9 @@ + + + diff --git a/package-lock.json b/package-lock.json index bac1c25..f379db0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,9 @@ "escape-html": "^1.0.3", "itty-router": "^4.0.26", "markdown-escape": "^2.0.0", + "preact": "^10.19.6", + "preact-iso": "^2.4.0", + "preact-render-to-string": "^6.4.0", "slash-create": "^6.0.2", "slug": "^8.2.3" }, @@ -32,15 +35,12 @@ "@types/less": "^3.0.6", "@types/markdown-escape": "^1.1.3", "@types/slug": "^5.0.7", - "babel": "^6.23.0", - "camelcase": "^8.0.0", + "change-case": "^5.4.2", "clean-css": "^5.3.3", "fast-deep-equal": "^3.1.3", "less": "^4.2.0", "rollup": "^4.9.5", "rollup-plugin-ts": "^3.4.5", - "source-map": "^0.7.4", - "terser": "^5.27.0", "tsx": "^4.7.0", "typescript": "^5.0.4", "wrangler": "^3.0.0" @@ -2910,18 +2910,6 @@ "printable-characters": "^1.0.42" } }, - "node_modules/babel": { - "version": "6.23.0", - "resolved": "https://registry.npmjs.org/babel/-/babel-6.23.0.tgz", - "integrity": "sha512-ZDcCaI8Vlct8PJ3DvmyqUz+5X2Ylz3ZuuItBe/74yXosk2dwyVo/aN7MCJ8HJzhnnJ+6yP4o+lDgG9MBe91DLA==", - "deprecated": "In 6.x, the babel package has been deprecated in favor of babel-cli. Check https://opencollective.com/babel to support the Babel maintainers", - "dev": true, - "bin": { - "babel": "lib/cli.js", - "babel-external-helpers": "lib/cli.js", - "babel-node": "lib/cli.js" - } - }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", @@ -3139,18 +3127,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/camelcase": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-8.0.0.tgz", - "integrity": "sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==", - "dev": true, - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001578", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001578.tgz", @@ -3204,6 +3180,12 @@ "node": ">=0.8.0" } }, + "node_modules/change-case": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-5.4.2.tgz", + "integrity": "sha512-WB3UiTDpT+vrTilAWaJS4gaIH/jc1He4H9f6erQvraUYas90uWT0JOYFkG1imdNv710XJ6gJvqynrgOHc4ihDA==", + "dev": true + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -4149,6 +4131,45 @@ "node": ">=6" } }, + "node_modules/preact": { + "version": "10.19.6", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.19.6.tgz", + "integrity": "sha512-gympg+T2Z1fG1unB8NH29yHJwnEaCH37Z32diPDku316OTnRPeMbiRV9kTrfZpocXjdfnWuFUl/Mj4BHaf6gnw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-iso": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/preact-iso/-/preact-iso-2.4.0.tgz", + "integrity": "sha512-m/xT9oTvNRZ4K/C05Ha91HdT8OPZCjf6Rs4y9arTALYYb1O1qfmygaJHXzkXFVkpDItHnpXMcisiwLn7CsZoLg==", + "peerDependencies": { + "preact": ">=10", + "preact-render-to-string": ">=5" + }, + "peerDependenciesMeta": { + "preact-render-to-string": { + "optional": true + } + } + }, + "node_modules/preact-render-to-string": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.4.0.tgz", + "integrity": "sha512-pzDwezZaLbK371OiJjXDsZJwVOALzFX5M1wEh2Kr0pEApq5AV6bRH/DFbA/zNA7Lck/duyREPQLLvzu2G6hEQQ==", + "dependencies": { + "pretty-format": "^3.8.0" + }, + "peerDependencies": { + "preact": ">=10" + } + }, + "node_modules/pretty-format": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-3.8.0.tgz", + "integrity": "sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==" + }, "node_modules/printable-characters": { "version": "1.0.42", "resolved": "https://registry.npmjs.org/printable-characters/-/printable-characters-1.0.42.tgz", @@ -4534,15 +4555,6 @@ "integrity": "sha512-9LK+E7Hv5R9u4g4C3p+jjLstaLe11MDsL21UpYaCNmapvMkYhqCV4A/f/3gyH8QjMyh6l68q9xC85vihY9ahMQ==", "dev": true }, - "node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", - "dev": true, - "engines": { - "node": ">= 8" - } - }, "node_modules/source-map-support": { "version": "0.5.21", "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", diff --git a/package.json b/package.json index 8e0acf8..2b3ea98 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "@types/less": "^3.0.6", "@types/markdown-escape": "^1.1.3", "@types/slug": "^5.0.7", - "camelcase": "^8.0.0", + "change-case": "^5.4.2", "clean-css": "^5.3.3", "fast-deep-equal": "^3.1.3", "less": "^4.2.0", @@ -41,6 +41,9 @@ "escape-html": "^1.0.3", "itty-router": "^4.0.26", "markdown-escape": "^2.0.0", + "preact": "^10.19.6", + "preact-iso": "^2.4.0", + "preact-render-to-string": "^6.4.0", "slash-create": "^6.0.2", "slug": "^8.2.3" } diff --git a/src/build/bundler.ts b/src/build/bundler.ts index 197b372..aa25d99 100644 --- a/src/build/bundler.ts +++ b/src/build/bundler.ts @@ -13,7 +13,7 @@ import typescriptModule from 'typescript'; import { readFile, writeFile, readdir } from 'node:fs/promises'; import { basename, dirname, join, normalize } from 'node:path'; import {createHash} from 'node:crypto'; -import camelcase from 'camelcase'; +import {camelCase} from 'change-case'; import { render as renderLess } from 'less'; import CleanCSS from 'clean-css'; import type { @@ -124,7 +124,7 @@ async function processTypescript(atPath: string, inDir: string, cache?: RollupCa ] }) const {output: [chunk]} = await build.generate({ - name: camelcase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))), + name: camelCase(basename(atPath.substring(0, atPath.length - TS_SUFFIX.length))), sourcemap: 'hidden', sourcemapFile: join(inDir, 'sourcemap.map'), format: 'iife', @@ -160,11 +160,11 @@ export async function getBundle(inDir: string): Promise<{ css: Map { + res.addSelectionListener((ev) => { + gen.setActiveResult(ev.detail, true) + }) + gen.addRerollListener((ev) => { + for (const result of ev.detail.changedResults) { + res.setActiveElementForTable(result) + } + }) + console.info("connected generator and response list") +}).catch((e) => { + console.error(e) +}) function updateHash(): void { if (location.hash === "" || location.hash === "#" || !location.hash) { diff --git a/src/client/generator-entrypoint.less b/src/client/generator-entrypoint.less index cc5568a..5fd00c8 100644 --- a/src/client/generator-entrypoint.less +++ b/src/client/generator-entrypoint.less @@ -1,5 +1,4 @@ @import "basic-look"; -@import "attribution"; @import "popup"; @import "pulse"; @@ -26,74 +25,10 @@ #generatedScenario { } -.generatedHead { - user-select: text; - margin: 0.5rem 0 0 0; - display: flex; - flex-flow: row nowrap; -} - -.generatedHead .generatedLabel span { - display: inline; - user-select: text; -} - -.generatedLabel { - flex: 1 1 auto; - display: inline-flex; - flex-flow: row nowrap; - align-items: center; - justify-content: left; - cursor: pointer; - padding-right: 0.2rem; - user-select: text; -} - -.generated { - margin: 0; - padding: 0; - appearance: none; - font: inherit; - outline: 0; - border: 0; -} - -.generatedSelect { - flex: 0 0 auto; - appearance: none; - cursor: pointer; - font-size: 1.5rem; - margin: 0; - transition: filter 0.3s ease, transform 0.3s ease; - width: 2rem; - height: 2rem; - text-align: center; - line-height: 2rem; - border-radius: 1rem; -} - #generator .buttons { margin-left: -0.3rem; } -.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus { - filter: brightness(120%) saturate(80%); - transform: scale(120%); -} - -.generatedHead .generatedSelect:active { - filter: brightness(80%) saturate(110%); - transform: scale(80%); -} - -.generatedSelect::after { - content: '🔒' -} - -.generatedSelect:checked::after { - content: '🎲'; -} - #copyButtons::before { content: "Copy as:"; margin: 0.2rem 0 0 0.3rem diff --git a/src/client/generator-entrypoint.ts b/src/client/generator-entrypoint.ts index 1108a77..36b1309 100644 --- a/src/client/generator-entrypoint.ts +++ b/src/client/generator-entrypoint.ts @@ -2,25 +2,31 @@ import { ExportFormat, exportScenario, type GeneratedState, - generatedStateToString, getResultFrom, + generatedStateToString, + getResultFrom, RolledValues, + rollOn, RollSelections, type RollTable, RollTableDatabase, - type RollTableResult + type RollTableDetailsAndResults, + type RollTableResult, + type RollTableResultFull } from '../common/rolltable'; -import { - buildGeneratedElement, copyBBID, copyEmojiTextID, - copyMDID, copyTextID, - htmlTableIdentifier, rerollAllId, rerollId, - selectAllId, - selectedIdPrefix, - selectNoneId -} from '../common/template'; +import { buildGenerated, htmlTableIdentifier } from '../common/template'; import { DOMLoaded } from './onload'; import { scrapeGeneratedScenario } from './scraper'; import { showPopup } from './popup'; import { pulseElement } from './pulse'; +import { DOMTemplateBuilder } from './template'; +import escapeHTML from 'escape-html'; + +export interface RerollEventDetail { + rerolledAll: boolean + changedResults: Iterable> + fullResults: ReadonlyMap + selections: ReadonlySet +} export class Generator { readonly generator: HTMLElement; @@ -44,11 +50,10 @@ export class Generator { } selectAll(): this { - this.selected.clear(); - for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable) { + for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable) { check.checked = true; - pulseElement(check); - const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); + check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) + const table = this.getTableWithHtmlId(check.id, 'selected-'); if (table) { this.selected.add(table); } @@ -58,10 +63,47 @@ export class Generator { selectNone(): this { this.selected.clear(); - for (const check of this.scenario.querySelectorAll('input[type=checkbox]') as Iterable) { + for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable) { check.checked = false; - pulseElement(check); + check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) + } + return this + } + + reroll(all: boolean): this { + if (!this.db) { + return this + } + const changes: RollTableResultFull[] = [] + for (const row of this.scenario.querySelectorAll(`.generatedElement`)) { + const check = row.querySelector(`input.generatedSelect:checked`) + const text = row.querySelector(`.resultText`) + if ((all || check) && text) { + let result = this.db.mappings.get(parseInt(text.dataset["mappingid"] ?? '-1')) + if (!result || result.table.resultsById.size === 1) { + continue + } + const origResult = result + const table = result.table + while (result === origResult) { + result = rollOn(table) + } + this.setActiveResult(result, all) + changes.push(result) + pulseElement(text) + } } + this.generator.dispatchEvent(new CustomEvent("reroll", { + composed: false, + bubbles: true, + cancelable: false, + detail: { + rerolledAll: all, + changedResults: changes, + fullResults: this.rolled, + selections: this.selected, + } + })) return this } @@ -83,10 +125,31 @@ export class Generator { return this } - attachHandlers(): this { - this.generator.addEventListener('click', (e) => this.clickHandler(e)); - this.generator.addEventListener('change', (e) => this.changeHandler(e)); - return this; + private getGeneratedElementForTable(table: RollTable): HTMLElement|null { + return this.scenario.querySelector(`#generated-${escapeHTML(htmlTableIdentifier(table))}`) + } + + private replaceResultInElement(result: RollTableResult, generatedElement: HTMLElement) { + // sister function is buildGeneratedElement + const generatedDiv = generatedElement.querySelector(`.generated`) + if (!generatedDiv) { + throw Error(`couldn't find .generated in replaceResultInElement`) + } + generatedDiv.replaceWith(buildGenerated({result, includesResponses: !!this.db, builder: DOMTemplateBuilder})) + const button = generatedElement.querySelector(`.resultText`)! + pulseElement(button) + this.rolled.add(result) + } + + private changeSelection(generatedElement: HTMLElement, selected: boolean) { + const check = generatedElement.querySelector(`.generatedSelect`) + if (!check) { + return + } + if (check.checked !== selected) { + check.checked = selected + check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) + } } async copy(format: ExportFormat): Promise { @@ -94,16 +157,23 @@ export class Generator { return navigator.clipboard.writeText(exported) } + attachHandlers(): this { + this.generator.addEventListener('click', (e) => this.clickHandler(e)); + this.generator.addEventListener('change', (e) => this.changeHandler(e)); + this.generator.querySelector(`#reroll`)!.disabled = (this.selected.size === 0) + return this; + } + private clickHandler(e: Event): void { if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) { switch (e.target.id) { - case selectNoneId: + case "selectNone": this.selectNone() break - case selectAllId: + case "selectAll": this.selectAll() break - case copyMDID: + case "copyMD": this.copy(ExportFormat.Markdown) .then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success')) .catch((e) => { @@ -111,7 +181,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error') }) break - case copyBBID: + case "copyBB": this.copy(ExportFormat.BBCode) .then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success')) .catch((e) => { @@ -119,7 +189,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error') }) break - case copyEmojiTextID: + case "copyEmojiText": this.copy(ExportFormat.TextEmoji) .then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success')) .catch((e) => { @@ -127,7 +197,7 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error') }) break - case copyTextID: + case "copyText": this.copy(ExportFormat.TextOnly) .then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success')) .catch((e) => { @@ -135,70 +205,35 @@ export class Generator { showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error') }) break - case rerollId: - for (const row of this.scenario.querySelectorAll(".generatedElement")) { - if (row.querySelector("input[type=checkbox]:checked")) { - const text = row.querySelector(".resultText") - if (text) { - pulseElement(text) - } - } - } - showPopup(this.rollButtons, `only pretending to reroll`, 'warning') + case "reroll": + this.reroll(false) break - case rerollAllId: - for (const row of this.scenario.querySelectorAll(".generatedElement")) { - const check = row.querySelector("input[type=checkbox]:checked") - if (check) { - check.checked = false - pulseElement(check) - } - const text = row.querySelector(".resultText") - if (text) { - pulseElement(text) - } - } - showPopup(this.rollButtons, `only pretending to reroll all`, 'warning') + case "rerollAll": + this.reroll(true) break default: - if (e.target.classList.contains("resultText")) { - for (let target: HTMLElement|null = e.target; target && target !== this.generator; target = target.parentElement) { - if (target.classList.contains("generatedElement")) { - const check = target.querySelector(".generatedSelect") - if (check) { - check.click() - } - } - } - } else { - return - } + return } e.preventDefault() } } private changeHandler(e: Event): void { - if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith(selectedIdPrefix)) { + if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith('selected-')) { const check = e.target - const table = this.getTableWithHtmlId(check.id, selectedIdPrefix); + const table = this.getTableWithHtmlId(check.id, 'selected-'); if (table) { if (check.checked) { this.selected.add(table); } else { this.selected.delete(table); } + this.generator.querySelector(`#reroll`)!.disabled = (this.selected.size === 0) pulseElement(check) } } } - private animationendHandler(e: AnimationEvent): void { - if (e.animationName === "pulse" && e.target instanceof HTMLElement && e.target.classList.contains("pulse")) { - e.target.classList.remove("pulse") - } - } - constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) { this.generator = generator; this.scenario = generatorForm; @@ -206,6 +241,21 @@ export class Generator { this.rollButtons = rollButtons; this.db = db; } + + setActiveResult(result: RollTableResultFull, clearSelection?: boolean) { + const tableElement = this.getGeneratedElementForTable(result.table) + if (!tableElement) { + return + } + this.replaceResultInElement(result, tableElement) + if (clearSelection) { + this.changeSelection(tableElement, false) + } + } + + addRerollListener(listener: (ev: CustomEvent) => void, options?: boolean|EventListenerOptions): void { + this.generator.addEventListener('reroll', listener, options) + } } function initGenerator(db?: RollTableDatabase): Generator { @@ -223,7 +273,7 @@ function initGenerator(db?: RollTableDatabase): Generator { } const rollButtons = document.getElementById("rollButtons") if (!rollButtons) { - throw Error('copy buttons were not found') + throw Error('roll buttons were not found') } return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers(); } @@ -240,5 +290,5 @@ export async function prepareGenerator(db?: Promise): Promise } DOMLoaded.then(() => pendingGenerator ?? prepareGenerator()) - .then(g => console.info(`loaded generator: ${generatedStateToString(g.state)}`)) + .then(g => console.info(`loaded generator:\n${generatedStateToString(g.state)}`)) .catch(e => console.error('failed to load generator', e)) diff --git a/src/client/popup.ts b/src/client/popup.ts index 28d44b6..026a411 100644 --- a/src/client/popup.ts +++ b/src/client/popup.ts @@ -1,6 +1,6 @@ export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void { if (!parent.classList.contains("jsPopupHost")) { - console.log(parent, "should be jsPopupHost") + console.warn(parent, "should be jsPopupHost") } const container = parent.ownerDocument.createElement("div") container.classList.add("jsPopupContainer") diff --git a/src/client/pulse.less b/src/client/pulse.less index 7ceb474..e2c9229 100644 --- a/src/client/pulse.less +++ b/src/client/pulse.less @@ -1,12 +1,10 @@ @keyframes pulse-bg { from { - background-color: transparent; } 10% { background-color: #60606060; } to { - background-color: transparent; } } diff --git a/src/client/pulse.ts b/src/client/pulse.ts index e0b99e0..5ddbcfb 100644 --- a/src/client/pulse.ts +++ b/src/client/pulse.ts @@ -1,6 +1,24 @@ +function onPulseEnd(e: AnimationEvent): void { + if (e.animationName === "pulse-bg" && e.target instanceof HTMLElement) { + e.target.classList.remove("pulse") + e.target.removeEventListener("animationend", onPulseEnd) + e.target.removeEventListener("animationcancel", onPulseEnd) + } +} + export function pulseElement(element: HTMLElement) { - element.classList.add("pulse") - element.style.animation = "none"; - getComputedStyle(element).animation - setTimeout(element.style.animation = "") + element.removeEventListener("animationend", onPulseEnd) + element.removeEventListener("animationcancel", onPulseEnd) + if (element.classList.contains("pulse")) { + const anim = + element.getAnimations().find(v => v instanceof CSSAnimation && v.animationName === "pulse-bg") + if (anim) { + anim.finish() + anim.play() + } + } else { + element.classList.add("pulse") + } + element.addEventListener("animationend", onPulseEnd) + element.addEventListener("animationcancel", onPulseEnd) } diff --git a/src/client/responses-entrypoint.less b/src/client/responses-entrypoint.less index 205d147..848aa54 100644 --- a/src/client/responses-entrypoint.less +++ b/src/client/responses-entrypoint.less @@ -79,8 +79,9 @@ .response { margin-top: 0.3rem; display: flex; - align-items: baseline; + align-items: stretch; flex-flow: row nowrap; + scroll-margin-top: 12rem; } .response.active { @@ -88,14 +89,22 @@ min-height: 1.5rem; &::before { - content: "▶"; + width: 1rem; + margin: 0.2rem 0.2rem 0.2rem 0.5rem; + content: ""; flex: 0 0 auto; - font-size: 1.25rem; - margin-right: 0.4rem; - line-height: 1.5rem; + background-image: + linear-gradient(to bottom left, transparent 50%, currentColor 0), + linear-gradient(to bottom right, currentColor 50%, transparent 0); + background-size: 100% 50%; + background-repeat: no-repeat; + background-position: top, bottom; } & .resultText { font-weight: bold; } + & .attribution .button { + display: none; + } } diff --git a/src/client/responses-entrypoint.ts b/src/client/responses-entrypoint.ts index 1709fff..133e4db 100644 --- a/src/client/responses-entrypoint.ts +++ b/src/client/responses-entrypoint.ts @@ -1,16 +1,82 @@ -import type { RollTable, RollTableDatabase } from '../common/rolltable'; +import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable'; import { DOMLoaded } from './onload'; +import { scrapeResponseLists } from './scraper'; +import { htmlTableIdentifier } from '../common/template'; +import escapeHTML from 'escape-html'; -class ResponseList { +class ResponseLists { readonly db: RollTableDatabase - constructor(db: RollTableDatabase) { + readonly listsElement: HTMLElement + constructor(db: RollTableDatabase, listsElement: HTMLElement) { this.db = db + this.listsElement = listsElement + } + + addSelectionListener(listener: (e: CustomEvent>) => void, options?: boolean|EventListenerOptions): void { + this.listsElement.addEventListener("resultselected", listener, options) + } + + configureHandlers(): this { + this.listsElement.addEventListener("click", (e) => { + if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) { + const response = e.target.closest(`.response`) + if (!response) { + console.log("no response") + return + } + const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN + if (isNaN(mappingId)) { + console.log("no mapping ID") + return + } + const result = this.db.mappings.get(mappingId) + if (!result) { + console.log("no result") + return + } + const ev = new CustomEvent>("resultselected", { + bubbles: true, + cancelable: true, + detail: result + }) + if (e.target.dispatchEvent(ev)) { + this.setActiveElementForTable(result) + const button = response.querySelector(`.resultText`) as HTMLElement + if (button) { + button.focus() + } + } + } + }) + return this + } + + setActiveElementForTable(result: RollTableResultFull) { + const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`) + const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`) + if (!newActive || oldActive === newActive) { + return + } + newActive.classList.add("active") + if (!oldActive) { + return + } + oldActive.classList.remove("active") } } -function initResponseList(): ResponseList { - throw Error("not yet implemented") +function initResponseList(): ResponseLists { + const listsElement = document.querySelector(`#responseLists`) + if (!listsElement) { + throw Error(`can't find #responseLists`) + } + const lists = scrapeResponseLists(listsElement) + if (!lists) { + throw Error(`can't parse #responseLists`) + } + const {db, active} = lists + return new ResponseLists(db, listsElement).configureHandlers() } -export const responseList: Promise = DOMLoaded.then(() => initResponseList()) -export const db: Promise = responseList.then(r => r.db) +export const responseLists: Promise = DOMLoaded.then(() => initResponseList()) +export const db: Promise = responseLists.then(r => r.db) diff --git a/src/client/scraper.ts b/src/client/scraper.ts index fd504f8..db87c70 100644 --- a/src/client/scraper.ts +++ b/src/client/scraper.ts @@ -1,20 +1,26 @@ import { - type InProgressGeneratedState, RolledValues, RollSelections, + type InProgressGeneratedState, + RolledValues, + RollSelections, type RollTableAuthor, + RollTableDatabase, + type RollTableDetailsAndResults, type RollTableDetailsNoResults, type RollTableLimited, type RollTableResult, + type RollTableResultFull, type RollTableResultSet } from '../common/rolltable'; +import { authorIdKey } from '../common/template'; export function asBoolean(s: string|undefined): boolean|undefined { if (typeof s === "undefined") { return } switch (s.toLowerCase()) { - case "true": + case 'true': return true - case "false": + case 'false': return false default: return @@ -70,10 +76,10 @@ export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|und if (!author) { return null } - const id = asInteger(author.dataset["id"]) - const name = textFrom(author.querySelector(".authorName")) + const id = asInteger(author.dataset[authorIdKey]) + const name = textFrom(author.querySelector(`.authorName`)) const url = hrefFrom(author.querySelector("a[href]")) - const relation = textFrom(author.querySelector(".authorRelation")) + const relation = textFrom(author.querySelector(`.authorRelation`)) if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') { return } @@ -91,7 +97,7 @@ export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null| return null } const id = asInteger(set.dataset["id"]) - const name = textFrom(set.querySelector(".setName")) + const name = textFrom(set.querySelector(`.setName`)) const global = asBoolean(set.dataset["global"]) if (typeof id === "undefined" || typeof global === "undefined") { return @@ -144,11 +150,11 @@ export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLi if (!head) { return null } - const table = scrapeTableHeader(head.querySelector(".tableHeader")) + const table = scrapeTableHeader(head.querySelector(`.tableHeader`)) if (!table) { return } - const selected = checkedFrom(head.querySelector("input[type=checkbox].generatedSelect")) + const selected = checkedFrom(head.querySelector(`input[type=checkbox].generatedSelect`)) return { table, selected, @@ -187,10 +193,10 @@ export function scrapeGeneratedElement(generated: HTMLElement|null): {result: Ro if (!generated) { return null } - const result = scrapeResultText(generated.querySelector(".resultText")) - const author = scrapeAuthor(generated.querySelector(".author")) - const set = scrapeResultSet(generated.querySelector(".resultSet")) - const header = scrapeGeneratedHead(generated.querySelector(".generatedHead")) + const result = scrapeResultText(generated.querySelector(`.resultText`)) + const author = scrapeAuthor(generated.querySelector(`.author`)) + const set = scrapeResultSet(generated.querySelector(`.resultSet`)) + const header = scrapeGeneratedHead(generated.querySelector(`.generatedHead`)) if (!header || !result) { return } @@ -225,7 +231,7 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG } const rolls = new RolledValues() const selection = new RollSelections() - for (const item of scenario.querySelectorAll(".generatedElement")) { + for (const item of scenario.querySelectorAll(`.generatedElement`)) { const element = scrapeGeneratedElement(item) if (!element) { return @@ -242,3 +248,58 @@ export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressG selected: selection, } } + +export function scrapeResponseList(responseTypeElement: HTMLElement|null, db: RollTableDatabase): [table: RollTableDetailsAndResults, active: RollTableResultFull|null]|null|undefined { + if (!responseTypeElement) { + return null + } + const table = scrapeTableHeader(responseTypeElement.querySelector(`.tableHeader`)) + if (!table || !table.full) { + return + } + const resultTable = db.addTable(table) + let activeResult: RollTableResultFull|null = null + for (const resultElement of responseTypeElement.querySelectorAll(`.response`)) { + const partialResult = scrapeResultText(resultElement.querySelector(`.resultText`)) + const author = scrapeAuthor(resultElement.querySelector(`.author`)) + const set = scrapeResultSet(resultElement.querySelector(`.resultSet`)) + const active = resultElement.classList.contains("active") + + if (!partialResult || !partialResult.full || typeof author === "undefined" || !set) { + return + } + + const result = db.addResult({ + ...partialResult, + set, + author, + table: resultTable, + }) + if (active) { + activeResult = result + } + } + return [resultTable, activeResult] +} + +export function scrapeResponseLists(lists: HTMLElement): {db: RollTableDatabase, active: ReadonlyMap|null>}|undefined +export function scrapeResponseLists(lists: null): null +export function scrapeResponseLists(lists: HTMLElement|null): {db: RollTableDatabase, active: ReadonlyMap|null>}|null|undefined { + if (!lists) { + return null + } + const db = new RollTableDatabase() + const active = new Map|null> + for (const responseTypeElement of lists.querySelectorAll(`.responseType`)) { + const responseType = scrapeResponseList(responseTypeElement, db) + if (!responseType) { + return + } + const [table, activeResult] = responseType + active.set(table, activeResult) + } + return { + db, + active, + } +} diff --git a/src/client/template.ts b/src/client/template.ts new file mode 100644 index 0000000..cd5983c --- /dev/null +++ b/src/client/template.ts @@ -0,0 +1,142 @@ +import { + type ButtonFeatures, + ButtonType, + type CheckboxFeatures, + type ElementFeatures, + type FormFeatures, + HyperlinkDestination, + type HyperlinkFeatures, + type LabelFeatures, + type TemplateBuilder +} from '../common/template'; + +function tag(tagName: T, features: ElementFeatures, contents: Node[]): HTMLElementTagNameMap[T] { + const element = document.createElement(tagName) + if (typeof features.id !== "undefined") { + element.id = features.id + } + if (typeof features.classes !== "undefined") { + if (typeof features.classes === "string") { + element.classList.add(features.classes) + } else { + for (const className of features.classes) { + element.classList.add(className) + } + } + } + if (typeof features.data !== "undefined") { + for (const [key, value] of features.data) { + element.dataset[key] = value + } + } + for (const node of contents) { + element.appendChild(node) + } + return element +} + +class DOMTemplateBuilderImpl implements TemplateBuilder { + makeButton(features: ButtonFeatures, ...contents: Node[]): HTMLButtonElement { + const element = tag('button', features, contents) + element.classList.add("button") + element.type = features.type ?? ButtonType.Button + if (typeof features.name === "string") { + element.name = features.name + } + if (typeof features.value === "string") { + element.value = features.value + } + return element + } + + makeCheckbox(features: CheckboxFeatures, ...contents: Node[]): HTMLInputElement { + const element = tag('input', features, contents) + element.type = "checkbox" + element.name = features.name + if (features.checked) { + element.checked = true + } + if (typeof features.value === "string") { + element.value = features.value + } + return element + } + + makeDiv(features: ElementFeatures, ...contents: Node[]): HTMLDivElement { + return tag('div', features, contents); + } + + makeFooter(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('footer', features, contents); + } + + makeForm(features: FormFeatures, ...contents: Node[]): HTMLFormElement { + const element = tag('form', features, contents) + element.action = features.action + element.method = features.method + return element; + } + + makeHeader(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('header', features, contents) + } + + makeHeading1(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { + return tag('h1', features, contents); + } + + makeHeading2(features: ElementFeatures, ...contents: Node[]): HTMLHeadingElement { + return tag('h2', features, contents); + } + + makeHyperlink(features: HyperlinkFeatures, ...contents: Node[]): HTMLAnchorElement { + const element = tag('a', features, contents) + element.href = features.url + if (features.destination === HyperlinkDestination.External) { + element.rel = "external nofollow noreferrer" + } + if (features.asButton) { + element.classList.add("button") + element.draggable = false + } + return element; + } + + makeLabel(features: LabelFeatures, ...contents: Node[]): HTMLLabelElement { + const element = tag('label', features, contents) + if (typeof features.forId === "string") { + element.htmlFor = features.forId + } + return element; + } + + makeListItem(features: ElementFeatures, ...contents: Node[]): HTMLLIElement { + return tag('li', features, contents) + } + + makeNav(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('nav', features, contents) + } + + makeNoscript(features: ElementFeatures, ...contents: Node[]): HTMLElement { + return tag('noscript', features, contents); + } + + makeParagraph(features: ElementFeatures, ...contents: Node[]): HTMLParagraphElement { + return tag('p', features, contents); + } + + makeSpan(features: ElementFeatures, ...contents: Node[]): HTMLSpanElement { + return tag('span', features, contents); + } + + makeText(text: string): Text { + return document.createTextNode(text); + } + + makeUnorderedList(features: ElementFeatures, ...contents: Node[]): HTMLUListElement { + return tag('ul', features, contents); + } +} + +export const DOMTemplateBuilder = new DOMTemplateBuilderImpl() diff --git a/src/client/tsconfig.json b/src/client/tsconfig.json index cac7504..2c45b00 100644 --- a/src/client/tsconfig.json +++ b/src/client/tsconfig.json @@ -31,7 +31,7 @@ // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ - // "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */ + "typeRoots": ["./types"], /* Specify multiple folders that act like `./node_modules/@types`. */ // "types": ["@cloudflare/workers-types/2023-07-01"] /* Specify type package names to be included without being referenced in a source file. */, // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ // "resolveJsonModule": true /* Enable importing .json files */, @@ -97,6 +97,12 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } } } diff --git a/src/client/types/customevent.d.ts b/src/client/types/customevent.d.ts new file mode 100644 index 0000000..4c2c642 --- /dev/null +++ b/src/client/types/customevent.d.ts @@ -0,0 +1,16 @@ +import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable'; +import type { RerollEventDetail } from '../generator-entrypoint'; + +interface CustomEventMap { + "resultselected": CustomEvent>; + "reroll": CustomEvent +} +declare global { + interface HTMLElement { + addEventListener(type: K, + listener: (this: Document, ev: CustomEventMap[K]) => void, + options?: boolean|EventListenerOptions): void; + dispatchEvent(ev: CustomEventMap[K]): boolean; + dispatchEvent(ev: HTMLElementEventMap[K]): boolean; + } +} diff --git a/src/common/rolltable.ts b/src/common/rolltable.ts index bef1ee8..1c34662 100644 --- a/src/common/rolltable.ts +++ b/src/common/rolltable.ts @@ -326,7 +326,7 @@ export class RollTableDatabase implements Iterable { } } - addResult(result: RollTableResultOrLookup|readonly [number, RollTableResultOrLookup]): RollTableResultFull { + addResult(result: RollTableResultOrLookup|readonly [number, RollTableResultOrLookup]): RollTableResultFull { if (isResultArray(result)) { const [, innerResult] = result as [number, RollTableResultOrLookup]; return this.addResult(innerResult); @@ -386,7 +386,7 @@ export class RollTableDatabase implements Iterable { } } -export function rollOn(table: RollTableDetailsAndResults): RollTableResult { +export function rollOn(table: RollTableDetailsAndResults): RollTableResultFull { const results = Array.from(table.resultsById.values()); if (results.length === 0) { throw Error(`no results for table ${table.identifier}`); @@ -453,9 +453,9 @@ export function exportScenario(contents: RollTableResult[], format: ExportFormat export function generatedStateToString(contents: GeneratedState): string { if (contents.final) { - return `Final state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}` + return `Final state:\n${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)}\n\t${rollResultToString(value)}`).join("\n\n")}` } else { - return `Current state: ${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)} : ${rollResultToString(value)}`).join(" ::: ")}. Selection: ${Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join(", ")}` + return `Current state:\n${Array.from(contents.rolled).map(([key, value]) => `${rollTableToString(key)}\n\t${rollResultToString(value)}`).join("\n\n")}\n\nSelection:\n\t${contents.selected.size > 0 ? Array.from(contents.selected).map(v => `${rollTableToStringShort(v)}`).join("\n\t") : "none"}` } } diff --git a/src/common/template.ts b/src/common/template.ts index 34ea78a..03da18a 100644 --- a/src/common/template.ts +++ b/src/common/template.ts @@ -1,170 +1,134 @@ import { - type RollTable, type RollTableAuthor, RollTableDatabase, type RollTableDetails, + type RollTable, + type RollTableDetails, type RollTableDetailsAndResults, - type RollTableResult, type RollTableResultFull, type RollTableResultSet + type RollTableResult } from './rolltable'; -import escapeHTML from 'escape-html'; -import slug from 'slug'; -export function htmlTableIdentifier(table: RollTable): string { - if (table.full) { - return slug(table.identifier); - } else { - return slug(table.header); - } -} - -export function buildFooter({ creditsUrl, includesResponses, includesGenerator }: { readonly creditsUrl: string, readonly includesResponses: boolean, readonly includesGenerator: boolean }): string { - return ` -
- ${includesGenerator ? `` : '' } - ${includesGenerator && includesResponses ? `

💡 You can save this page to be able to generate scenarios offline!

` : ''} -

- Project credits/instructions/source code -

-
`; -} - -export function buildAuthor({ author }: { readonly author: RollTableAuthor }): string { - if (author.url) { - return `
${escapeHTML(author.relation)} ${escapeHTML(author.name)}
`; - } else { - return `
${escapeHTML(author.relation)} ${escapeHTML(author.name)}
`; - } -} - -export function buildSet({ resultSet }: { readonly resultSet: RollTableResultSet }): string { - return `
in ${resultSet.name ? 'the' : 'a'} ${resultSet.global ? 'global' : 'server-local'} set${resultSet.name ? ` ${escapeHTML(resultSet.name)}` : ''}
`; -} - -export function buildResultAttribution({ result }: { readonly result: RollTableResultFull }): string { - return `
${result.author ? buildAuthor({ author: result.author }) : ''}${buildSet({ resultSet: result.set })}
`; -} +// TODO: port the rest of these to preact -export const selectedIdPrefix = 'selected-' -export function buildGeneratedElement({ result, selected }: { readonly result: RollTableResult, readonly selected: boolean|null }): string { - return ( -`
  • -

    ${selected !== null ? `` : ''}

    -
    ${result.full ? buildResultAttribution({ result }) : ''}
    -
  • `) +export function buildGeneratorPage>( + { results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses, builder }: + { readonly results: ReadonlyMap, + readonly generatorTargetUrl: string, + readonly clientId: string, + readonly creditsUrl: string, + readonly editable: boolean, + readonly selected: ReadonlySet, + readonly includesResponses: boolean, + readonly builder: BuilderT}): ReturnType { + return builder.makeDiv( + {id: "generator", classes: "page"}, + builder.makeForm({method: FormMethod.Post, action: generatorTargetUrl, id: "generatorWindow", classes: ["window", "readable"]}, + builder.makeHeading2({id: "generatorHead"}, builder.makeText("Your generated scenario")), + builder.makeUnorderedList({id: "generatedScenario"}, + ...Array.from(results.values()).map(result => + buildGeneratedElement({ + result, + selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null, + includesResponses, + builder}))), + builder.makeDiv({id: "generatorControls"}, + builder.makeDiv({id: "copyButtons", classes: ["buttons", "requiresJs", "jsPopupHost"]}, + builder.makeButton({id: "copyMD"}, builder.makeText("Markdown")), + builder.makeButton({id: "copyBB"}, builder.makeText("BBCode")), + builder.makeButton({id: "copyEmojiText"}, builder.makeText("Text + Emoji")), + builder.makeButton({id: "copyText"}, builder.makeText("Text Only")), + ), + ...(editable ? [builder.makeDiv({id: "rollButtons", classes: ["buttons"]}, + builder.makeButton({type: ButtonType.Submit, id: "reroll", name: "submit", value: "reroll"}, builder.makeText("Reroll Selected")), + builder.makeButton({id: "selectAll", classes: "requiresJs"}, builder.makeText("Select All")), + builder.makeButton({id: "selectNone", classes: "requiresJs"}, builder.makeText("Select None")), + )] : []), + builder.makeDiv({id: "scenarioButtons", classes: ["buttons"]}, + ...(editable ? [ + builder.makeHyperlink({id: "rerollAll", url: generatorTargetUrl, destination: HyperlinkDestination.Internal, asButton: true}, builder.makeText("New Scenario")), + builder.makeButton({type: ButtonType.Submit, id: "saveScenario", name: "submit", value: "saveScenario"}, builder.makeText("Get Scenario Link")) + ] : [ + builder.makeHyperlink({url: generatorTargetUrl, destination: HyperlinkDestination.Internal, asButton: true}, builder.makeText("Open in Generator")) + ]) + ), + ...(clientId !== '' || includesResponses ? [builder.makeDiv({id: "generatorLinks", classes: ["buttons"]}, + ...(clientId !== '' ? [builder.makeHyperlink( + { + url: `https://discord.com/api/oauth2/authorize?client_id=${ + encodeURIComponent(clientId)}&permissions=0&scope=applications.commands`, + destination: HyperlinkDestination.External, + asButton: true}, + builder.makeText("Add to Discord"))] : []), + ...(includesResponses ? [builder.makeHyperlink( + { + url: `#responses`, + destination: HyperlinkDestination.Internal, + asButton: true}, + builder.makeText("View Possible Responses"))] : []), + )] : []) + ) + ), + buildFooter({includesResponses, includesGenerator: true, creditsUrl, builder}) + ) as ReturnType } -export const submitName = "submit" -export const rerollId = "reroll" -export const rerollAllId = "rerollAll" -export const saveScenarioId = "saveScenario" -export const selectAllId = "selectAll" -export const selectNoneId = "selectNone" -export const copyMDID = "copyMD" -export const copyBBID = "copyBB" -export const copyEmojiTextID = "copyEmojiText" -export const copyTextID = "copyText" -export function buildGeneratorPage( - { results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses }: - { readonly results: ReadonlyMap, readonly generatorTargetUrl: string, readonly clientId: string, readonly creditsUrl: string, readonly editable: boolean, readonly selected: ReadonlySet, readonly includesResponses: boolean }): string { - return ` -
    -
    -

    Your generated scenario

    -
      ${Array.from(results.values()).map(result => buildGeneratedElement({ result, selected: (editable && includesResponses && result.table.full === 'results') ? selected.has(result.table) : null })).join('')}
    -
    -
    - - - - -
    - ${editable ? `
    - - - -
    ` : ''} -
    - ${editable - ? `New Scenario - ` - : `Open in Generator`} -
    - ${clientId !== '' || includesResponses ? - `` : ''} -
    -
    - ${buildFooter({ includesResponses: includesResponses, includesGenerator: true, creditsUrl })} -
    `; +export function buildResponseTypeButton>({table, builder}: {readonly table: RollTableDetails, readonly builder: BuilderT}): ReturnType { + return builder.makeHyperlink({ + url: `#responses-${htmlTableIdentifier(table)}`, + destination: HyperlinkDestination.Internal, + asButton: true, + }, builder.makeText(`${table.emoji} ${table.name}`)) as ReturnType } -export function buildResponseTypeButton({table}: {readonly table: RollTableDetails}) { - return `${escapeHTML(table.emoji)} ${escapeHTML(table.name)}` +export function buildResponse>({result, active, includesGenerator, builder}: {readonly result: RollTableResult, readonly active: boolean, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType { + return builder.makeListItem( + { + id: result.full ? `response-${result.mappingId}` : undefined, + classes: ["response", "jsPopupHost", ...(active ? ["active"] : []), ...(result.full ? ["attributed"] : [])], + }, + builder.makeButton({classes: "resultText", data: buildResultData(result)}, builder.makeText(result.text)), + buildResultAttribution({ + result, + button: result.full && includesGenerator ? builder.makeButton({classes: ["makeResponseActive", "requiresJs"]}, builder.makeText("Set in Generated Scenario")) : undefined, + builder})) as ReturnType } -export function buildResultData(result: RollTableResult): string { - return result.full ? `data-mappingid="${result.mappingId}" data-textid="${result.textId}" data-updated="${result.updated.getTime()}"` : '' +export function buildResponseList>({table, activeResult, includesGenerator, builder}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType { + return builder.makeListItem( + { + classes: ["responseType", "window", "readable"], + id: `responses-${htmlTableIdentifier(table)}` + }, + builder.makeHeading2( + { + classes: ["responseTypeHead", "tableHeader"], + data: buildTableData(table) + }, + builder.makeSpan({classes: "tableEmoji"}, builder.makeText(table.emoji)), + builder.makeText(' '), + builder.makeSpan({classes: "tableTitle"}, builder.makeText(table.title)), + ), + builder.makeUnorderedList({}, ...Array.from(table.resultsById.values()) + .map(result => + buildResponse({result, active: result === activeResult, includesGenerator, builder}))) + ) as ReturnType } -export function buildTableData(table: RollTable): string { - return `data-ordinal="${table.ordinal}" ${table.full - ? `data-id="${table.id}" data-identifier="${escapeHTML(table.identifier)}" data-name="${escapeHTML(table.name)}"` - : ''}` -} - -export function buildResponse({result, active}: {readonly result: RollTableResult, readonly active: boolean}) { - return `
  • - - ${result.full ? buildResultAttribution({result}) : ''} -
  • ` -} - -export function buildResponseList({table, activeResult}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult}) { - return `
  • -

    ${escapeHTML(table.emoji)} ${escapeHTML(table.title)}

    -
      - ${Array.from(table.resultsById.values()).map(result => buildResponse({result, active: result === activeResult})).join('')} -
    -
  • ` -} - -export function buildResponsesPage( - { tables, results, creditsUrl, includesGenerator }: { +export function buildResponsesPage>( + { tables, results, creditsUrl, includesGenerator, builder }: { readonly tables: Iterable, readonly results?: ReadonlyMap, readonly creditsUrl: string, - readonly includesGenerator: boolean}): string { - return ` -
    -
    -

    Possible Responses

    - -
    -
      - ${Array.from(tables).map(table => buildResponseList({table, activeResult: results?.get(table)})).join('')} -
    - ${buildFooter({ includesResponses: true, includesGenerator, creditsUrl })} -
    - -`; -} - -export function wrapPage( - { title, bodyContent, script, styles, noscriptStyles }: - { readonly title: string, readonly bodyContent: string, readonly script: string, readonly styles: string, readonly noscriptStyles: string }): string { - return ` - - - - ${title} - - - - - -${bodyContent} - -`; + readonly includesGenerator: boolean, + readonly builder: BuilderT}): ReturnType { + return builder.makeDiv({id: "responses", classes: "page"}, + builder.makeHeader({id: "responsesHeader", classes: "window"}, + builder.makeHeading1({id: "responsesHead"}, builder.makeText("Possible Responses")), + builder.makeNav({id: "responsesHeaderNav", classes: "buttons"}, + ...Array.from(tables).map(table => buildResponseTypeButton({table, builder})), + builder.makeHyperlink({url: `#generator`, destination: HyperlinkDestination.Internal, asButton: true, id: "returnToGenerator"}, builder.makeText("Return to Generator")) + ), + ), + builder.makeUnorderedList({id: "responseLists"}, + ...Array.from(tables).map(table => + buildResponseList({table, activeResult: results?.get(table), includesGenerator, builder}))), + buildFooter({builder, creditsUrl, includesResponses: true, includesGenerator}), + ) as ReturnType } diff --git a/src/client/attribution.less b/src/common/template/Attribution.less similarity index 59% rename from src/client/attribution.less rename to src/common/template/Attribution.less index b554a37..7a0b850 100644 --- a/src/client/attribution.less +++ b/src/common/template/Attribution.less @@ -1,3 +1,6 @@ +@import "AttributionAuthor"; +@import "AttributionSet"; + .attributed { position: relative; } @@ -23,13 +26,15 @@ position: relative; margin-bottom: 0.5rem; font-size: 1rem; - padding: 0.5rem; + padding: 0.75rem; border-radius: 0.5rem; box-sizing: border-box; transform: scale(0); transform-origin: bottom center; - transition: opacity 0.25s ease, transform 0.25s ease; - transition-delay: 250ms; + transition-property: opacity, transform; + transition-duration: 250ms, 250ms; + transition-timing-function: ease, ease; + transition-delay: 0ms, 0ms; pointer-events: initial; user-select: none; } @@ -37,6 +42,10 @@ user-select: none; } +.attribution .button { + margin-top: 0.5rem; +} + .attributionBubble::after { content: ""; position: absolute; @@ -48,44 +57,36 @@ border-color: black transparent transparent transparent; } -.attributed:hover, .attributed:focus-within { +.attributed:focus-within { user-select: text; } -.attributed:hover .attributionBubble { - transition-delay: 1.0s; -} - -.attributed:focus-within .attributionBubble { - transition-delay: 0s; -} - -.attributed:hover .attributionBubble, .attributed:focus-within .attributionBubble { +.attributed:focus-within .attributionBubble, .attributed .attributionBubble:hover { opacity: 100%; transform: none; user-select: text; } -.attributed:hover .attributionBubble *, .attributed:focus-within .attributionBubble * { +.attributed:focus-within .attributionBubble *, .attributed .attributionBubble:hover * { user-select: text; } -.attributionBubble a { +.attributionBubble a:not(.button) { transition: color 300ms ease; } -.attributionBubble a:link { +.attributionBubble a:not(.button):link { color: aquamarine; } -.attributionBubble a:visited { +.attributionBubble a:not(.button):visited { color: mediumaquamarine; } -.attributionBubble a:focus, .attributionBubble a:hover { +.attributionBubble a:not(.button):focus, .attributionBubble a:not(.button):hover { color: lightcyan; } -.attributionBubble a:active { +.attributionBubble a:not(.button):active { color: aqua; } diff --git a/src/common/template/Attribution.tsx b/src/common/template/Attribution.tsx new file mode 100644 index 0000000..3840a1c --- /dev/null +++ b/src/common/template/Attribution.tsx @@ -0,0 +1,73 @@ +import type { + RollTableAuthor, +} from '../rolltable'; +import { + AttributionAuthor, + reconstituteAttributionAuthor, + reconstituteAttributionAuthorIfExists +} from './AttributionAuthor'; +import { Fragment } from 'preact'; +import { + AttributionSet, + type AttributionSetProps, + reconstituteAttributionSet, + reconstituteAttributionSetIfExists +} from './AttributionSet'; +import type { PropsWithChildren } from 'preact/compat'; + +export interface AttributionPropsFull { + author: RollTableAuthor|null + set: AttributionSetProps +} + +export interface AttributionPropsEmpty { + author?: null + set?: null +} + +export type AttributionProps = AttributionPropsFull|AttributionPropsEmpty + +export interface PartialAttributionPropsFull { + author?: Partial|null + set?: Partial +} + +export interface PartialAttributionPropsEmpty { + author?: null + set?: null +} + +export type PartialAttributionProps = PartialAttributionPropsFull|PartialAttributionPropsEmpty + +export function reconstituteAttribution(div: HTMLDivElement, partial?: PartialAttributionProps): AttributionProps { + const set = reconstituteAttributionSetIfExists( + div.querySelector(".resultSet"), + partial?.set) + const author = reconstituteAttributionAuthorIfExists( + div.querySelector(".author"), + partial?.author) + if (!set) { + return {} + } else { + return { + set: set, + author: author, + } + } +} + +export function Attribution({author, set, children}: AttributionProps & PropsWithChildren) { + return
    +
    + {set + ? + {author && } + + + :

    + Authorship unknown +

    } + {children} +
    +
    +} diff --git a/src/common/template/AttributionAuthor.less b/src/common/template/AttributionAuthor.less new file mode 100644 index 0000000..e69de29 diff --git a/src/common/template/AttributionAuthor.tsx b/src/common/template/AttributionAuthor.tsx new file mode 100644 index 0000000..2b2845c --- /dev/null +++ b/src/common/template/AttributionAuthor.tsx @@ -0,0 +1,28 @@ +import type { RollTableAuthor } from '../rolltable'; + +export function reconstituteAttributionAuthorIfExists(element: HTMLParagraphElement | null, partial?: Partial|null): RollTableAuthor|null { + if (!element || partial === null) { + return null + } + return reconstituteAttributionAuthor(element, partial) +} + +export function reconstituteAttributionAuthor(p: HTMLParagraphElement, partial?: Partial): RollTableAuthor { + return { + id: partial?.id ?? parseInt(p.dataset.id!!), + name: partial?.name ?? p.querySelector(".authorName")!.innerText, + url: typeof partial?.url !== "undefined" ? partial.url : (p.querySelector(".authorUrl")?.href ?? null), + relation: partial?.relation ?? p.querySelector(".authorRelation")!.innerText, + } +} + +export function AttributionAuthor({ relation, id, url, name }: RollTableAuthor) { + return

    + {relation} + {" "} + {url + ? {name} + : name + } +

    +} diff --git a/src/common/template/AttributionSet.less b/src/common/template/AttributionSet.less new file mode 100644 index 0000000..e69de29 diff --git a/src/common/template/AttributionSet.tsx b/src/common/template/AttributionSet.tsx new file mode 100644 index 0000000..cdebd4a --- /dev/null +++ b/src/common/template/AttributionSet.tsx @@ -0,0 +1,26 @@ +import type { RollTableResultSet } from '../rolltable'; +import { Fragment } from 'preact'; + +export type AttributionSetProps = Pick + +export function reconstituteAttributionSetIfExists(element: HTMLParagraphElement | null, partial?: Partial|null): AttributionSetProps|null { + if (!element || partial === null) { + return null + } + return reconstituteAttributionSet(element, partial) +} + +export function reconstituteAttributionSet(p: HTMLParagraphElement, partial?: Partial): AttributionSetProps { + return { + id: partial?.id ?? parseInt(p.dataset.id!!), + name: partial?.name ?? p.querySelector(".setName")?.innerText ?? null, + global: partial?.global ?? p.classList.contains('global'), + } +} + +export function AttributionSet({global, name, id}: AttributionSetProps) { + return

    + in {name ? 'the' : 'a'} {global ? 'global' : 'server-local'} set + {name && {' '}{name}} +

    +} diff --git a/src/common/template/Button.less b/src/common/template/Button.less new file mode 100644 index 0000000..89cf907 --- /dev/null +++ b/src/common/template/Button.less @@ -0,0 +1,33 @@ +.button { + border: none; + padding: 0.5rem; + font-size: 1rem; + text-align: center; + text-decoration: none; + color: black; + font-family: inherit; + background-color: lightgray; + cursor: pointer; + user-select: none; + border-radius: 0.8rem 0.4rem; + box-shadow: 0 0 black; + transform: none; + transition: background-color 0.1s ease, transform 0.1s ease, box-shadow 0.1s ease; + + &:disabled { + background-color: slategray; + color: #333; + cursor: inherit; + } + + &:not(:disabled):hover, &:not(:disabled):focus { + background-color: darkgray; + box-shadow: -0.2rem 0.2rem black; + transform: translate(0.2rem, -0.2rem); + } + + &:not(:disabled):active { + box-shadow: 0 0 black; + transform: none; + } +} diff --git a/src/common/template/Button.tsx b/src/common/template/Button.tsx new file mode 100644 index 0000000..c75213f --- /dev/null +++ b/src/common/template/Button.tsx @@ -0,0 +1,33 @@ +import type { PropsWithChildren } from 'preact/compat'; + +export interface LinkButtonProps { + "class"?: string + type?: "link" + href: string + external?: boolean +} + +export interface FormButtonProps { + "class"?: string + type: HTMLButtonElement["type"] + href?: null + external?: null +} + +export type ButtonProps = LinkButtonProps|FormButtonProps + +export function Button({"class": className, type, href, external, children}: ButtonProps & PropsWithChildren) { + if (href) { + return + {children} + + } else { + return + } +} diff --git a/src/common/template/GeneratedElement.less b/src/common/template/GeneratedElement.less new file mode 100644 index 0000000..708974a --- /dev/null +++ b/src/common/template/GeneratedElement.less @@ -0,0 +1,63 @@ +@import "TableHeader"; +@import "GeneratedResult"; + +.generatedElement { + list-style: none; +} + +.generatedHead { + user-select: text; + margin: 0.5rem 0 0 0; + display: flex; + flex-flow: row nowrap; +} + +.generatedHead .generatedLabel span { + display: inline; + user-select: text; +} + +.generatedLabel { + flex: 1 1 auto; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: left; + cursor: pointer; + padding-right: 0.2rem; + user-select: text; +} + +.generatedHead:hover .generatedSelect, .generatedHead .generatedSelect:focus { + background-color: #90909030; + border: 0.1rem solid darkgray; + filter: brightness(105%) saturate(105%); + transform: scale(120%) rotate(15deg); +} + +.generatedHead .generatedSelect:active { + filter: brightness(80%) saturate(80%); + transform: scale(80%) rotate(30deg); +} + +.generatedSelect { + flex: 0 0 auto; + appearance: none; + cursor: pointer; + font-size: 1.5rem; + margin: 0; + transition: filter 0.3s ease, transform 0.3s ease, border-bottom-width 0.3s ease, border-top-width 0.3s ease, border-left-width 0.3s ease, border-right-width 0.3s ease; + width: 2rem; + height: 2rem; + text-align: center; + line-height: 2rem; + border-radius: 1rem; + + &::after { + content: '🔒' + } + + &:checked::after { + content: '🎲'; + } +} diff --git a/src/common/template/GeneratedElement.tsx b/src/common/template/GeneratedElement.tsx new file mode 100644 index 0000000..84ce226 --- /dev/null +++ b/src/common/template/GeneratedElement.tsx @@ -0,0 +1,30 @@ +import { TableEmoji, TableHeaderDataset, tableIdentifier, type TableProps, TableTitle } from './TableHeader'; +import { GeneratedResult, type GeneratedResultProps } from './GeneratedResult'; + +export type GeneratedElementProps = { + table: TableProps + selected: boolean|null +} & GeneratedResultProps + +// TODO: reconstitute this +// TODO: get a callback for checkbox value changes + +export function GeneratedElement(props: GeneratedElementProps) { + const checkId = `selected-${tableIdentifier(props.table)}` + return
  • +

    + + {props.selected !== null + ? + : null} +

    + +
  • +} diff --git a/src/common/template/GeneratedResult.less b/src/common/template/GeneratedResult.less new file mode 100644 index 0000000..f64a640 --- /dev/null +++ b/src/common/template/GeneratedResult.less @@ -0,0 +1,10 @@ +@import "Attribution"; +@import "ResultText"; + +.generatedResult { + margin: 0; + padding: 0; + appearance: none; + font: inherit; + border: 0; +} diff --git a/src/common/template/GeneratedResult.tsx b/src/common/template/GeneratedResult.tsx new file mode 100644 index 0000000..dc76bf2 --- /dev/null +++ b/src/common/template/GeneratedResult.tsx @@ -0,0 +1,64 @@ +import { + Attribution, + type AttributionPropsEmpty, type AttributionPropsFull, + type PartialAttributionProps, type PartialAttributionPropsEmpty, + reconstituteAttribution, +} from './Attribution'; +import { responseIdPrefix } from './util'; +import { + reconstituteResultText, + ResultText, + type ResultTextPropsFull, + type ResultTextPropsLimited +} from './ResultText'; +import { Button } from './Button'; + +export type GeneratedResultPropsFull = { + includesResponses: boolean +} & AttributionPropsFull & ResultTextPropsFull + +export type GeneratedResultPropsLimited = { + includesResponses?: null +} & AttributionPropsEmpty & ResultTextPropsLimited + +export type GeneratedResultProps = GeneratedResultPropsFull|GeneratedResultPropsLimited + + +export type PartialGeneratedResultPropsFull = { + includesResponses?: boolean +} & PartialAttributionProps & Partial + +export type PartialGeneratedResultPropsLimited = { + includesResponses?: null +} & PartialAttributionPropsEmpty & Partial + +export type PartialGeneratedResultProps = PartialGeneratedResultPropsFull|PartialGeneratedResultPropsLimited + +export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: PartialGeneratedResultProps): GeneratedResultProps { + const result = + reconstituteResultText(div.querySelector(".resultText")!, partial) + const attribution = + reconstituteAttribution(div.querySelector(".attribution")!, partial) + if (result.updated && attribution.set) { + return { + includesResponses: !!div.querySelector(".jumpToResponse"), + ...attribution, + ...result, + } + } else { + return { + text: result.text + } + } +} + +export function GeneratedResult(props: GeneratedResultProps) { + return
    + + + {props.includesResponses + ?

    + : null} +
    +
    +} diff --git a/src/common/template/PageFooter.less b/src/common/template/PageFooter.less new file mode 100644 index 0000000..35907d4 --- /dev/null +++ b/src/common/template/PageFooter.less @@ -0,0 +1,7 @@ +footer { + display: block; + margin: 0.75rem 0 0 0; + font-size: 0.75rem; + user-select: none; + text-align: center; +} diff --git a/src/common/template/PageFooter.tsx b/src/common/template/PageFooter.tsx new file mode 100644 index 0000000..1ca3c74 --- /dev/null +++ b/src/common/template/PageFooter.tsx @@ -0,0 +1,35 @@ +import { Fragment } from 'preact'; + +export interface PageFooterProps { + creditsUrl: string + includesResponses: boolean + includesGenerator: boolean +} + +export function reconstituteFooterProps(footer: HTMLElement, partial: Partial = {}): PageFooterProps { + return { + creditsUrl: partial.creditsUrl ?? footer.querySelector(".creditsLink")!.href, + includesResponses: partial.includesResponses ?? footer.querySelector(".jsOffHint") !== null, + includesGenerator: partial.includesGenerator ?? footer.querySelector(".saveHint") !== null, + } +} + +export function PageFooter({creditsUrl, includesGenerator, includesResponses}: PageFooterProps) { + return
    + {includesGenerator + ? + + {includesResponses + ?

    💡 You can save this page to be able to generate scenarios offline!

    + : null} +
    + : null} +

    + Project credits/instructions/source code/license +

    +
    +} diff --git a/src/common/template/ResultText.less b/src/common/template/ResultText.less new file mode 100644 index 0000000..5eb4a74 --- /dev/null +++ b/src/common/template/ResultText.less @@ -0,0 +1,37 @@ +.resultText { + flex: 1 1 auto; + appearance: none; + background-color: transparent; + color: inherit; + font-size: inherit; + font-family: inherit; + text-decoration: none; + border: 0; + padding: 0.2rem 0.5rem; + cursor: pointer; + text-align: left; + word-wrap: normal; + display: block; + width: 100%; + box-sizing: border-box; + white-space: normal; + user-select: text; + transition: background-color 0.2s ease; + border-radius: 0.3rem; + + &:hover:not(:active) { + background-color: #BFBFBF60; + } + + &:focus:not(:active) { + background-color: #9F9FFF90; + } + + &:focus:hover:not(:active) { + background-color: #8F8FDF90; + } + + &:active { + background-color: #3F3FFFA0; + } +} diff --git a/src/common/template/ResultText.tsx b/src/common/template/ResultText.tsx new file mode 100644 index 0000000..d352e47 --- /dev/null +++ b/src/common/template/ResultText.tsx @@ -0,0 +1,38 @@ +export interface ResultTextPropsBase { + text: string +} + +export interface ResultTextPropsFull extends ResultTextPropsBase { + mappingId: number + textId: number + updated: Date +} + +export interface ResultTextPropsLimited extends ResultTextPropsBase { + mappingId?: null + textId?: null + updated?: null +} + +export function reconstituteResultText(button: HTMLButtonElement, partial: Partial = {}): ResultTextProps { + const text = button.innerText + if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") { + return {text} + } else { + return { + text, + mappingId: partial.mappingId ?? parseInt(button.dataset["mappingId"]!), + textId: partial.textId ?? parseInt(button.dataset["textId"]!), + updated: partial.updated ?? new Date(parseInt(button.dataset["updated"]!)) + } + } +} + +export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited + +export function ResultText({text, mappingId, textId, updated}: ResultTextProps) { + return +} diff --git a/src/common/template/TableHeader.less b/src/common/template/TableHeader.less new file mode 100644 index 0000000..c1985f5 --- /dev/null +++ b/src/common/template/TableHeader.less @@ -0,0 +1,14 @@ +.tableHeader { + font-size: 1.25rem; + font-weight: bold; + display: flex; + justify-content: stretch; + align-items: baseline; + margin-bottom: 0; +} + +.tableEmoji { + font-size: 1.75rem; + padding-right: 0.5rem; + user-select: text; +} diff --git a/src/common/template/TableHeader.tsx b/src/common/template/TableHeader.tsx new file mode 100644 index 0000000..b9c2f3c --- /dev/null +++ b/src/common/template/TableHeader.tsx @@ -0,0 +1,73 @@ +import slug from 'slug'; + +// TODO: reconstitute the three things here + +export type TableFullProps = TableIdentifierFullProps & TableHeaderFullProps & TableEmojiProps & TableTitleProps +export type TableLimitedProps = TableIdentifierLimitedProps & TableHeaderLimitedProps & TableEmojiProps & TableTitleProps +export type TableProps = TableFullProps|TableLimitedProps +export interface TableIdentifierFullProps { + identifier: string + title: string +} + +export interface TableIdentifierLimitedProps { + identifier?: null + title: string +} + +export type TableIdentifierProps = TableIdentifierFullProps|TableIdentifierLimitedProps + +export function tableIdentifier({ identifier, title }: TableIdentifierProps): string { + if (typeof identifier === 'string') { + return slug(identifier); + } else { + return slug(title); + } +} + +export interface TableHeaderFullProps { + ordinal: number, + id: number, + name: string, + identifier: string +} + +export interface TableHeaderLimitedProps { + ordinal: number, + id?: null, + name?: null, + identifier?: null +} + +export type TableHeaderProps = TableHeaderFullProps|TableHeaderLimitedProps + +export function TableHeaderDataset({ ordinal, id, name, identifier }: TableHeaderProps): Record<`data-${string}`, string> { + if (typeof identifier === "string") { + return { + "data-ordinal": `${ordinal}`, + "data-id": `${id}`, + "data-name": name, + "data-identifier": identifier + } + } else { + return { + "data-ordinal": `${ordinal}`, + } + } +} + +export interface TableEmojiProps { + emoji: string +} + +export function TableEmoji({emoji}: TableEmojiProps) { + return {emoji} +} + +export interface TableTitleProps { + title: string +} + +export function TableTitle({title}: TableTitleProps) { + return {title} +} diff --git a/src/common/template/util.ts b/src/common/template/util.ts new file mode 100644 index 0000000..c739d7a --- /dev/null +++ b/src/common/template/util.ts @@ -0,0 +1,2 @@ +// TODO: move this to the response file +export const responseIdPrefix = "response-" diff --git a/src/common/tsconfig.json b/src/common/tsconfig.json index 1634754..a804e3e 100644 --- a/src/common/tsconfig.json +++ b/src/common/tsconfig.json @@ -6,6 +6,13 @@ "forceConsistentCasingInFileNames": true, "moduleResolution": "NodeNext", "strict": true, - "skipLibCheck": true + "skipLibCheck": true, + "lib": ["DOM"], + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } } } diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index 2316ed9..7b40b91 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -96,6 +96,13 @@ /* Completeness */ // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ - "skipLibCheck": true /* Skip type checking all .d.ts files. */ + "skipLibCheck": true, /* Skip type checking all .d.ts files. */ + + "jsx": "react-jsx", + "jsxImportSource": "preact", + "paths": { + "react": ["./node_modules/preact/compat/"], + "react-dom": ["./node_modules/preact/compat/"] + } } } diff --git a/src/server/web/router.ts b/src/server/web/router.ts index bbb5e8a..f5b0036 100644 --- a/src/server/web/router.ts +++ b/src/server/web/router.ts @@ -1,11 +1,12 @@ import { type IRequestStrict, Router } from 'itty-router'; import type { Database } from '../db/database'; -import { buildGeneratorPage, buildResponsesPage, wrapPage } from '../../common/template'; +import { buildGeneratorPage, buildResponsesPage } from '../../common/template'; import { CSS, JS } from './bundles/client.generated'; import type { HashedBundled } from '../../common/bundle'; import { getSourceMapFileName, SourceMapExtension, SourceMaps } from './bundles/sourcemaps'; import { collapseWhiteSpace } from 'collapse-white-space'; import { getQuerySingleton, takeLast } from '../request/query'; +import { StringTemplateBuilder } from './template'; interface WebEnv { readonly BASE_URL: string, @@ -13,7 +14,23 @@ interface WebEnv { readonly DISCORD_APP_ID: string } - +export function wrapPage( + { title, bodyContent, script, styles, noscriptStyles }: + { readonly title: string, readonly bodyContent: string, readonly script: string, readonly styles: string, readonly noscriptStyles: string }): string { + return ` + + + + ${title} + + + + + +${bodyContent} + +`; +} export function webRouter(base: string) { function getSourceMappedJS(name: keyof typeof JS) { const { bundled, hash }: HashedBundled = JS[name]; @@ -35,13 +52,15 @@ export function webRouter(base: string) { results: results.rolled, editable: !results.final, selected: results.selected, - includesResponses: true + includesResponses: true, + builder: StringTemplateBuilder, }) const responses = buildResponsesPage({ tables: Array.from(results.db.tables.values()), results: results.rolled, creditsUrl: env.CREDITS_URL, - includesGenerator: true + includesGenerator: true, + builder: StringTemplateBuilder, }) const wrapped = wrapPage({ title: 'Vore Scenario Generator', diff --git a/src/server/web/template.ts b/src/server/web/template.ts new file mode 100644 index 0000000..e076209 --- /dev/null +++ b/src/server/web/template.ts @@ -0,0 +1,131 @@ +import { + type ButtonFeatures, + ButtonType, + type CheckboxFeatures, + type ElementFeatures, extendClasses, + type FormFeatures, + HyperlinkDestination, + type HyperlinkFeatures, + type LabelFeatures, + type TemplateBuilder +} from '../../common/template'; +import escapeHTML from 'escape-html'; +import { kebabCase } from 'change-case'; + +function tag(tagName: string, features: ElementFeatures, attributes: string[], contents: string[]): string { + if (typeof features.id !== "undefined") { + attributes.push(`id="${escapeHTML(features.id)}"`) + } + if (typeof features.classes !== "undefined") { + attributes.push(`class="${typeof features.classes === "string" + ? escapeHTML(features.classes) + : Array.from(features.classes).map(escapeHTML).join(" ")}"`) + } + if (typeof features.data !== "undefined") { + for (const [key, value] of features.data) { + attributes.push(`data-${escapeHTML(kebabCase(key))}="${escapeHTML(value)}"`) + } + } + return `<${tagName}${attributes.length === 0 ? "" : " " + attributes.join(" ")}>${contents.join("")}` +} + +class StringTemplateBuilderImpl implements TemplateBuilder { + + makeButton(features: ButtonFeatures, ...contents: string[]): string { + const attributes = [ + `type="${escapeHTML(features.type ?? ButtonType.Button)}"`, + ] + if (typeof features.name === "string") { + attributes.push(`name="${escapeHTML(features.name)}"`) + } + if (typeof features.value === "string") { + attributes.push(`value="${escapeHTML(features.value)}"`) + } + return tag('button', {...features, classes: extendClasses(features.classes, "button")}, attributes, contents) + } + + makeCheckbox(features: CheckboxFeatures, ...contents: string[]): string { + const attributes = [`type="checkbox"`, `name="${escapeHTML(features.name)}"`] + if (features.checked) { + attributes.push("checked") + } + if (typeof features.value === "string") { + attributes.push(`value="${escapeHTML(features.value)}"`) + } + return tag('input', features, attributes, contents); + } + + makeDiv(features: ElementFeatures, ...contents: string[]): string { + return tag('div', features, [], contents); + } + + makeFooter(features: ElementFeatures, ...contents: string[]): string { + return tag('footer', features, [], contents); + } + + makeForm(features: FormFeatures, ...contents: string[]): string { + const attributes = [`action="${escapeHTML(features.action)}"`, `method="${escapeHTML(features.method)}"`] + return tag('form', features, attributes, contents); + } + + makeHeader(features: ElementFeatures, ...contents: string[]): string { + return tag('header', features, [], contents) + } + + makeHeading1(features: ElementFeatures, ...contents: string[]): string { + return tag('h1', features, [], contents); + } + + makeHeading2(features: ElementFeatures, ...contents: string[]): string { + return tag('h2', features, [], contents); + } + + makeHyperlink(features: HyperlinkFeatures, ...contents: string[]): string { + const attributes = [`href="${escapeHTML(features.url)}"`] + if (features.destination === HyperlinkDestination.External) { + attributes.push(`rel="external nofollow noreferrer"`) + } + if (features.asButton) { + attributes.push(`draggable="false"`) + } + return tag('a', {...features, classes: extendClasses(features.classes, features.asButton ? ["button"] : [])}, attributes, contents); + } + + makeLabel(features: LabelFeatures, ...contents: string[]): string { + const attributes = [] + if (typeof features.forId === "string") { + attributes.push(`for="${escapeHTML(features.forId)}"`) + } + return tag('label', features, attributes, contents); + } + + makeListItem(features: ElementFeatures, ...contents: string[]): string { + return tag('li', features, [], contents) + } + + makeNav(features: ElementFeatures, ...contents: string[]): string { + return tag('nav', features, [], contents) + } + + makeNoscript(features: ElementFeatures, ...contents: string[]): string { + return tag('noscript', features, [], contents); + } + + makeParagraph(features: ElementFeatures, ...contents: string[]): string { + return tag('p', features, [], contents); + } + + makeSpan(features: ElementFeatures, ...contents: string[]): string { + return tag('span', features, [], contents); + } + + makeText(text: string): string { + return escapeHTML(text); + } + + makeUnorderedList(features: ElementFeatures, ...contents: string[]): string { + return tag('ul', features, [], contents); + } +} + +export const StringTemplateBuilder = new StringTemplateBuilderImpl()