preact port nearing completion

main
Mari 3 weeks ago
parent f0a46781de
commit ae1c05270f
  1. 66
      src/client/basic-look.less
  2. 4
      src/client/combined-generator-responses-entrypoint-old.ts
  3. 6
      src/client/combined-generator-responses-entrypoint.less
  4. 2
      src/client/generator-entrypoint-old.ts
  5. 38
      src/client/generator-entrypoint.less
  6. 19
      src/client/popup.ts
  7. 2
      src/client/responses-entrypoint-old.ts
  8. 113
      src/client/responses-entrypoint.less
  9. 305
      src/client/scraper.ts
  10. 142
      src/client/template.ts
  11. 16
      src/client/types/customevent.d.ts
  12. 0
      src/common/client/Attribution.less
  13. 2
      src/common/client/Attribution.tsx
  14. 0
      src/common/client/AttributionAuthor.less
  15. 0
      src/common/client/AttributionAuthor.tsx
  16. 0
      src/common/client/AttributionSet.less
  17. 0
      src/common/client/AttributionSet.tsx
  18. 11
      src/common/client/Button.less
  19. 46
      src/common/client/Button.tsx
  20. 0
      src/common/client/GeneratedElement.less
  21. 103
      src/common/client/GeneratedElement.tsx
  22. 0
      src/common/client/GeneratedResult.less
  23. 30
      src/common/client/GeneratedResult.tsx
  24. 37
      src/common/client/GeneratorPage.less
  25. 172
      src/common/client/GeneratorPage.tsx
  26. 6
      src/common/client/Main.less
  27. 3
      src/common/client/MainGeneratorOnly.less
  28. 93
      src/common/client/MainGeneratorOnly.tsx
  29. 20
      src/common/client/Page.less
  30. 0
      src/common/client/PageFooter.less
  31. 11
      src/common/client/PageFooter.tsx
  32. 36
      src/common/client/ResponseElement.less
  33. 68
      src/common/client/ResponseElement.tsx
  34. 32
      src/common/client/ResponseType.less
  35. 79
      src/common/client/ResponseType.tsx
  36. 51
      src/common/client/ResponsesPage.less
  37. 42
      src/common/client/ResponsesPage.tsx
  38. 0
      src/common/client/ResultText.less
  39. 19
      src/common/client/ResultText.tsx
  40. 0
      src/common/client/TableHeader.less
  41. 48
      src/common/client/TableHeader.tsx
  42. 0
      src/common/client/pulseElement.less
  43. 0
      src/common/client/pulseElement.ts
  44. 22
      src/common/client/usePopup.less
  45. 34
      src/common/client/usePopup.ts
  46. 1
      src/common/client/util.ts
  47. 13
      src/common/rolltable.ts
  48. 134
      src/common/template.ts
  49. 33
      src/common/template/Button.tsx
  50. 30
      src/common/template/GeneratedElement.tsx
  51. 2
      src/common/template/util.ts
  52. 6
      src/server/web/router.ts
  53. 131
      src/server/web/template.ts

@ -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 {prepareGenerator} from './generator-entrypoint'
import {responseLists, db} from './responses-entrypoint-old'
import {prepareGenerator} from './generator-entrypoint-old'
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => {
res.addSelectionListener((ev) => {

@ -1,5 +1,7 @@
@import "generator-entrypoint";
@import "responses-entrypoint";
@import "../common/client/GeneratorPage";
@import "../common/client/ResponsesPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";
#generator:not(:target) {
display: none;

@ -16,7 +16,7 @@ import {
import { buildGenerated, htmlTableIdentifier } from '../common/template';
import { DOMLoaded } from './onload';
import { scrapeGeneratedScenario } from './scraper';
import { showPopup } from './popup';
import { showPopup } from './Popup';
import { pulseElement } from './pulse';
import { DOMTemplateBuilder } from './template';
import escapeHTML from 'escape-html';

@ -1,35 +1,3 @@
@import "basic-look";
@import "popup";
@import "pulse";
#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
}
@import "../common/client/GeneratorPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";

@ -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)
})
}

@ -74,7 +74,7 @@ function initResponseList(): ResponseLists {
if (!lists) {
throw Error(`can't parse #responseLists`)
}
const {db, active} = lists
const {db} = lists
return new ResponseLists(db, listsElement).configureHandlers()
}

@ -1,110 +1,3 @@
@import "basic-look";
@import "attribution";
@import "popup";
#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;
}
}
@import "../common/client/ResponsesPage";
@import "../common/client/Page";
@import "../common/client/PageFooter";

@ -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;
}
}

@ -3,14 +3,12 @@ import type {
} 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';

@ -31,3 +31,14 @@
transform: none;
}
}
.buttons {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: stretch;
& > * {
flex: 1 0 auto;
margin: 0.2rem 0 0 0.3rem
}
}

@ -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>
}

@ -4,33 +4,27 @@ import {
type PartialAttributionProps, type PartialAttributionPropsEmpty,
reconstituteAttribution,
} from './Attribution';
import { responseIdPrefix } from './util';
import {
reconstituteResultText,
ResultText,
type ResultTextPropsFull,
type ResultTextPropsLimited
} from './ResultText';
import { Button } from './Button';
import { LinkButton } from './Button';
import { responseIdPrefix } from './ResponseElement';
import { IncludesResponses } from './ResponsesPage';
import { useContext } from 'preact/hooks';
export type GeneratedResultPropsFull = {
includesResponses: boolean
} & AttributionPropsFull & ResultTextPropsFull
export type GeneratedResultPropsFull = AttributionPropsFull & ResultTextPropsFull
export type GeneratedResultPropsLimited = {
includesResponses?: null
} & AttributionPropsEmpty & ResultTextPropsLimited
export type GeneratedResultPropsLimited = AttributionPropsEmpty & ResultTextPropsLimited
export type GeneratedResultProps = GeneratedResultPropsFull|GeneratedResultPropsLimited
export type PartialGeneratedResultPropsFull = {
includesResponses?: boolean
} & PartialAttributionProps & Partial<ResultTextPropsFull>
export type PartialGeneratedResultPropsFull = PartialAttributionProps & Partial<ResultTextPropsFull>
export type PartialGeneratedResultPropsLimited = {
includesResponses?: null
} & PartialAttributionPropsEmpty & Partial<ResultTextPropsLimited>
export type PartialGeneratedResultPropsLimited = PartialAttributionPropsEmpty & Partial<ResultTextPropsLimited>
export type PartialGeneratedResultProps = PartialGeneratedResultPropsFull|PartialGeneratedResultPropsLimited
@ -41,23 +35,23 @@ export function reconstituteGeneratedResult(div: HTMLDivElement, partial?: Parti
reconstituteAttribution(div.querySelector(".attribution")!, partial)
if (result.updated && attribution.set) {
return {
includesResponses: !!div.querySelector(".jumpToResponse"),
...attribution,
...result,
}
} else {
return {
text: result.text
...result as ResultTextPropsLimited,
}
}
}
export function GeneratedResult(props: GeneratedResultProps) {
const includesResponses = useContext(IncludesResponses);
return <div class="generatedResult attributed">
<ResultText {...props} />
<Attribution {...props}>
{props.includesResponses
? <p><Button class={"jumpToResponse"} href={`#${responseIdPrefix}${props.mappingId}`}>Jump to Result in List</Button></p>
{includesResponses && props.set
? <p><LinkButton class={"jumpToResponse"} href={`#${responseIdPrefix}${props.mappingId}`}>Jump to Result in List</LinkButton></p>
: null}
</Attribution>
</div>

@ -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 { IncludesResponses } from './ResponsesPage';
import { IncludesGenerator } from './GeneratorPage';
import { useContext } from 'preact/hooks';
export interface PageFooterProps {
creditsUrl: string
includesResponses: boolean
includesGenerator: boolean
}
export function reconstituteFooterProps(footer: HTMLElement, partial: Partial<PageFooterProps> = {}): PageFooterProps {
return {
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>
{includesGenerator
? <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>
}

@ -1,3 +1,6 @@
import { useEffect, useRef, useState } from 'preact/hooks';
import { pulseElement } from './pulseElement';
export interface ResultTextPropsBase {
text: string
}
@ -14,6 +17,8 @@ export interface ResultTextPropsLimited extends ResultTextPropsBase {
updated?: null
}
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited
export function reconstituteResultText(button: HTMLButtonElement, partial: Partial<ResultTextProps> = {}): ResultTextProps {
const text = button.innerText
if (typeof partial.mappingId ?? button.dataset["mappingId"] === "undefined") {
@ -28,10 +33,18 @@ export function reconstituteResultText(button: HTMLButtonElement, partial: Parti
}
}
export type ResultTextProps = ResultTextPropsFull|ResultTextPropsLimited
export function ResultText({text, mappingId, textId, updated}: ResultTextProps) {
return <button className="resultText"
const ref = useRef<HTMLButtonElement>(null)
const [lastText, setLastText] = useState<string>(text)
useEffect(() => {
if (text !== lastText) {
setLastText(text)
if (ref.current) {
pulseElement(ref.current)
}
}
}, [ref, text, lastText, setLastText]);
return <button className="resultText" ref={ref}
{...(updated
? { "data-mapping-id": mappingId, "data-text-id": textId, "data-updated": updated.getTime() }
: {})}>{text}</button>

@ -1,10 +1,17 @@
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 function reconstituteTable(element: HTMLElement, partial?: Partial<TableProps>): TableProps {
return {
...reconstituteTableHeader(element.dataset, partial),
...reconstituteTableEmoji(element.querySelector(".tableEmoji")!, partial),
...reconstituteTableTitle(element.querySelector(".tableTitle")!, partial),
}
}
export interface TableIdentifierFullProps {
identifier: string
title: string
@ -41,13 +48,28 @@ export interface TableHeaderLimitedProps {
export type TableHeaderProps = TableHeaderFullProps|TableHeaderLimitedProps
export function reconstituteTableHeader(dataset: DOMStringMap, partial?: Partial<TableHeaderProps>): TableHeaderProps {
if ((partial?.id ?? null) !== null || "id" in dataset) {
return {
ordinal: partial?.ordinal ?? parseInt(dataset["ordinal"]!),
id: partial?.id ?? parseInt(dataset["id"]!),
name: partial?.name ?? dataset["name"]!,
identifier: partial?.identifier ?? dataset["identifier"]!
}
} else {
return {
ordinal: partial?.ordinal ?? parseInt(dataset["ordinal"]!)
}
}
}
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
"data-identifier": identifier,
}
} else {
return {
@ -60,6 +82,12 @@ export interface TableEmojiProps {
emoji: string
}
export function reconstituteTableEmoji(element: HTMLSpanElement, partial?: Partial<TableEmojiProps>): TableEmojiProps {
return {
emoji: partial?.emoji ?? element.innerText
}
}
export function TableEmoji({emoji}: TableEmojiProps) {
return <span class="tableEmoji">{emoji}</span>
}
@ -68,6 +96,20 @@ export interface TableTitleProps {
title: string
}
export function reconstituteTableTitle(element: HTMLSpanElement, partial?: Partial<TableTitleProps>): TableTitleProps {
return {
title: partial?.title ?? element.innerText
}
}
export function TableTitle({title}: TableTitleProps) {
return <span class="tableTitle">{title}</span>
}
export interface TableNameProps {
name: string
}
export function TableName({name}: TableNameProps) {
return <span class="tableName">{name}</span>
}

@ -44,3 +44,25 @@
.jsPopupHost {
position: relative;
}
@keyframes popup {
from {
transform: scale(0);
opacity: 0;
}
10% {
transform: none;
opacity: 100%;
}
75% {
transform: none;
opacity: 100%;
}
to {
transform: scale(0);
opacity: 0;
}
}

@ -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]
}

@ -434,6 +434,19 @@ export enum ExportFormat {
TextOnly = "text",
}
export function exportFormatToString(format: ExportFormat): string {
switch (format) {
case ExportFormat.Markdown:
return "Markdown"
case ExportFormat.BBCode:
return "BBCode"
case ExportFormat.TextEmoji:
return "text with emoji"
case ExportFormat.TextOnly:
return "text"
}
}
export function exportResult(result: RollTableResult, format: ExportFormat): string {
switch (format) {
case ExportFormat.Markdown:

@ -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,6 +1,5 @@
import { type IRequestStrict, Router } from 'itty-router';
import type { Database } from '../db/database';
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';
@ -45,6 +44,9 @@ export function webRouter(base: string) {
async function handleMainPage(req: IRequestStrict, env: WebEnv, db: Database): Promise<string> {
const results = await db.getGeneratorPageForDiscordSet(
getQuerySingleton(req.query['server'], takeLast) ?? null);
// TODO: use SSR with the Main components here
// TODO: handle POSTs by rerolling and redisplaying appropriately - redirect to a GET with text IDs listed
// TODO: support json output here
const generator = buildGeneratorPage({
creditsUrl: env.CREDITS_URL,
clientId: env.DISCORD_APP_ID,
@ -73,12 +75,14 @@ export function webRouter(base: string) {
}
const router = Router<IRequestStrict, [env: WebEnv, db: Database, ctx: ExecutionContext]>({ base })
.get('/responses', async (req, _env, _db, _ctx) => {
// TODO: make this actually just the responses
const url = new URL(req.url);
url.pathname = base;
url.hash = '#responses';
return Response.redirect(url.toString(), 303);
})
.get('/generator', async (req, _env, _db, _ctx) => {
// TODO: make this actually just the generator
const url = new URL(req.url);
url.pathname = base;
url.hash = '#generator';

@ -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…
Cancel
Save