parent
f0a46781de
commit
ae1c05270f
@ -1,66 +0,0 @@ |
|||||||
body { |
|
||||||
background-color: deepskyblue; |
|
||||||
font-family: sans-serif; |
|
||||||
padding: 0; |
|
||||||
margin: 0; |
|
||||||
} |
|
||||||
|
|
||||||
.window { |
|
||||||
background-color: #f8f7f0; |
|
||||||
padding: 1rem; |
|
||||||
border: 0.1rem solid black; |
|
||||||
border-radius: 0.5rem; |
|
||||||
box-sizing: border-box; |
|
||||||
} |
|
||||||
|
|
||||||
.page { |
|
||||||
} |
|
||||||
|
|
||||||
.page * { |
|
||||||
user-select: none; |
|
||||||
} |
|
||||||
|
|
||||||
.readable { |
|
||||||
width: 35rem; |
|
||||||
} |
|
||||||
|
|
||||||
ul { |
|
||||||
padding: 0; |
|
||||||
} |
|
||||||
|
|
||||||
li { |
|
||||||
list-style: none; |
|
||||||
} |
|
||||||
|
|
||||||
.buttons { |
|
||||||
display: flex; |
|
||||||
flex-wrap: wrap; |
|
||||||
align-items: center; |
|
||||||
justify-content: stretch; |
|
||||||
& > * { |
|
||||||
flex: 1 0 auto; |
|
||||||
margin: 0.2rem 0 0 0.3rem |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@keyframes popup { |
|
||||||
from { |
|
||||||
transform: scale(0); |
|
||||||
opacity: 0; |
|
||||||
} |
|
||||||
|
|
||||||
10% { |
|
||||||
transform: none; |
|
||||||
opacity: 100%; |
|
||||||
} |
|
||||||
|
|
||||||
75% { |
|
||||||
transform: none; |
|
||||||
opacity: 100%; |
|
||||||
} |
|
||||||
|
|
||||||
to { |
|
||||||
transform: scale(0); |
|
||||||
opacity: 0; |
|
||||||
} |
|
||||||
} |
|
@ -1,5 +1,5 @@ |
|||||||
import {responseLists, db} from './responses-entrypoint' |
import {responseLists, db} from './responses-entrypoint-old' |
||||||
import {prepareGenerator} from './generator-entrypoint' |
import {prepareGenerator} from './generator-entrypoint-old' |
||||||
|
|
||||||
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => { |
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => { |
||||||
res.addSelectionListener((ev) => { |
res.addSelectionListener((ev) => { |
@ -1,35 +1,3 @@ |
|||||||
@import "basic-look"; |
@import "../common/client/GeneratorPage"; |
||||||
@import "popup"; |
@import "../common/client/Page"; |
||||||
@import "pulse"; |
@import "../common/client/PageFooter"; |
||||||
|
|
||||||
#generator { |
|
||||||
position: absolute; |
|
||||||
top: 0; |
|
||||||
min-height: 100dvh; |
|
||||||
left: 0; |
|
||||||
right: 0; |
|
||||||
margin: 0; |
|
||||||
padding: 2rem; |
|
||||||
display: flex; |
|
||||||
box-sizing: border-box; |
|
||||||
flex-flow: column nowrap; |
|
||||||
justify-content: center; |
|
||||||
align-items: center; |
|
||||||
} |
|
||||||
|
|
||||||
#generatorHead { |
|
||||||
margin-top: 0; |
|
||||||
user-select: text; |
|
||||||
} |
|
||||||
|
|
||||||
#generatedScenario { |
|
||||||
} |
|
||||||
|
|
||||||
#generator .buttons { |
|
||||||
margin-left: -0.3rem; |
|
||||||
} |
|
||||||
|
|
||||||
#copyButtons::before { |
|
||||||
content: "Copy as:"; |
|
||||||
margin: 0.2rem 0 0 0.3rem |
|
||||||
} |
|
||||||
|
@ -1,19 +0,0 @@ |
|||||||
export function showPopup(parent: HTMLElement, text: string, className?: 'success'|'info'|'warning'|'error'): void { |
|
||||||
if (!parent.classList.contains("jsPopupHost")) { |
|
||||||
console.warn(parent, "should be jsPopupHost") |
|
||||||
} |
|
||||||
const container = parent.ownerDocument.createElement("div") |
|
||||||
container.classList.add("jsPopupContainer") |
|
||||||
parent.appendChild(container) |
|
||||||
const popup = parent.ownerDocument.createElement("div") |
|
||||||
popup.classList.add("jsPopup") |
|
||||||
if (className) { |
|
||||||
popup.classList.add(className) |
|
||||||
} |
|
||||||
popup.innerText = text |
|
||||||
container.appendChild(popup) |
|
||||||
popup.addEventListener('animationend', () => { |
|
||||||
container.removeChild(popup) |
|
||||||
parent.removeChild(container) |
|
||||||
}) |
|
||||||
} |
|
@ -1,110 +1,3 @@ |
|||||||
@import "basic-look"; |
@import "../common/client/ResponsesPage"; |
||||||
@import "attribution"; |
@import "../common/client/Page"; |
||||||
@import "popup"; |
@import "../common/client/PageFooter"; |
||||||
|
|
||||||
#responsesHeader { |
|
||||||
position: sticky; |
|
||||||
display: flex; |
|
||||||
flex-flow: column; |
|
||||||
align-items: center; |
|
||||||
border-top: 0; |
|
||||||
border-left: 0; |
|
||||||
border-right: 0; |
|
||||||
border-radius: 0; |
|
||||||
margin: 0; |
|
||||||
top: 0; |
|
||||||
left: 0; |
|
||||||
right: 0; |
|
||||||
height: 9.5rem; |
|
||||||
z-index: 2; |
|
||||||
} |
|
||||||
|
|
||||||
#responsesHeader .buttons { |
|
||||||
display: flex; |
|
||||||
flex-flow: row wrap; |
|
||||||
padding-top: 0.2rem; |
|
||||||
padding-left: 0.3rem; |
|
||||||
padding-right: 0.3rem; |
|
||||||
margin: 0; |
|
||||||
overflow-y: auto; |
|
||||||
overflow-x: visible; |
|
||||||
} |
|
||||||
|
|
||||||
#returnToGenerator { |
|
||||||
flex-basis: 50%; |
|
||||||
} |
|
||||||
|
|
||||||
.responseNavEmoji { |
|
||||||
margin-right: 0.2rem; |
|
||||||
} |
|
||||||
|
|
||||||
#responsesHead { |
|
||||||
margin-top: 0; |
|
||||||
margin-bottom: 0; |
|
||||||
font-size: 1.5rem; |
|
||||||
} |
|
||||||
|
|
||||||
#responseLists { |
|
||||||
display: flex; |
|
||||||
flex-flow: row wrap; |
|
||||||
padding: 0.1rem; |
|
||||||
justify-content: center; |
|
||||||
} |
|
||||||
|
|
||||||
.responseType { |
|
||||||
list-style: none; |
|
||||||
padding: 1rem; |
|
||||||
scroll-margin-top: 10rem; |
|
||||||
margin-top: 0.5rem; |
|
||||||
margin-left: 1rem; |
|
||||||
margin-bottom: 0.5rem; |
|
||||||
} |
|
||||||
|
|
||||||
.responseType > h2 { |
|
||||||
margin-top: 0; |
|
||||||
} |
|
||||||
|
|
||||||
.responseTypeHead { |
|
||||||
position: sticky; |
|
||||||
top: 9.4rem; |
|
||||||
background-color: inherit; |
|
||||||
z-index: 1; |
|
||||||
padding-bottom: 0.2rem; |
|
||||||
} |
|
||||||
|
|
||||||
.responseTypeTitle { |
|
||||||
flex: 1 1 auto; |
|
||||||
} |
|
||||||
|
|
||||||
.response { |
|
||||||
margin-top: 0.3rem; |
|
||||||
display: flex; |
|
||||||
align-items: stretch; |
|
||||||
flex-flow: row nowrap; |
|
||||||
scroll-margin-top: 12rem; |
|
||||||
} |
|
||||||
|
|
||||||
.response.active { |
|
||||||
position: relative; |
|
||||||
min-height: 1.5rem; |
|
||||||
|
|
||||||
&::before { |
|
||||||
width: 1rem; |
|
||||||
margin: 0.2rem 0.2rem 0.2rem 0.5rem; |
|
||||||
content: ""; |
|
||||||
flex: 0 0 auto; |
|
||||||
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; |
|
||||||
} |
|
||||||
} |
|
||||||
|
@ -1,305 +0,0 @@ |
|||||||
import { |
|
||||||
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': |
|
||||||
return true |
|
||||||
case 'false': |
|
||||||
return false |
|
||||||
default: |
|
||||||
return |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function asInteger(s: string|undefined): number|undefined { |
|
||||||
if (typeof s === "undefined") { |
|
||||||
return |
|
||||||
} |
|
||||||
const result = parseInt(s) |
|
||||||
if (Number.isNaN(result)) { |
|
||||||
return |
|
||||||
} |
|
||||||
return result |
|
||||||
} |
|
||||||
|
|
||||||
export function asTimestamp(s: string|undefined): Date|undefined { |
|
||||||
const i = asInteger(s) |
|
||||||
if (typeof i === "undefined") { |
|
||||||
return |
|
||||||
} |
|
||||||
const date = new Date(i) |
|
||||||
if (Number.isNaN(date.valueOf())) { |
|
||||||
return |
|
||||||
} |
|
||||||
return date |
|
||||||
} |
|
||||||
|
|
||||||
export function textFrom(e: HTMLElement|null): string|undefined { |
|
||||||
if (!e) { |
|
||||||
return |
|
||||||
} |
|
||||||
return e.innerText.trim() |
|
||||||
} |
|
||||||
|
|
||||||
export function hrefFrom(e: HTMLAnchorElement|null): string | null { |
|
||||||
if (!e) { |
|
||||||
return null |
|
||||||
} |
|
||||||
return e.href |
|
||||||
} |
|
||||||
|
|
||||||
export function checkedFrom(e: HTMLInputElement|null): boolean | null { |
|
||||||
if (!e) { |
|
||||||
return null |
|
||||||
} |
|
||||||
return e.checked |
|
||||||
} |
|
||||||
|
|
||||||
// element to find here is .author
|
|
||||||
export function scrapeAuthor(author: HTMLElement|null): RollTableAuthor|null|undefined { |
|
||||||
if (!author) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const id = asInteger(author.dataset[authorIdKey]) |
|
||||||
const name = textFrom(author.querySelector(`.authorName`)) |
|
||||||
const url = hrefFrom(author.querySelector<HTMLAnchorElement>("a[href]")) |
|
||||||
const relation = textFrom(author.querySelector(`.authorRelation`)) |
|
||||||
if (typeof id === "undefined" || typeof name === "undefined" || typeof relation === 'undefined') { |
|
||||||
return |
|
||||||
} |
|
||||||
return { |
|
||||||
id, |
|
||||||
name, |
|
||||||
url, |
|
||||||
relation |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// element to find here is .resultSet
|
|
||||||
export function scrapeResultSet(set: HTMLElement|null): RollTableResultSet|null|undefined { |
|
||||||
if (!set) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const id = asInteger(set.dataset["id"]) |
|
||||||
const name = textFrom(set.querySelector(`.setName`)) |
|
||||||
const global = asBoolean(set.dataset["global"]) |
|
||||||
if (typeof id === "undefined" || typeof global === "undefined") { |
|
||||||
return |
|
||||||
} |
|
||||||
return { |
|
||||||
id, |
|
||||||
name: name ?? null, |
|
||||||
description: null, |
|
||||||
global, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// element to find here is .tableHeader
|
|
||||||
export function scrapeTableHeader(head: HTMLElement|null): RollTableLimited|RollTableDetailsNoResults|null|undefined { |
|
||||||
if (!head) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const emoji = textFrom(head.querySelector(".tableEmoji")) |
|
||||||
const title = textFrom(head.querySelector(".tableTitle")) |
|
||||||
const ordinal = asInteger(head.dataset["ordinal"]) |
|
||||||
const id = asInteger(head.dataset["id"]) |
|
||||||
const identifier = head.dataset["identifier"] |
|
||||||
const name = head.dataset["name"] |
|
||||||
if (typeof emoji === 'undefined' || typeof title === 'undefined' || typeof ordinal === 'undefined') { |
|
||||||
return |
|
||||||
} |
|
||||||
const header = `${emoji} ${title}` |
|
||||||
if (typeof id === 'undefined' || typeof identifier === 'undefined' || typeof name === 'undefined') { |
|
||||||
return { |
|
||||||
full: false, |
|
||||||
emoji, |
|
||||||
title, |
|
||||||
header, |
|
||||||
ordinal, |
|
||||||
} |
|
||||||
} |
|
||||||
return { |
|
||||||
full: 'details', |
|
||||||
id, |
|
||||||
identifier, |
|
||||||
emoji, |
|
||||||
title, |
|
||||||
header, |
|
||||||
ordinal, |
|
||||||
name, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function scrapeGeneratedHead(head: HTMLElement|null): {table: RollTableLimited|RollTableDetailsNoResults, selected: boolean|null}|null|undefined { |
|
||||||
if (!head) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const table = scrapeTableHeader(head.querySelector(`.tableHeader`)) |
|
||||||
if (!table) { |
|
||||||
return |
|
||||||
} |
|
||||||
const selected = checkedFrom(head.querySelector(`input[type=checkbox].generatedSelect`)) |
|
||||||
return { |
|
||||||
table, |
|
||||||
selected, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// element to find here is .resultText
|
|
||||||
export function scrapeResultText(result: HTMLElement|null): {full: false, text: string}|{full: true, mappingId: number, textId: number, updated: Date, text: string}|undefined|null { |
|
||||||
if (!result) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const text = textFrom(result) |
|
||||||
const mappingId = asInteger(result.dataset["mappingid"]) |
|
||||||
const textId = asInteger(result.dataset["textid"]) |
|
||||||
const updated = asTimestamp(result.dataset["updated"]) |
|
||||||
if (typeof text === 'undefined') { |
|
||||||
return |
|
||||||
} |
|
||||||
if (typeof mappingId === 'undefined' || typeof textId === 'undefined' || typeof updated == 'undefined') { |
|
||||||
return { |
|
||||||
full: false, |
|
||||||
text, |
|
||||||
} |
|
||||||
} |
|
||||||
return { |
|
||||||
full: true, |
|
||||||
text, |
|
||||||
textId, |
|
||||||
mappingId, |
|
||||||
updated: new Date(updated) |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// element to find here is .generatedElement
|
|
||||||
export function scrapeGeneratedElement(generated: HTMLElement|null): {result: RollTableResult, selected: boolean|null}|null|undefined { |
|
||||||
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`)) |
|
||||||
if (!header || !result) { |
|
||||||
return |
|
||||||
} |
|
||||||
const {table, selected} = header |
|
||||||
if (!set || typeof author === "undefined" || !result.full) { |
|
||||||
return { |
|
||||||
result: { |
|
||||||
full: false, |
|
||||||
table, |
|
||||||
text: result.text, |
|
||||||
}, |
|
||||||
selected |
|
||||||
} |
|
||||||
} |
|
||||||
return { |
|
||||||
result: { |
|
||||||
...result, |
|
||||||
author, |
|
||||||
set, |
|
||||||
table, |
|
||||||
}, |
|
||||||
selected, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function scrapeGeneratedScenario(scenario: HTMLElement): InProgressGeneratedState|undefined |
|
||||||
export function scrapeGeneratedScenario(scenario: null): null |
|
||||||
// element to find here is #generatedScenario
|
|
||||||
export function scrapeGeneratedScenario(scenario: HTMLElement|null): InProgressGeneratedState|null|undefined { |
|
||||||
if (!scenario) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const rolls = new RolledValues() |
|
||||||
const selection = new RollSelections() |
|
||||||
for (const item of scenario.querySelectorAll<HTMLElement>(`.generatedElement`)) { |
|
||||||
const element = scrapeGeneratedElement(item) |
|
||||||
if (!element) { |
|
||||||
return |
|
||||||
} |
|
||||||
const {result, selected} = element |
|
||||||
rolls.add(result) |
|
||||||
if (selected) { |
|
||||||
selection.add(result.table) |
|
||||||
} |
|
||||||
} |
|
||||||
return { |
|
||||||
final: false, |
|
||||||
rolled: rolls, |
|
||||||
selected: selection, |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
export function scrapeResponseList(responseTypeElement: HTMLElement|null, db: RollTableDatabase): [table: RollTableDetailsAndResults, active: RollTableResultFull<RollTableDetailsAndResults>|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<RollTableDetailsAndResults>|null = null |
|
||||||
for (const resultElement of responseTypeElement.querySelectorAll<HTMLElement>(`.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<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null>}|undefined |
|
||||||
export function scrapeResponseLists(lists: null): null |
|
||||||
export function scrapeResponseLists(lists: HTMLElement|null): {db: RollTableDatabase, active: ReadonlyMap<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null>}|null|undefined { |
|
||||||
if (!lists) { |
|
||||||
return null |
|
||||||
} |
|
||||||
const db = new RollTableDatabase() |
|
||||||
const active = new Map<RollTableDetailsAndResults, RollTableResultFull<RollTableDetailsAndResults>|null> |
|
||||||
for (const responseTypeElement of lists.querySelectorAll<HTMLElement>(`.responseType`)) { |
|
||||||
const responseType = scrapeResponseList(responseTypeElement, db) |
|
||||||
if (!responseType) { |
|
||||||
return |
|
||||||
} |
|
||||||
const [table, activeResult] = responseType |
|
||||||
active.set(table, activeResult) |
|
||||||
} |
|
||||||
return { |
|
||||||
db, |
|
||||||
active, |
|
||||||
} |
|
||||||
} |
|
@ -1,142 +0,0 @@ |
|||||||
import { |
|
||||||
type ButtonFeatures, |
|
||||||
ButtonType, |
|
||||||
type CheckboxFeatures, |
|
||||||
type ElementFeatures, |
|
||||||
type FormFeatures, |
|
||||||
HyperlinkDestination, |
|
||||||
type HyperlinkFeatures, |
|
||||||
type LabelFeatures, |
|
||||||
type TemplateBuilder |
|
||||||
} from '../common/template'; |
|
||||||
|
|
||||||
function tag<T extends keyof HTMLElementTagNameMap>(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<Node> { |
|
||||||
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() |
|
@ -1,16 +0,0 @@ |
|||||||
import type { RollTableDetailsAndResults, RollTableResultFull } from '../../common/rolltable'; |
|
||||||
import type { RerollEventDetail } from '../generator-entrypoint'; |
|
||||||
|
|
||||||
interface CustomEventMap { |
|
||||||
"resultselected": CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>; |
|
||||||
"reroll": CustomEvent<RerollEventDetail> |
|
||||||
} |
|
||||||
declare global { |
|
||||||
interface HTMLElement { |
|
||||||
addEventListener<K extends keyof CustomEventMap>(type: K, |
|
||||||
listener: (this: Document, ev: CustomEventMap[K]) => void, |
|
||||||
options?: boolean|EventListenerOptions): void; |
|
||||||
dispatchEvent<K extends keyof CustomEventMap>(ev: CustomEventMap[K]): boolean; |
|
||||||
dispatchEvent<K extends keyof HTMLElementEventMap>(ev: HTMLElementEventMap[K]): boolean; |
|
||||||
} |
|
||||||
} |
|
@ -0,0 +1,46 @@ |
|||||||
|
import type { PropsWithChildren } from 'preact/compat'; |
||||||
|
|
||||||
|
export interface LinkButtonProps { |
||||||
|
"class"?: string |
||||||
|
id?: string |
||||||
|
href: string |
||||||
|
external?: boolean |
||||||
|
onClick?: (ev: Event) => void |
||||||
|
} |
||||||
|
|
||||||
|
export interface FormButtonProps { |
||||||
|
"class"?: string |
||||||
|
id?: string |
||||||
|
type?: HTMLButtonElement["type"] |
||||||
|
href?: null |
||||||
|
external?: null |
||||||
|
name?: string |
||||||
|
value?: string |
||||||
|
disabled?: boolean |
||||||
|
onClick?: (ev: Event) => void |
||||||
|
} |
||||||
|
|
||||||
|
export function LinkButton({"class": className, id, href, external, onClick, children}: LinkButtonProps & PropsWithChildren) { |
||||||
|
return <a |
||||||
|
class={`button${className ? " " + className : ""}`} |
||||||
|
href={href} |
||||||
|
{...(external ? {rel: "external nofollow noreferrer"} : {})} |
||||||
|
{...(id ? {id} : {})} |
||||||
|
{...(onClick ? {onClick} : {})} |
||||||
|
draggable={false}> |
||||||
|
{children} |
||||||
|
</a> |
||||||
|
} |
||||||
|
|
||||||
|
export function FormButton({"class": className, id, name, value, disabled, type = "button", onClick, children}: FormButtonProps & PropsWithChildren) { |
||||||
|
return <button |
||||||
|
type={type} |
||||||
|
{...(id ? {id} : {})} |
||||||
|
{...(onClick ? {onClick} : {})} |
||||||
|
{...(name ? {name} : {})} |
||||||
|
{...(value ? {value} : {})} |
||||||
|
{...(typeof disabled === "boolean" ? {disabled} : {})} |
||||||
|
class={`button${className ? " " + className : ""}`}> |
||||||
|
{children} |
||||||
|
</button> |
||||||
|
} |
@ -0,0 +1,103 @@ |
|||||||
|
import { |
||||||
|
reconstituteTable, |
||||||
|
TableEmoji, |
||||||
|
TableHeaderDataset, |
||||||
|
tableIdentifier, |
||||||
|
type TableProps, |
||||||
|
TableTitle |
||||||
|
} from './TableHeader'; |
||||||
|
import { |
||||||
|
GeneratedResult, |
||||||
|
type GeneratedResultProps, type GeneratedResultPropsFull, |
||||||
|
type PartialGeneratedResultProps, type PartialGeneratedResultPropsFull, |
||||||
|
reconstituteGeneratedResult |
||||||
|
} from './GeneratedResult'; |
||||||
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; |
||||||
|
import { pulseElement } from './pulseElement'; |
||||||
|
|
||||||
|
export type GeneratedElementPropsBase = { |
||||||
|
table: TableProps |
||||||
|
selected: boolean|null |
||||||
|
} & GeneratedResultProps |
||||||
|
|
||||||
|
export type GeneratedElementEditableProps = GeneratedElementPropsBase & { selected: boolean } & GeneratedResultPropsFull |
||||||
|
export type GeneratedElementReadonlyProps = GeneratedElementPropsBase & { selected: null } & GeneratedResultProps |
||||||
|
|
||||||
|
export type GeneratedElementProps = GeneratedElementEditableProps|GeneratedElementReadonlyProps |
||||||
|
|
||||||
|
export type PartialGeneratedElementProps = PartialGeneratedElementEditableProps|PartialGeneratedElementReadonlyProps |
||||||
|
|
||||||
|
export type PartialGeneratedElementEditableProps = { |
||||||
|
table?: Partial<TableProps> |
||||||
|
selected?: boolean |
||||||
|
} & PartialGeneratedResultPropsFull |
||||||
|
|
||||||
|
export type PartialGeneratedElementReadonlyProps = { |
||||||
|
table?: Partial<TableProps> |
||||||
|
selected?: null |
||||||
|
} & PartialGeneratedResultProps |
||||||
|
|
||||||
|
export function reconstituteGeneratedElement(element: HTMLLIElement, partial?: PartialGeneratedElementProps): GeneratedElementProps { |
||||||
|
const result = reconstituteGeneratedResult(element.querySelector(".generatedResult")!, partial) |
||||||
|
const selected = typeof partial?.selected !== 'undefined' |
||||||
|
? partial.selected |
||||||
|
: (element.querySelector<HTMLInputElement>('.generatedSelect')?.checked) ?? null |
||||||
|
const table = reconstituteTable(element.querySelector(".tableHeader")!, partial?.table) |
||||||
|
if (result.set) { |
||||||
|
return { |
||||||
|
...result, |
||||||
|
table, |
||||||
|
selected, |
||||||
|
} |
||||||
|
} else { |
||||||
|
return { |
||||||
|
...result, |
||||||
|
table, |
||||||
|
selected: null, |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export interface GeneratedElementEvents { |
||||||
|
onSelectionChange?: (tableId: number, selected: boolean) => void |
||||||
|
} |
||||||
|
|
||||||
|
export function GeneratedElement({ onSelectionChange, ...props }: GeneratedElementProps & GeneratedElementEvents) { |
||||||
|
const ref = useRef<HTMLInputElement>(null); |
||||||
|
const selected = props.selected |
||||||
|
const [lastSelected, setLastSelected] = useState(selected) |
||||||
|
const tableId = tableIdentifier(props.table) |
||||||
|
const checkId = `selected-${tableId}` |
||||||
|
const changeCallback = useCallback((ev: Event) => { |
||||||
|
if (onSelectionChange && typeof props.table.id === "number") { |
||||||
|
onSelectionChange(props.table.id, (ev.currentTarget as HTMLInputElement).checked) |
||||||
|
} |
||||||
|
}, [onSelectionChange]) |
||||||
|
useEffect(() => { |
||||||
|
if (selected !== lastSelected) { |
||||||
|
setLastSelected(selected) |
||||||
|
if (ref.current) { |
||||||
|
pulseElement(ref.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [ref, selected, lastSelected, setLastSelected]) |
||||||
|
return <li class="generatedElement" id={`generated-${tableIdentifier(props.table)}`}> |
||||||
|
<h2 class="generatedHead"> |
||||||
|
<label |
||||||
|
class="generatedLabel tableHeader" |
||||||
|
{...(props.selected !== null ? {"for": checkId} : {})} |
||||||
|
{...TableHeaderDataset(props.table)}> |
||||||
|
<TableEmoji {...props.table} /> |
||||||
|
{' '} |
||||||
|
<TableTitle {...props.table} /> |
||||||
|
</label> |
||||||
|
<input type="checkbox" class={`generatedSelect${props.selected === null ? " unselectable" : ""}`} |
||||||
|
id={checkId} |
||||||
|
name={checkId} |
||||||
|
checked={props.selected ?? false} |
||||||
|
onChange={changeCallback} |
||||||
|
ref={ref} /> |
||||||
|
</h2> |
||||||
|
<GeneratedResult {...props} /> |
||||||
|
</li> |
||||||
|
} |
@ -0,0 +1,37 @@ |
|||||||
|
@import "Page"; |
||||||
|
@import "Button"; |
||||||
|
@import "usePopup"; |
||||||
|
|
||||||
|
#generator { |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
min-height: 100dvh; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
margin: 0; |
||||||
|
padding: 2rem; |
||||||
|
display: flex; |
||||||
|
box-sizing: border-box; |
||||||
|
flex-flow: column nowrap; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
} |
||||||
|
|
||||||
|
#generatorHead { |
||||||
|
margin-top: 0; |
||||||
|
user-select: text; |
||||||
|
} |
||||||
|
|
||||||
|
#generatedScenario { |
||||||
|
padding: 0; |
||||||
|
} |
||||||
|
|
||||||
|
#generator .buttons { |
||||||
|
margin-left: -0.3rem; |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
#copyButtons::before { |
||||||
|
content: "Copy as:"; |
||||||
|
margin: 0.2rem 0 0 0.3rem |
||||||
|
} |
@ -0,0 +1,172 @@ |
|||||||
|
|
||||||
|
import { FormButton, LinkButton } from './Button'; |
||||||
|
import { createContext, Fragment } from 'preact'; |
||||||
|
import { usePopup } from './usePopup'; |
||||||
|
import { useCallback, useContext } from 'preact/hooks'; |
||||||
|
import { ExportFormat, exportFormatToString } from '../rolltable'; |
||||||
|
import { |
||||||
|
GeneratedElement, |
||||||
|
type GeneratedElementProps, |
||||||
|
reconstituteGeneratedElement, |
||||||
|
} from './GeneratedElement'; |
||||||
|
import { IncludesResponses } from './ResponsesPage'; |
||||||
|
import { tableIdentifier } from './TableHeader'; |
||||||
|
|
||||||
|
export const IncludesGenerator = createContext(false) |
||||||
|
|
||||||
|
export interface GeneratorProps { |
||||||
|
generatorTargetUrl: string |
||||||
|
elements: GeneratedElementProps[] |
||||||
|
addToDiscordUrl: string|null |
||||||
|
editable: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export enum GeneratorSelect { |
||||||
|
All = "all", |
||||||
|
None = "none", |
||||||
|
} |
||||||
|
|
||||||
|
export enum GeneratorReroll { |
||||||
|
All = "all", |
||||||
|
Selected = "selected", |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteGenerator(element: HTMLDivElement, partial?: Partial<GeneratorProps>): GeneratorProps { |
||||||
|
const addToDiscordUrl = partial?.addToDiscordUrl ?? element.querySelector<HTMLAnchorElement>("#addToDiscord")?.href ?? null |
||||||
|
const editable = partial?.editable ?? !!element.querySelector("#rollButtons") |
||||||
|
const elements = partial?.elements ?? |
||||||
|
Array.from(element.querySelector<HTMLUListElement>("#generatedScenario")!.children) |
||||||
|
.map(li => reconstituteGeneratedElement(li as HTMLLIElement, editable ? {} : {selected: null})) |
||||||
|
|
||||||
|
const generatorTargetUrl = partial?.generatorTargetUrl ?? element.querySelector<HTMLFormElement>("#generatorWindow")!.action |
||||||
|
return { |
||||||
|
addToDiscordUrl, |
||||||
|
editable, |
||||||
|
generatorTargetUrl, |
||||||
|
elements: elements, |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export interface GeneratorEvents { |
||||||
|
onCopy?: (format: ExportFormat) => Promise<void> |
||||||
|
onReroll?: (which: GeneratorReroll) => Promise<void> |
||||||
|
onSelect?: (which: GeneratorSelect) => void |
||||||
|
onSelectionChange?: (tableId: number, selected: boolean) => void |
||||||
|
} |
||||||
|
|
||||||
|
enum GeneratorSelectionState { |
||||||
|
All = "All", |
||||||
|
Partial = "Some", |
||||||
|
None = "None" |
||||||
|
} |
||||||
|
|
||||||
|
export function GeneratorPage({ editable, generatorTargetUrl, addToDiscordUrl, onSelectionChange, onSelect, onReroll, onCopy, elements }: GeneratorProps & GeneratorEvents) { |
||||||
|
const includesResponses = useContext(IncludesResponses); |
||||||
|
const [copyPopupHost, showCopyPopup] = usePopup<HTMLDivElement>() |
||||||
|
const [rerollPopupHost, showRerollPopup] = usePopup<HTMLDivElement>() |
||||||
|
const copyWrapper = useCallback((format: ExportFormat) => { |
||||||
|
if (!onCopy) { |
||||||
|
console.error("No copy handler") |
||||||
|
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error') |
||||||
|
} |
||||||
|
onCopy(format).then(() => { |
||||||
|
return showCopyPopup(`Copied ${exportFormatToString(format)} to clipboard`) |
||||||
|
}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
return showCopyPopup(`Failed to copy ${exportFormatToString(format)} to clipboard`, 'error') |
||||||
|
}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
}) |
||||||
|
}, [showCopyPopup, onCopy]) |
||||||
|
const md = useCallback(() => copyWrapper(ExportFormat.Markdown), [copyWrapper]) |
||||||
|
const bb = useCallback(() => copyWrapper(ExportFormat.BBCode), [copyWrapper]) |
||||||
|
const emoji = useCallback(() => copyWrapper(ExportFormat.TextEmoji), [copyWrapper]) |
||||||
|
const text = useCallback(() => copyWrapper(ExportFormat.TextOnly), [copyWrapper]) |
||||||
|
const selected = elements.reduce<null|GeneratorSelectionState>((current, next) => { |
||||||
|
if (next.selected === null) { |
||||||
|
return current |
||||||
|
} |
||||||
|
switch (current) { |
||||||
|
case GeneratorSelectionState.Partial: |
||||||
|
return GeneratorSelectionState.Partial |
||||||
|
case GeneratorSelectionState.None: |
||||||
|
return next.selected ? GeneratorSelectionState.Partial : GeneratorSelectionState.None |
||||||
|
case GeneratorSelectionState.All: |
||||||
|
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.Partial |
||||||
|
case null: |
||||||
|
return next.selected ? GeneratorSelectionState.All : GeneratorSelectionState.None |
||||||
|
} |
||||||
|
}, null) |
||||||
|
const selectAll = useCallback((ev: Event) => { |
||||||
|
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||||
|
return |
||||||
|
} |
||||||
|
onSelect(GeneratorSelect.All) |
||||||
|
}, [onSelect, showRerollPopup]) |
||||||
|
const selectNone = useCallback((ev: Event) => { |
||||||
|
if (!onSelect || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||||
|
return |
||||||
|
} |
||||||
|
onSelect(GeneratorSelect.None) |
||||||
|
}, [onSelect, showRerollPopup]) |
||||||
|
const rerollSelected = useCallback((ev: Event) => { |
||||||
|
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||||
|
return |
||||||
|
} |
||||||
|
onReroll(GeneratorReroll.Selected).then(() => {}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
return showRerollPopup(`Failed to reroll`, 'error') |
||||||
|
}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
}) |
||||||
|
}, [onReroll, showRerollPopup]) |
||||||
|
const rerollAll = useCallback((ev: Event) => { |
||||||
|
if (!onReroll || (ev.currentTarget instanceof HTMLButtonElement && ev.currentTarget.disabled)) { |
||||||
|
return |
||||||
|
} |
||||||
|
onReroll(GeneratorReroll.All).then(() => {}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
return showRerollPopup(`Failed to reroll all`, 'error') |
||||||
|
}).catch((ex: unknown) => { |
||||||
|
console.error(ex) |
||||||
|
}) |
||||||
|
}, [onReroll, showRerollPopup]) |
||||||
|
|
||||||
|
return <div id="generator" class="page"> |
||||||
|
<form method="post" action={generatorTargetUrl} id="generatorWindow" class="window readable"> |
||||||
|
<h2 id="generatorHead">Your generated scenario</h2> |
||||||
|
<ul id="generatedScenario"> |
||||||
|
{elements.map(i => <GeneratedElement key={tableIdentifier(i.table)} {...i} onSelectionChange={onSelectionChange} />)} |
||||||
|
</ul> |
||||||
|
<div id="generatorControls"> |
||||||
|
<div ref={copyPopupHost} id="copyButtons" className="buttons requiresJs jsPopupHost"> |
||||||
|
<FormButton id="copyMD" type="button" onClick={onCopy && md} disabled={!onCopy}>Markdown</FormButton> |
||||||
|
<FormButton id="copyBB" type="button" onClick={onCopy && bb} disabled={!onCopy}>BBCode</FormButton> |
||||||
|
<FormButton id="copyEmojiText" type="button" onClick={onCopy && emoji} disabled={!onCopy}>Text + Emoji</FormButton> |
||||||
|
<FormButton id="copyText" type="button" onClick={onCopy && text} disabled={!onCopy}>Text Only</FormButton> |
||||||
|
</div> |
||||||
|
{editable ? <div ref={rerollPopupHost} id="rollButtons" class="buttons jsPopupHost"> |
||||||
|
<FormButton type="submit" id="reroll" name="submit" |
||||||
|
value="reroll" disabled={onReroll && (!selected || selected === GeneratorSelectionState.None)} onClick={onReroll && rerollSelected}> |
||||||
|
Reroll {selected === GeneratorSelectionState.All ? 'All' : 'Selected'} |
||||||
|
</FormButton> |
||||||
|
<FormButton type="button" id="selectAll" class="requiresJs" onClick={selectAll} disabled={!onSelect || selected === GeneratorSelectionState.All}>Select All</FormButton> |
||||||
|
<FormButton type="button" id="selectNone" class="requiresJs" onClick={selectNone} disabled={!onSelect || !selected || selected === GeneratorSelectionState.None}>Select None</FormButton> |
||||||
|
</div> : null} |
||||||
|
<div id="scenarioButtons" class="buttons"> |
||||||
|
{editable ? <Fragment> |
||||||
|
<LinkButton id="rerollAll" href={generatorTargetUrl} external={false} onClick={onReroll && rerollAll}>New Scenario</LinkButton> |
||||||
|
<FormButton id="saveScenario" name="submit" value="saveScenario" type="submit">Get Scenario Link</FormButton> |
||||||
|
</Fragment> : <Fragment> |
||||||
|
<LinkButton id="openInGenerator" href={generatorTargetUrl} external={false}>Open in Generator</LinkButton> |
||||||
|
</Fragment>} |
||||||
|
</div> |
||||||
|
{ addToDiscordUrl || includesResponses ? |
||||||
|
<div id="generatorLinks" class="buttons"> |
||||||
|
{addToDiscordUrl && <LinkButton external={true} id="addToDiscord" href={addToDiscordUrl}>Add to Discord</LinkButton>} |
||||||
|
{includesResponses ? <LinkButton external={false} id="goToResponses" href="#responses">View Possible Responses</LinkButton> : null} |
||||||
|
</div> : null} |
||||||
|
</div> |
||||||
|
</form> |
||||||
|
</div> |
||||||
|
} |
@ -0,0 +1,6 @@ |
|||||||
|
body { |
||||||
|
background-color: deepskyblue; |
||||||
|
font-family: sans-serif; |
||||||
|
padding: 0; |
||||||
|
margin: 0; |
||||||
|
} |
@ -0,0 +1,3 @@ |
|||||||
|
@import "./Main"; |
||||||
|
@import "./GeneratorPage"; |
||||||
|
@import "./PageFooter"; |
@ -0,0 +1,93 @@ |
|||||||
|
import { GeneratorPage, GeneratorSelect, IncludesGenerator } from './GeneratorPage'; |
||||||
|
import { PageFooter } from './PageFooter'; |
||||||
|
import type { GeneratedElementProps } from './GeneratedElement'; |
||||||
|
import { useCallback, useMemo, useState } from 'preact/hooks'; |
||||||
|
import { ExportFormat, exportScenario, RollSelections, type RollTable, type RollTableResult } from '../rolltable'; |
||||||
|
|
||||||
|
export interface GeneratorMainProps { |
||||||
|
editable: boolean |
||||||
|
|
||||||
|
generatorTargetUrl: string |
||||||
|
addToDiscordUrl: string |
||||||
|
creditsUrl: string |
||||||
|
|
||||||
|
initialResults: ReadonlyMap<RollTable, RollTableResult> |
||||||
|
initialSelected?: ReadonlySet<RollTable> |
||||||
|
} |
||||||
|
|
||||||
|
export interface GeneratorMainEvents { |
||||||
|
copyText?: (text: string) => Promise<void> |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: add a "reconstitute" function for MainGeneratorOnly
|
||||||
|
// TODO: add the other two top-level pages (MainResponsesOnly, MainGeneratorResponses) with "reconstitute" functions
|
||||||
|
// TODO: add the entry points that reconstitute and hydrate each of the respective top-level pages
|
||||||
|
|
||||||
|
function MainGeneratorOnly({ |
||||||
|
editable, generatorTargetUrl, addToDiscordUrl, |
||||||
|
creditsUrl, initialResults, initialSelected, copyText}: GeneratorMainProps & GeneratorMainEvents) { |
||||||
|
const [results, ] = |
||||||
|
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults) |
||||||
|
const [selected, setSelected] = |
||||||
|
useState<ReadonlySet<RollTable>|null>(initialSelected ?? null) |
||||||
|
const onCopy = useCallback(async (format: ExportFormat) => { |
||||||
|
if (!copyText) { |
||||||
|
return Promise.reject(Error("Copy functionality is not implemented")) |
||||||
|
} |
||||||
|
return copyText(exportScenario(Array.from(results.values()), format)) |
||||||
|
}, [copyText, results]) |
||||||
|
const onSelectionChange = useCallback((tableId: number, state: boolean) => { |
||||||
|
const table = Array.from(initialResults.keys()).find(table => table.full && table.id === tableId) |
||||||
|
if (!table) { |
||||||
|
return |
||||||
|
} |
||||||
|
const newSelection = new RollSelections(selected) |
||||||
|
if (state) { |
||||||
|
newSelection.add(table) |
||||||
|
} else { |
||||||
|
newSelection.delete(table) |
||||||
|
} |
||||||
|
setSelected(newSelection) |
||||||
|
}, [initialResults, selected, setSelected]) |
||||||
|
const onSelect = useCallback((select: GeneratorSelect) => { |
||||||
|
switch (select) { |
||||||
|
case GeneratorSelect.All: |
||||||
|
setSelected(new RollSelections(initialResults.keys())); |
||||||
|
break; |
||||||
|
case GeneratorSelect.None: |
||||||
|
setSelected(new RollSelections()); |
||||||
|
break; |
||||||
|
} |
||||||
|
}, [initialResults, setSelected]) |
||||||
|
const elements = useMemo(() => { |
||||||
|
const output: GeneratedElementProps[] = [] |
||||||
|
for (const result of results.values()) { |
||||||
|
if (result.full) { |
||||||
|
output.push({ |
||||||
|
...result, |
||||||
|
selected: selected === null ? null : selected.has(result.table) |
||||||
|
}) |
||||||
|
} else { |
||||||
|
output.push({ |
||||||
|
...result, |
||||||
|
selected: null, |
||||||
|
}) |
||||||
|
} |
||||||
|
} |
||||||
|
return output |
||||||
|
}, [results, selected]) |
||||||
|
return <IncludesGenerator.Provider value={true}> |
||||||
|
<GeneratorPage |
||||||
|
generatorTargetUrl={generatorTargetUrl} |
||||||
|
elements={elements} |
||||||
|
addToDiscordUrl={addToDiscordUrl} |
||||||
|
editable={editable} |
||||||
|
onCopy={onCopy} |
||||||
|
// TODO: implement onReroll using JSON fetch
|
||||||
|
// specifically: POST to the target URL as if you're submitting the form,
|
||||||
|
// _but_ add Accept: text/json to indicate you want it for API purposes and not as a page
|
||||||
|
onSelect={selected ? onSelect : undefined} |
||||||
|
onSelectionChange={selected ? onSelectionChange : undefined} /> |
||||||
|
<PageFooter creditsUrl={creditsUrl} /> |
||||||
|
</IncludesGenerator.Provider> |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
.window { |
||||||
|
background-color: #f8f7f0; |
||||||
|
padding: 1rem; |
||||||
|
border: 0.1rem solid black; |
||||||
|
border-radius: 0.5rem; |
||||||
|
box-sizing: border-box; |
||||||
|
} |
||||||
|
|
||||||
|
.page { |
||||||
|
} |
||||||
|
|
||||||
|
.page * { |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
.readable { |
||||||
|
width: 35rem; |
||||||
|
} |
||||||
|
|
||||||
|
|
@ -1,20 +1,21 @@ |
|||||||
import { Fragment } from 'preact'; |
import { Fragment } from 'preact'; |
||||||
|
import { IncludesResponses } from './ResponsesPage'; |
||||||
|
import { IncludesGenerator } from './GeneratorPage'; |
||||||
|
import { useContext } from 'preact/hooks'; |
||||||
|
|
||||||
export interface PageFooterProps { |
export interface PageFooterProps { |
||||||
creditsUrl: string |
creditsUrl: string |
||||||
includesResponses: boolean |
|
||||||
includesGenerator: boolean |
|
||||||
} |
} |
||||||
|
|
||||||
export function reconstituteFooterProps(footer: HTMLElement, partial: Partial<PageFooterProps> = {}): PageFooterProps { |
export function reconstituteFooterProps(footer: HTMLElement, partial: Partial<PageFooterProps> = {}): PageFooterProps { |
||||||
return { |
return { |
||||||
creditsUrl: partial.creditsUrl ?? footer.querySelector<HTMLAnchorElement>(".creditsLink")!.href, |
creditsUrl: partial.creditsUrl ?? footer.querySelector<HTMLAnchorElement>(".creditsLink")!.href, |
||||||
includesResponses: partial.includesResponses ?? footer.querySelector<HTMLElement>(".jsOffHint") !== null, |
|
||||||
includesGenerator: partial.includesGenerator ?? footer.querySelector<HTMLElement>(".saveHint") !== null, |
|
||||||
} |
} |
||||||
} |
} |
||||||
|
|
||||||
export function PageFooter({creditsUrl, includesGenerator, includesResponses}: PageFooterProps) { |
export function PageFooter({creditsUrl}: PageFooterProps) { |
||||||
|
const includesGenerator = useContext(IncludesGenerator) |
||||||
|
const includesResponses = useContext(IncludesResponses) |
||||||
return <footer> |
return <footer> |
||||||
{includesGenerator |
{includesGenerator |
||||||
? <Fragment> |
? <Fragment> |
@ -0,0 +1,36 @@ |
|||||||
|
@import "ResultText"; |
||||||
|
@import "Attribution"; |
||||||
|
|
||||||
|
.response { |
||||||
|
margin-top: 0.3rem; |
||||||
|
display: flex; |
||||||
|
align-items: stretch; |
||||||
|
flex-flow: row nowrap; |
||||||
|
scroll-margin-top: 12rem; |
||||||
|
list-style: none; |
||||||
|
} |
||||||
|
|
||||||
|
.response.active { |
||||||
|
position: relative; |
||||||
|
min-height: 1.5rem; |
||||||
|
|
||||||
|
&::before { |
||||||
|
width: 1rem; |
||||||
|
margin: 0.2rem 0.2rem 0.2rem 0.5rem; |
||||||
|
content: ""; |
||||||
|
flex: 0 0 auto; |
||||||
|
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; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,68 @@ |
|||||||
|
import { reconstituteResultText, ResultText, type ResultTextPropsFull } from './ResultText'; |
||||||
|
import { |
||||||
|
Attribution, |
||||||
|
type AttributionPropsFull, |
||||||
|
type PartialAttributionPropsFull, reconstituteAttribution |
||||||
|
} from './Attribution'; |
||||||
|
import { FormButton } from './Button'; |
||||||
|
import { IncludesGenerator } from './GeneratorPage'; |
||||||
|
import { useCallback, useContext, useEffect, useState } from 'preact/hooks'; |
||||||
|
import { type Context, createContext, createRef } from 'preact'; |
||||||
|
import { pulseElement } from './pulseElement'; |
||||||
|
|
||||||
|
export const CurrentSelectedResponse: Context<number|null> = createContext<number|null>(null) |
||||||
|
|
||||||
|
export interface ResponseElementProps { |
||||||
|
attribution: AttributionPropsFull |
||||||
|
result: ResultTextPropsFull |
||||||
|
selected: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export interface ResponseElementEvents { |
||||||
|
onSelected?: (mappingId: number) => void |
||||||
|
} |
||||||
|
|
||||||
|
export interface PartialResponseElementProps { |
||||||
|
attribution?: PartialAttributionPropsFull |
||||||
|
result?: Partial<ResultTextPropsFull> |
||||||
|
selected?: boolean |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteResponseElement(element: HTMLLIElement, partial?: PartialResponseElementProps): ResponseElementProps { |
||||||
|
const result = reconstituteResultText(element.querySelector<HTMLButtonElement>(".resultText")!, partial?.result) |
||||||
|
const attribution = reconstituteAttribution(element.querySelector<HTMLDivElement>(".attribution")!, partial?.attribution) |
||||||
|
return { |
||||||
|
result: result as ResultTextPropsFull, |
||||||
|
attribution: attribution as AttributionPropsFull, |
||||||
|
selected: partial?.selected ?? element.classList.contains("active") |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export const responseIdPrefix="response-" |
||||||
|
|
||||||
|
export function ResponseElement({attribution, result, selected, onSelected}: ResponseElementProps & ResponseElementEvents) { |
||||||
|
const includesGenerator = useContext(IncludesGenerator); |
||||||
|
const [lastSelected, setLastSelected] = useState(selected) |
||||||
|
const onSelect = useCallback(() => { |
||||||
|
if (onSelected) { |
||||||
|
onSelected(result.mappingId) |
||||||
|
} |
||||||
|
}, [attribution, result, onSelected]) |
||||||
|
const ref = createRef<HTMLLIElement>() |
||||||
|
useEffect(() => { |
||||||
|
if (lastSelected !== selected) { |
||||||
|
setLastSelected(selected) |
||||||
|
if (ref.current) { |
||||||
|
pulseElement(ref.current) |
||||||
|
} |
||||||
|
} |
||||||
|
}, [selected, lastSelected, setLastSelected, ref]); |
||||||
|
return <li ref={ref} id={responseIdPrefix + result.mappingId} class={`response attributed${selected ? " active" : ""}`}> |
||||||
|
<ResultText {...result} /> |
||||||
|
<Attribution {...attribution}> |
||||||
|
{includesGenerator |
||||||
|
? <FormButton type={"button"} class="makeResponseActive requiresJs" onClick={onSelect}>Set in Generated Scenario</FormButton> |
||||||
|
: null} |
||||||
|
</Attribution> |
||||||
|
</li> |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
@import "ResponseElement"; |
||||||
|
@import "Page"; |
||||||
|
@import "TableHeader"; |
||||||
|
|
||||||
|
.responseType { |
||||||
|
list-style: none; |
||||||
|
padding: 1rem; |
||||||
|
scroll-margin-top: 10rem; |
||||||
|
margin-top: 0.5rem; |
||||||
|
margin-left: 1rem; |
||||||
|
margin-bottom: 0.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
.responseType > h2 { |
||||||
|
margin-top: 0; |
||||||
|
} |
||||||
|
|
||||||
|
.responseTypeHead { |
||||||
|
position: sticky; |
||||||
|
top: 9.4rem; |
||||||
|
background-color: inherit; |
||||||
|
z-index: 1; |
||||||
|
padding-bottom: 0.2rem; |
||||||
|
} |
||||||
|
|
||||||
|
.responseTypeTitle { |
||||||
|
flex: 1 1 auto; |
||||||
|
} |
||||||
|
|
||||||
|
.responseTypeList { |
||||||
|
padding: 0; |
||||||
|
} |
@ -0,0 +1,79 @@ |
|||||||
|
import { |
||||||
|
reconstituteTable, |
||||||
|
TableEmoji, |
||||||
|
type TableFullProps, |
||||||
|
TableHeaderDataset, |
||||||
|
tableIdentifier, |
||||||
|
TableTitle |
||||||
|
} from './TableHeader'; |
||||||
|
import { |
||||||
|
reconstituteResponseElement, |
||||||
|
ResponseElement, |
||||||
|
type ResponseElementProps, responseIdPrefix |
||||||
|
} from './ResponseElement'; |
||||||
|
import { useCallback } from 'preact/hooks'; |
||||||
|
|
||||||
|
export interface ResponseTypeProps { |
||||||
|
table: TableFullProps, |
||||||
|
selectedMappingId: number | null, |
||||||
|
contents: Omit<ResponseElementProps, 'selected'>[], |
||||||
|
} |
||||||
|
|
||||||
|
export interface PartialResponseTypeProps { |
||||||
|
table?: Partial<TableFullProps>; |
||||||
|
selectedMappingId?: number | null; |
||||||
|
contents?: Omit<ResponseElementProps, 'selected'>[]; |
||||||
|
} |
||||||
|
|
||||||
|
export function reconstituteResponseType(element: HTMLLIElement, partial?: PartialResponseTypeProps): ResponseTypeProps { |
||||||
|
const table = reconstituteTable(element.querySelector('.tableHeader')!, partial?.table) as TableFullProps; |
||||||
|
let selected: number | null | undefined = partial?.selectedMappingId ?? null, |
||||||
|
contents: Omit<ResponseElementProps, 'selected'>[] | undefined = partial?.contents; |
||||||
|
if (!contents) { |
||||||
|
contents = []; |
||||||
|
for (const child of Array.from(element.querySelector('.responseTypeList')!.children) as HTMLLIElement[]) { |
||||||
|
const childContents = reconstituteResponseElement(child); |
||||||
|
if (typeof selected === 'undefined' && childContents.selected) { |
||||||
|
selected = childContents.result.mappingId; |
||||||
|
} |
||||||
|
contents.push(childContents); |
||||||
|
} |
||||||
|
if (typeof selected === 'undefined') { |
||||||
|
selected = null; |
||||||
|
} |
||||||
|
} else if (typeof selected === 'undefined') { |
||||||
|
const active = element.querySelector('.response.active'); |
||||||
|
selected = active ? parseInt(active.id.substring(responseIdPrefix.length)) : null; |
||||||
|
} |
||||||
|
return { |
||||||
|
table, |
||||||
|
selectedMappingId: selected, |
||||||
|
contents |
||||||
|
}; |
||||||
|
} |
||||||
|
|
||||||
|
export interface ResponseTypeEvents { |
||||||
|
onSelectResponse?: (tableId: number, mappingId: number) => void |
||||||
|
} |
||||||
|
|
||||||
|
export const responseListIdPrefix = 'responses-'; |
||||||
|
|
||||||
|
export function ResponseType({ table, selectedMappingId, contents, onSelectResponse }: ResponseTypeProps & ResponseTypeEvents) { |
||||||
|
const onSelectChild = useCallback((mappingId: number) => { |
||||||
|
if (onSelectResponse) { |
||||||
|
onSelectResponse(table.id, mappingId) |
||||||
|
} |
||||||
|
}, [onSelectResponse]); |
||||||
|
return <li id={responseListIdPrefix + tableIdentifier(table)} class="responseType window readable"> |
||||||
|
<h2 class="responseTypeHead tableHeader" {...TableHeaderDataset(table)}> |
||||||
|
<TableEmoji emoji={table.emoji} />{' '}<TableTitle title={table.title} /> |
||||||
|
</h2> |
||||||
|
<ul class="responseTypeList"> |
||||||
|
{contents.map(result => |
||||||
|
<ResponseElement key={result.result.mappingId} |
||||||
|
selected={result.result.mappingId === selectedMappingId} |
||||||
|
onSelected={onSelectChild} |
||||||
|
{...result} />)} |
||||||
|
</ul> |
||||||
|
</li>; |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
@import "Page"; |
||||||
|
@import "ResponseType"; |
||||||
|
|
||||||
|
#responsesHeader { |
||||||
|
position: sticky; |
||||||
|
display: flex; |
||||||
|
flex-flow: column; |
||||||
|
align-items: center; |
||||||
|
border-top: 0; |
||||||
|
border-left: 0; |
||||||
|
border-right: 0; |
||||||
|
border-radius: 0; |
||||||
|
margin: 0; |
||||||
|
top: 0; |
||||||
|
left: 0; |
||||||
|
right: 0; |
||||||
|
height: 9.5rem; |
||||||
|
z-index: 2; |
||||||
|
} |
||||||
|
|
||||||
|
#responsesHeader .buttons { |
||||||
|
display: flex; |
||||||
|
flex-flow: row wrap; |
||||||
|
padding-top: 0.2rem; |
||||||
|
padding-left: 0.3rem; |
||||||
|
padding-right: 0.3rem; |
||||||
|
margin: 0; |
||||||
|
overflow-y: auto; |
||||||
|
overflow-x: visible; |
||||||
|
} |
||||||
|
|
||||||
|
#returnToGenerator { |
||||||
|
flex-basis: 50%; |
||||||
|
} |
||||||
|
|
||||||
|
.responseNavEmoji { |
||||||
|
margin-right: 0.2rem; |
||||||
|
} |
||||||
|
|
||||||
|
#responsesHead { |
||||||
|
margin-top: 0; |
||||||
|
margin-bottom: 0; |
||||||
|
font-size: 1.5rem; |
||||||
|
} |
||||||
|
|
||||||
|
#responseLists { |
||||||
|
display: flex; |
||||||
|
flex-flow: row wrap; |
||||||
|
padding: 0.1rem; |
||||||
|
justify-content: center; |
||||||
|
} |
@ -0,0 +1,42 @@ |
|||||||
|
import { createContext } from 'preact'; |
||||||
|
import { responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType'; |
||||||
|
import { IncludesGenerator } from './GeneratorPage'; |
||||||
|
import { LinkButton } from './Button'; |
||||||
|
import { TableEmoji, tableIdentifier, TableName } from './TableHeader'; |
||||||
|
import { useContext } from 'preact/hooks'; |
||||||
|
|
||||||
|
export const IncludesResponses = createContext(false) |
||||||
|
|
||||||
|
export interface ResponsesProps { |
||||||
|
types: ResponseTypeProps[] |
||||||
|
} |
||||||
|
|
||||||
|
export interface ResponsesEvents { |
||||||
|
onSelectResponse: (tableId: number, mappingId: number) => void |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: add a "reconstitute" function for ResponsesPage
|
||||||
|
|
||||||
|
export function ResponsesPage({ types, onSelectResponse }: ResponsesProps & ResponsesEvents) { |
||||||
|
const includesGenerator = useContext(IncludesGenerator); |
||||||
|
return <div id="responses" class="page"> |
||||||
|
<header id="responsesHeader" class="window"> |
||||||
|
<h1 id="responsesHead">Possible Responses</h1> |
||||||
|
<nav id="responsesHeaderNav" class="buttons"> |
||||||
|
{types.map(type => |
||||||
|
<LinkButton key={tableIdentifier(type.table)} href={`#${responseListIdPrefix}${tableIdentifier(type.table)}`} external={false}> |
||||||
|
<TableEmoji emoji={type.table.emoji} />{' '}<TableName name={type.table.name} /> |
||||||
|
</LinkButton>)} |
||||||
|
{includesGenerator |
||||||
|
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton> |
||||||
|
: null} |
||||||
|
</nav> |
||||||
|
</header> |
||||||
|
<ul id="responseLists"> |
||||||
|
{types.map(type => |
||||||
|
<ResponseType key={tableIdentifier(type.table)} |
||||||
|
onSelectResponse={onSelectResponse} |
||||||
|
{...type} />)} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
} |
@ -0,0 +1,34 @@ |
|||||||
|
import { type MutableRef, useCallback, useRef } from 'preact/hooks'; |
||||||
|
|
||||||
|
export function usePopup<T extends HTMLElement>(): [hostRef: MutableRef<T|null>, triggerPopup: (text: string, className?: 'success'|'info'|'warning'|'error') => Promise<boolean>] { |
||||||
|
const hostRef = useRef<T|null>(null) |
||||||
|
const showPopup = useCallback(async (text: string, className?: 'success'|'info'|'warning'|'error'): Promise<boolean> => { |
||||||
|
const host = hostRef.current |
||||||
|
if (!host) { |
||||||
|
return false |
||||||
|
} |
||||||
|
if (!host.classList.contains("jsPopupHost")) { |
||||||
|
throw Error("host must be jsPopupHost") |
||||||
|
} |
||||||
|
const container = host.ownerDocument.createElement("div") |
||||||
|
container.classList.add("jsPopupContainer") |
||||||
|
host.appendChild(container) |
||||||
|
const popup = host.ownerDocument.createElement("div") |
||||||
|
popup.classList.add("jsPopup") |
||||||
|
if (className) { |
||||||
|
popup.classList.add(className) |
||||||
|
} |
||||||
|
popup.innerText = text |
||||||
|
container.appendChild(popup) |
||||||
|
return new Promise((resolve) => { |
||||||
|
const removePopup = () => { |
||||||
|
container.removeChild(popup) |
||||||
|
host.removeChild(container) |
||||||
|
resolve(true) |
||||||
|
} |
||||||
|
popup.addEventListener('animationend', removePopup) |
||||||
|
popup.addEventListener('animationcancel', removePopup) |
||||||
|
}) |
||||||
|
}, [hostRef]) |
||||||
|
return [hostRef, showPopup] |
||||||
|
} |
@ -0,0 +1 @@ |
|||||||
|
|
@ -1,134 +0,0 @@ |
|||||||
import { |
|
||||||
type RollTable, |
|
||||||
type RollTableDetails, |
|
||||||
type RollTableDetailsAndResults, |
|
||||||
type RollTableResult |
|
||||||
} from './rolltable'; |
|
||||||
|
|
||||||
// TODO: port the rest of these to preact
|
|
||||||
|
|
||||||
export function buildGeneratorPage<T, BuilderT extends TemplateBuilder<T>>( |
|
||||||
{ results, generatorTargetUrl, clientId, creditsUrl, editable, selected, includesResponses, builder }: |
|
||||||
{ readonly results: ReadonlyMap<RollTable, RollTableResult>, |
|
||||||
readonly generatorTargetUrl: string, |
|
||||||
readonly clientId: string, |
|
||||||
readonly creditsUrl: string, |
|
||||||
readonly editable: boolean, |
|
||||||
readonly selected: ReadonlySet<RollTable>, |
|
||||||
readonly includesResponses: boolean, |
|
||||||
readonly builder: BuilderT}): ReturnType<BuilderT["makeDiv"]> { |
|
||||||
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<BuilderT["makeDiv"]> |
|
||||||
} |
|
||||||
|
|
||||||
export function buildResponseTypeButton<T, BuilderT extends TemplateBuilder<T>>({table, builder}: {readonly table: RollTableDetails, readonly builder: BuilderT}): ReturnType<BuilderT["makeHyperlink"]> { |
|
||||||
return builder.makeHyperlink({ |
|
||||||
url: `#responses-${htmlTableIdentifier(table)}`, |
|
||||||
destination: HyperlinkDestination.Internal, |
|
||||||
asButton: true, |
|
||||||
}, builder.makeText(`${table.emoji} ${table.name}`)) as ReturnType<BuilderT["makeHyperlink"]> |
|
||||||
} |
|
||||||
|
|
||||||
export function buildResponse<T, BuilderT extends TemplateBuilder<T>>({result, active, includesGenerator, builder}: {readonly result: RollTableResult, readonly active: boolean, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType<BuilderT["makeListItem"]> { |
|
||||||
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<BuilderT["makeListItem"]> |
|
||||||
} |
|
||||||
|
|
||||||
export function buildResponseList<T, BuilderT extends TemplateBuilder<T>>({table, activeResult, includesGenerator, builder}: {readonly table: RollTableDetailsAndResults, readonly activeResult?: RollTableResult, readonly includesGenerator: boolean, readonly builder: BuilderT}): ReturnType<BuilderT["makeListItem"]> { |
|
||||||
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<BuilderT["makeListItem"]> |
|
||||||
} |
|
||||||
|
|
||||||
export function buildResponsesPage<T, BuilderT extends TemplateBuilder<T>>( |
|
||||||
{ tables, results, creditsUrl, includesGenerator, builder }: { |
|
||||||
readonly tables: Iterable<RollTableDetailsAndResults>, |
|
||||||
readonly results?: ReadonlyMap<RollTable, RollTableResult>, |
|
||||||
readonly creditsUrl: string, |
|
||||||
readonly includesGenerator: boolean, |
|
||||||
readonly builder: BuilderT}): ReturnType<BuilderT["makeDiv"]> { |
|
||||||
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<BuilderT["makeDiv"]> |
|
||||||
} |
|
@ -1,33 +0,0 @@ |
|||||||
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 <a |
|
||||||
class={`button${className ? " " + className : ""}`} |
|
||||||
href={href} |
|
||||||
{...(external ? {rel: "external nofollow noreferrer"} : {})} |
|
||||||
draggable={false}> |
|
||||||
{children} |
|
||||||
</a> |
|
||||||
} else { |
|
||||||
return <button type={type} class={`button${className ? " " + className : ""}`}> |
|
||||||
{children} |
|
||||||
</button> |
|
||||||
} |
|
||||||
} |
|
@ -1,30 +0,0 @@ |
|||||||
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 <li class="generatedElement" id={`generated-${tableIdentifier(props.table)}`}> |
|
||||||
<h2 class="generatedHead"> |
|
||||||
<label |
|
||||||
class="generatedLabel tableHeader" |
|
||||||
{...(props.selected !== null ? {"for": checkId} : {})} |
|
||||||
{...TableHeaderDataset(props.table)}> |
|
||||||
<TableEmoji {...props.table} /> |
|
||||||
{' '} |
|
||||||
<TableTitle {...props.table} /> |
|
||||||
</label> |
|
||||||
{props.selected !== null |
|
||||||
? <input type="checkbox" class="generatedSelect" id={checkId} name={checkId} checked={props.selected} /> |
|
||||||
: null} |
|
||||||
</h2> |
|
||||||
<GeneratedResult {...props} /> |
|
||||||
</li> |
|
||||||
} |
|
@ -1,2 +0,0 @@ |
|||||||
// TODO: move this to the response file
|
|
||||||
export const responseIdPrefix = "response-" |
|
@ -1,131 +0,0 @@ |
|||||||
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("")}</${tagName}>` |
|
||||||
} |
|
||||||
|
|
||||||
class StringTemplateBuilderImpl implements TemplateBuilder<string> { |
|
||||||
|
|
||||||
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() |
|
Loading…
Reference in new issue