parent
ae1c05270f
commit
b54a67a5cd
@ -0,0 +1,37 @@ |
||||
-- Migration number: 0006 2024-07-01T08:24:03.736Z |
||||
CREATE TABLE IF NOT EXISTS userAuditLog ( |
||||
changeId INTEGER PRIMARY KEY, |
||||
userId INTEGER NOT NULL, |
||||
newName TEXT NULL, |
||||
newUrl TEXT NULL, |
||||
timestamp INTEGER NOT NULL |
||||
) STRICT; |
||||
|
||||
CREATE INDEX IF NOT EXISTS userUpdateTimestamp ON userAuditLog (userId, timestamp DESC); |
||||
|
||||
CREATE TABLE IF NOT EXISTS setAuditLog ( |
||||
changeId INTEGER PRIMARY KEY, |
||||
userId INTEGER NOT NULL, |
||||
setId INTEGER NOT NULL, |
||||
newName TEXT NULL, |
||||
newDescription TEXT NULL, |
||||
updateType INTEGER NOT NULL, |
||||
timestamp INTEGER NOT NULL |
||||
) STRICT; |
||||
|
||||
CREATE INDEX IF NOT EXISTS setUpdateTimestamp ON setAuditLog (setId, timestamp DESC); |
||||
CREATE INDEX IF NOT EXISTS setUpdateTimestampByUser ON setAuditLog (userId, timestamp DESC); |
||||
|
||||
CREATE TABLE IF NOT EXISTS resultAuditLog ( |
||||
changeId INTEGER PRIMARY KEY, |
||||
mappingId INTEGER NOT NULL, |
||||
userId INTEGER NOT NULL, |
||||
setId INTEGER NOT NULL, |
||||
newResultId INTEGER NULL, |
||||
updateType INTEGER NOT NULL, |
||||
timestamp INTEGER NOT NULL |
||||
) STRICT; |
||||
|
||||
CREATE INDEX IF NOT EXISTS resultUpdateTimestampByMapping ON resultAuditLog (mappingId, timestamp DESC); |
||||
CREATE INDEX IF NOT EXISTS resultUpdateTimestampBySet ON resultAuditLog (setId, timestamp DESC); |
||||
CREATE INDEX IF NOT EXISTS resultUpdateTimestampByUser ON resultAuditLog (userId, timestamp DESC); |
File diff suppressed because it is too large
Load Diff
After Width: | Height: | Size: 64 KiB |
@ -1,16 +1,19 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
"module": "commonjs", |
||||
"module": "ESNext", |
||||
"esModuleInterop": true, |
||||
"allowSyntheticDefaultImports": true, |
||||
"target": "ESNext", |
||||
"strict": true, |
||||
"noEmit": true, |
||||
"noImplicitAny": true, |
||||
"moduleResolution": "node", |
||||
"allowSyntheticDefaultImports": true, |
||||
"allowJs": true, |
||||
"moduleResolution": "Bundler", |
||||
"sourceMap": true, |
||||
"baseUrl": "./" |
||||
"baseUrl": "./", |
||||
"importsNotUsedAsValues": "remove", |
||||
}, |
||||
"include": [ |
||||
"*" |
||||
"./*" |
||||
] |
||||
} |
||||
|
@ -1,25 +0,0 @@ |
||||
import {responseLists, db} from './responses-entrypoint-old' |
||||
import {prepareGenerator} from './generator-entrypoint-old' |
||||
|
||||
Promise.all([prepareGenerator(db), responseLists]).then(([gen, res]) => { |
||||
res.addSelectionListener((ev) => { |
||||
gen.setActiveResult(ev.detail, true) |
||||
}) |
||||
gen.addRerollListener((ev) => { |
||||
for (const result of ev.detail.changedResults) { |
||||
res.setActiveElementForTable(result) |
||||
} |
||||
}) |
||||
console.info("connected generator and response list") |
||||
}).catch((e) => { |
||||
console.error(e) |
||||
}) |
||||
|
||||
function updateHash(): void { |
||||
if (location.hash === "" || location.hash === "#" || !location.hash) { |
||||
location.replace("#generator") |
||||
} |
||||
} |
||||
|
||||
window.addEventListener("hashchange", updateHash) |
||||
updateHash() |
@ -0,0 +1,9 @@ |
||||
@import "../common/client/MainGeneratorAndResponses.css"; |
||||
|
||||
#generator:not(:target) { |
||||
display: none; |
||||
} |
||||
|
||||
#generator:target ~ #responses { |
||||
display: none; |
||||
} |
@ -1,12 +0,0 @@ |
||||
@import "../common/client/GeneratorPage"; |
||||
@import "../common/client/ResponsesPage"; |
||||
@import "../common/client/Page"; |
||||
@import "../common/client/PageFooter"; |
||||
|
||||
#generator:not(:target) { |
||||
display: none; |
||||
} |
||||
|
||||
#generator:target ~ #responses { |
||||
display: none; |
||||
} |
@ -0,0 +1,15 @@ |
||||
import { DOMLoaded } from './onload.js'; |
||||
import { createElement, hydrate } from 'preact'; |
||||
import { |
||||
MainGeneratorAndResponses, |
||||
type MainGeneratorAndResponsesProps, reconstituteMainGeneratorAndResponses |
||||
} from '../common/client/MainGeneratorAndResponses.js'; |
||||
|
||||
DOMLoaded.then(() => { |
||||
const props: MainGeneratorAndResponsesProps = { |
||||
...reconstituteMainGeneratorAndResponses(document.querySelector("div#mainGeneratorAndResponses")!), |
||||
} |
||||
hydrate(createElement(MainGeneratorAndResponses, props), document.body) |
||||
}).catch((ex) => { |
||||
console.error(ex) |
||||
}) |
@ -1,294 +0,0 @@ |
||||
import { |
||||
ExportFormat, |
||||
exportScenario, |
||||
type GeneratedState, |
||||
generatedStateToString, |
||||
getResultFrom, |
||||
RolledValues, |
||||
rollOn, |
||||
RollSelections, |
||||
type RollTable, |
||||
RollTableDatabase, |
||||
type RollTableDetailsAndResults, |
||||
type RollTableResult, |
||||
type RollTableResultFull |
||||
} from '../common/rolltable'; |
||||
import { buildGenerated, htmlTableIdentifier } from '../common/template'; |
||||
import { DOMLoaded } from './onload'; |
||||
import { scrapeGeneratedScenario } from './scraper'; |
||||
import { showPopup } from './Popup'; |
||||
import { pulseElement } from './pulse'; |
||||
import { DOMTemplateBuilder } from './template'; |
||||
import escapeHTML from 'escape-html'; |
||||
|
||||
export interface RerollEventDetail { |
||||
rerolledAll: boolean |
||||
changedResults: Iterable<RollTableResultFull<RollTableDetailsAndResults>> |
||||
fullResults: ReadonlyMap<RollTable, RollTableResult> |
||||
selections: ReadonlySet<RollTable> |
||||
} |
||||
|
||||
export class Generator { |
||||
readonly generator: HTMLElement; |
||||
readonly scenario: HTMLUListElement; |
||||
readonly copyButtons: HTMLElement; |
||||
readonly rollButtons: HTMLElement; |
||||
readonly db: RollTableDatabase | undefined; |
||||
private readonly rolled = new RolledValues(); |
||||
private readonly selected = new RollSelections(); |
||||
|
||||
get state(): GeneratedState { |
||||
return { |
||||
final: false, |
||||
rolled: this.rolled, |
||||
selected: this.selected, |
||||
} |
||||
} |
||||
|
||||
getTableWithHtmlId(id: string, prefix?: string): RollTable | undefined { |
||||
return Array.from(this.rolled.keys()).find(t => id === ((prefix ?? '') + htmlTableIdentifier(t))); |
||||
} |
||||
|
||||
selectAll(): this { |
||||
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:not(:checked)') as Iterable<HTMLInputElement>) { |
||||
check.checked = true; |
||||
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) |
||||
const table = this.getTableWithHtmlId(check.id, 'selected-'); |
||||
if (table) { |
||||
this.selected.add(table); |
||||
} |
||||
} |
||||
return this |
||||
} |
||||
|
||||
selectNone(): this { |
||||
this.selected.clear(); |
||||
for (const check of this.scenario.querySelectorAll('input[type=checkbox]:checked') as Iterable<HTMLInputElement>) { |
||||
check.checked = false; |
||||
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) |
||||
} |
||||
return this |
||||
} |
||||
|
||||
reroll(all: boolean): this { |
||||
if (!this.db) { |
||||
return this |
||||
} |
||||
const changes: RollTableResultFull<RollTableDetailsAndResults>[] = [] |
||||
for (const row of this.scenario.querySelectorAll(`.generatedElement`)) { |
||||
const check = row.querySelector<HTMLInputElement>(`input.generatedSelect:checked`) |
||||
const text = row.querySelector<HTMLElement>(`.resultText`) |
||||
if ((all || check) && text) { |
||||
let result = this.db.mappings.get(parseInt(text.dataset["mappingid"] ?? '-1')) |
||||
if (!result || result.table.resultsById.size === 1) { |
||||
continue |
||||
} |
||||
const origResult = result |
||||
const table = result.table |
||||
while (result === origResult) { |
||||
result = rollOn(table) |
||||
} |
||||
this.setActiveResult(result, all) |
||||
changes.push(result) |
||||
pulseElement(text) |
||||
} |
||||
} |
||||
this.generator.dispatchEvent(new CustomEvent<RerollEventDetail>("reroll", { |
||||
composed: false, |
||||
bubbles: true, |
||||
cancelable: false, |
||||
detail: { |
||||
rerolledAll: all, |
||||
changedResults: changes, |
||||
fullResults: this.rolled, |
||||
selections: this.selected, |
||||
} |
||||
})) |
||||
return this |
||||
} |
||||
|
||||
loadValuesFromDOM(): this { |
||||
this.rolled.clear() |
||||
this.selected.clear() |
||||
const scenario = scrapeGeneratedScenario(this.scenario) |
||||
if (!scenario) { |
||||
throw Error("Failed to load generated values from DOM") |
||||
} |
||||
for (const [scrapedTable, scrapedResult] of scenario.rolled) { |
||||
const table = this.db?.getTableMatching(scrapedTable) ?? scrapedTable |
||||
const result = getResultFrom(table, scrapedResult) |
||||
if (scenario.selected.has(scrapedTable)) { |
||||
this.selected.add(table) |
||||
} |
||||
this.rolled.add(result) |
||||
} |
||||
return this |
||||
} |
||||
|
||||
private getGeneratedElementForTable(table: RollTable): HTMLElement|null { |
||||
return this.scenario.querySelector(`#generated-${escapeHTML(htmlTableIdentifier(table))}`) |
||||
} |
||||
|
||||
private replaceResultInElement(result: RollTableResult, generatedElement: HTMLElement) { |
||||
// sister function is buildGeneratedElement
|
||||
const generatedDiv = generatedElement.querySelector(`.generated`) |
||||
if (!generatedDiv) { |
||||
throw Error(`couldn't find .generated in replaceResultInElement`) |
||||
} |
||||
generatedDiv.replaceWith(buildGenerated({result, includesResponses: !!this.db, builder: DOMTemplateBuilder})) |
||||
const button = generatedElement.querySelector<HTMLButtonElement>(`.resultText`)! |
||||
pulseElement(button) |
||||
this.rolled.add(result) |
||||
} |
||||
|
||||
private changeSelection(generatedElement: HTMLElement, selected: boolean) { |
||||
const check = generatedElement.querySelector<HTMLInputElement>(`.generatedSelect`) |
||||
if (!check) { |
||||
return |
||||
} |
||||
if (check.checked !== selected) { |
||||
check.checked = selected |
||||
check.dispatchEvent<"change">(new Event("change", {cancelable: true, bubbles: true, composed: false})) |
||||
} |
||||
} |
||||
|
||||
async copy(format: ExportFormat): Promise<void> { |
||||
const exported = exportScenario(Array.from(this.rolled.values()), format) |
||||
return navigator.clipboard.writeText(exported) |
||||
} |
||||
|
||||
attachHandlers(): this { |
||||
this.generator.addEventListener('click', (e) => this.clickHandler(e)); |
||||
this.generator.addEventListener('change', (e) => this.changeHandler(e)); |
||||
this.generator.querySelector<HTMLButtonElement>(`#reroll`)!.disabled = (this.selected.size === 0) |
||||
return this; |
||||
} |
||||
|
||||
private clickHandler(e: Event): void { |
||||
if (e.target instanceof HTMLButtonElement || e.target instanceof HTMLAnchorElement) { |
||||
switch (e.target.id) { |
||||
case "selectNone": |
||||
this.selectNone() |
||||
break |
||||
case "selectAll": |
||||
this.selectAll() |
||||
break |
||||
case "copyMD": |
||||
this.copy(ExportFormat.Markdown) |
||||
.then(() => showPopup(this.copyButtons, `Copied Markdown to clipboard!`, 'success')) |
||||
.catch((e) => { |
||||
console.error("Failed while copying Markdown:", e) |
||||
showPopup(this.copyButtons, `Failed to copy Markdown to clipboard`, 'error') |
||||
}) |
||||
break |
||||
case "copyBB": |
||||
this.copy(ExportFormat.BBCode) |
||||
.then(() => showPopup(this.copyButtons, `Copied BBCode to clipboard!`, 'success')) |
||||
.catch((e) => { |
||||
console.error("Failed while copying BBCode:", e) |
||||
showPopup(this.copyButtons, `Failed to copy BBCode to clipboard`, 'error') |
||||
}) |
||||
break |
||||
case "copyEmojiText": |
||||
this.copy(ExportFormat.TextEmoji) |
||||
.then(() => showPopup(this.copyButtons, `Copied text (with emojis) to clipboard!`, 'success')) |
||||
.catch((e) => { |
||||
console.error("Failed while copying text (with emojis):", e) |
||||
showPopup(this.copyButtons, `Failed to copy text (with emojis) to clipboard`, 'error') |
||||
}) |
||||
break |
||||
case "copyText": |
||||
this.copy(ExportFormat.TextOnly) |
||||
.then(() => showPopup(this.copyButtons, `Copied text to clipboard!`, 'success')) |
||||
.catch((e) => { |
||||
console.error("Failed while copying text:", e) |
||||
showPopup(this.copyButtons, `Failed to copy text to clipboard`, 'error') |
||||
}) |
||||
break |
||||
case "reroll": |
||||
this.reroll(false) |
||||
break |
||||
case "rerollAll": |
||||
this.reroll(true) |
||||
break |
||||
default: |
||||
return |
||||
} |
||||
e.preventDefault() |
||||
} |
||||
} |
||||
|
||||
private changeHandler(e: Event): void { |
||||
if (e.target instanceof HTMLInputElement && e.target.type === 'checkbox' && e.target.id.startsWith('selected-')) { |
||||
const check = e.target |
||||
const table = this.getTableWithHtmlId(check.id, 'selected-'); |
||||
if (table) { |
||||
if (check.checked) { |
||||
this.selected.add(table); |
||||
} else { |
||||
this.selected.delete(table); |
||||
} |
||||
this.generator.querySelector<HTMLButtonElement>(`#reroll`)!.disabled = (this.selected.size === 0) |
||||
pulseElement(check) |
||||
} |
||||
} |
||||
} |
||||
|
||||
constructor(generator: HTMLElement, generatorForm: HTMLUListElement, copyButtons: HTMLElement, rollButtons: HTMLElement, db?: RollTableDatabase) { |
||||
this.generator = generator; |
||||
this.scenario = generatorForm; |
||||
this.copyButtons = copyButtons; |
||||
this.rollButtons = rollButtons; |
||||
this.db = db; |
||||
} |
||||
|
||||
setActiveResult(result: RollTableResultFull<RollTableDetailsAndResults>, clearSelection?: boolean) { |
||||
const tableElement = this.getGeneratedElementForTable(result.table) |
||||
if (!tableElement) { |
||||
return |
||||
} |
||||
this.replaceResultInElement(result, tableElement) |
||||
if (clearSelection) { |
||||
this.changeSelection(tableElement, false) |
||||
} |
||||
} |
||||
|
||||
addRerollListener(listener: (ev: CustomEvent<RerollEventDetail>) => void, options?: boolean|EventListenerOptions): void { |
||||
this.generator.addEventListener('reroll', listener, options) |
||||
} |
||||
} |
||||
|
||||
function initGenerator(db?: RollTableDatabase): Generator { |
||||
const generatorFound = document.getElementById('generator'); |
||||
if (!generatorFound) { |
||||
throw Error('generator was not found'); |
||||
} |
||||
const generatedScenarioFound = document.getElementById('generatedScenario'); |
||||
if (!generatedScenarioFound || !(generatedScenarioFound instanceof HTMLUListElement)) { |
||||
throw Error('generated scenario was not found'); |
||||
} |
||||
const copyButtons = document.getElementById("copyButtons") |
||||
if (!copyButtons) { |
||||
throw Error('copy buttons were not found') |
||||
} |
||||
const rollButtons = document.getElementById("rollButtons") |
||||
if (!rollButtons) { |
||||
throw Error('roll buttons were not found') |
||||
} |
||||
return new Generator(generatorFound, generatedScenarioFound, copyButtons, rollButtons, db).loadValuesFromDOM().attachHandlers(); |
||||
} |
||||
|
||||
let pendingGenerator: Promise<Generator>|undefined = undefined |
||||
|
||||
export async function prepareGenerator(db?: Promise<RollTableDatabase>): Promise<Generator> { |
||||
if (pendingGenerator) { |
||||
throw Error(`prepareGenerator should only be called once`) |
||||
} |
||||
pendingGenerator = DOMLoaded.then(() => db) |
||||
.then((promisedDb) => initGenerator(promisedDb)) |
||||
return pendingGenerator |
||||
} |
||||
|
||||
DOMLoaded.then(() => pendingGenerator ?? prepareGenerator()) |
||||
.then(g => console.info(`loaded generator:\n${generatedStateToString(g.state)}`)) |
||||
.catch(e => console.error('failed to load generator', e)) |
@ -0,0 +1,2 @@ |
||||
@import "../common/client/MainGeneratorOnly.css"; |
||||
/* empty file, just for redirecting the build process to point into the common files */ |
@ -1,3 +0,0 @@ |
||||
@import "../common/client/GeneratorPage"; |
||||
@import "../common/client/Page"; |
||||
@import "../common/client/PageFooter"; |
@ -0,0 +1,16 @@ |
||||
import { DOMLoaded } from './onload.js'; |
||||
import { |
||||
type MainGeneratorProps, |
||||
MainGeneratorOnly, |
||||
reconstituteMainGeneratorOnly |
||||
} from '../common/client/MainGeneratorOnly.js'; |
||||
import { createElement, hydrate } from 'preact'; |
||||
|
||||
DOMLoaded.then(() => { |
||||
const props: MainGeneratorProps = { |
||||
...reconstituteMainGeneratorOnly(document.querySelector("div#mainGeneratorOnly")!), |
||||
} |
||||
hydrate(createElement(MainGeneratorOnly, props), document.body) |
||||
}).catch((ex) => { |
||||
console.error(ex) |
||||
}) |
@ -1,82 +0,0 @@ |
||||
import type { RollTableDatabase, RollTableDetailsAndResults, RollTableResultFull } from '../common/rolltable'; |
||||
import { DOMLoaded } from './onload'; |
||||
import { scrapeResponseLists } from './scraper'; |
||||
import { htmlTableIdentifier } from '../common/template'; |
||||
import escapeHTML from 'escape-html'; |
||||
|
||||
class ResponseLists { |
||||
readonly db: RollTableDatabase |
||||
readonly listsElement: HTMLElement |
||||
constructor(db: RollTableDatabase, listsElement: HTMLElement) { |
||||
this.db = db |
||||
this.listsElement = listsElement |
||||
} |
||||
|
||||
addSelectionListener(listener: (e: CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>) => void, options?: boolean|EventListenerOptions): void { |
||||
this.listsElement.addEventListener("resultselected", listener, options) |
||||
} |
||||
|
||||
configureHandlers(): this { |
||||
this.listsElement.addEventListener("click", (e) => { |
||||
if (e.target instanceof HTMLElement && e.target.classList.contains("makeResponseActive")) { |
||||
const response = e.target.closest(`.response`) |
||||
if (!response) { |
||||
console.log("no response") |
||||
return |
||||
} |
||||
const mappingId = response.id && response.id.startsWith("response-") ? parseInt(response.id.substring("response-".length), 10) : NaN |
||||
if (isNaN(mappingId)) { |
||||
console.log("no mapping ID") |
||||
return |
||||
} |
||||
const result = this.db.mappings.get(mappingId) |
||||
if (!result) { |
||||
console.log("no result") |
||||
return |
||||
} |
||||
const ev = new CustomEvent<RollTableResultFull<RollTableDetailsAndResults>>("resultselected", { |
||||
bubbles: true, |
||||
cancelable: true, |
||||
detail: result |
||||
}) |
||||
if (e.target.dispatchEvent(ev)) { |
||||
this.setActiveElementForTable(result) |
||||
const button = response.querySelector(`.resultText`) as HTMLElement |
||||
if (button) { |
||||
button.focus() |
||||
} |
||||
} |
||||
} |
||||
}) |
||||
return this |
||||
} |
||||
|
||||
setActiveElementForTable(result: RollTableResultFull<RollTableDetailsAndResults>) { |
||||
const oldActive = this.listsElement.querySelector(`#responses-${escapeHTML(htmlTableIdentifier(result.table))} .response.active`) |
||||
const newActive = this.listsElement.querySelector(`#response-${escapeHTML(`${result.mappingId}`)}`) |
||||
if (!newActive || oldActive === newActive) { |
||||
return |
||||
} |
||||
newActive.classList.add("active") |
||||
if (!oldActive) { |
||||
return |
||||
} |
||||
oldActive.classList.remove("active") |
||||
} |
||||
} |
||||
|
||||
function initResponseList(): ResponseLists { |
||||
const listsElement = document.querySelector<HTMLElement>(`#responseLists`) |
||||
if (!listsElement) { |
||||
throw Error(`can't find #responseLists`) |
||||
} |
||||
const lists = scrapeResponseLists(listsElement) |
||||
if (!lists) { |
||||
throw Error(`can't parse #responseLists`) |
||||
} |
||||
const {db} = lists |
||||
return new ResponseLists(db, listsElement).configureHandlers() |
||||
} |
||||
|
||||
export const responseLists: Promise<ResponseLists> = DOMLoaded.then(() => initResponseList()) |
||||
export const db: Promise<RollTableDatabase> = responseLists.then(r => r.db) |
@ -0,0 +1 @@ |
||||
@import "../common/client/MainResponsesOnly.css"; |
@ -1,3 +0,0 @@ |
||||
@import "../common/client/ResponsesPage"; |
||||
@import "../common/client/Page"; |
||||
@import "../common/client/PageFooter"; |
@ -0,0 +1,8 @@ |
||||
import { DOMLoaded } from './onload.js'; |
||||
import { MainResponsesOnly, reconstituteMainResponsesOnly } from '../common/client/MainResponsesOnly.js'; |
||||
import { createElement, hydrate } from 'preact'; |
||||
|
||||
DOMLoaded.then(() => { |
||||
const props = reconstituteMainResponsesOnly(document.querySelector("div#mainResponsesOnly")!) |
||||
hydrate(createElement(MainResponsesOnly, props), document.body) |
||||
}) |
@ -1,5 +1,6 @@ |
||||
@import "Attribution"; |
||||
@import "ResultText"; |
||||
@import "Attribution.css"; |
||||
@import "pulseElement.css"; |
||||
@import "ResultText.css"; |
||||
|
||||
.generatedResult { |
||||
margin: 0; |
@ -0,0 +1,13 @@ |
||||
@import "Main.css"; |
||||
@import "GeneratorPage.css"; |
||||
@import "ResponsesPage.css"; |
||||
@import "PageFooter.css"; |
||||
|
||||
#mainGeneratorAndResponses { |
||||
display: flex; |
||||
flex-flow: column; |
||||
justify-content: center; |
||||
align-content: center; |
||||
min-height: 100dvh; |
||||
min-width: 100dvw; |
||||
} |
@ -0,0 +1,364 @@ |
||||
import { |
||||
GeneratorFormAction, |
||||
type GeneratorFormInfo, |
||||
GeneratorPage, |
||||
GeneratorSelect, |
||||
type GeneratorSubmitResult, |
||||
reconstituteGenerator |
||||
} from './GeneratorPage.js'; |
||||
import { PageFooter, reconstituteFooterProps } from './PageFooter.js'; |
||||
import { deserializeLimitedText, type GeneratedElementProps, serializeLimitedText } from './GeneratedElement.js'; |
||||
import { useCallback, useMemo, useState } from 'preact/hooks'; |
||||
import { |
||||
ExportFormat, |
||||
exportScenario, |
||||
RolledValues, |
||||
rollOnAll, |
||||
RollSelections, |
||||
type RollTable, |
||||
RollTableDatabase, |
||||
type RollTableDetailsAndResults, |
||||
type RollTableResult |
||||
} from '../rolltable.js'; |
||||
import { useHistoryState } from './useHistory.js'; |
||||
import { reconstituteResponses, ResponsesPage, ResponsesTarget } from './ResponsesPage.js'; |
||||
import type { ResponseTypeProps } from './ResponseType.js'; |
||||
import { copyText } from './useClipboard.js'; |
||||
import { IncludesGenerator, IncludesResponses } from './contexts.js'; |
||||
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js'; |
||||
|
||||
export interface MainGeneratorAndResponsesProps { |
||||
targetUrl: string |
||||
addToDiscordUrl: string|null |
||||
creditsUrl: string |
||||
|
||||
initialEditable: boolean |
||||
database: RollTableDatabase |
||||
initialResults: ReadonlyMap<RollTable, RollTableResult> |
||||
initialSelected: ReadonlySet<RollTableDetailsAndResults>|null |
||||
} |
||||
|
||||
export function parseDatabaseFrom({responseTypes, generatorElements, editable}: { |
||||
responseTypes: ResponseTypeProps[], |
||||
generatorElements: GeneratedElementProps[], |
||||
editable: boolean, |
||||
}): { |
||||
database: RollTableDatabase, |
||||
values: ReadonlyMap<RollTable, RollTableResult>, |
||||
selections: ReadonlySet<RollTableDetailsAndResults>|null |
||||
} { |
||||
const database = new RollTableDatabase() |
||||
for (const responseType of responseTypes) { |
||||
const table = database.addTable({ |
||||
full: 'details', |
||||
id: responseType.table.id, |
||||
identifier: responseType.table.identifier, |
||||
emoji: responseType.table.emoji, |
||||
title: responseType.table.title, |
||||
ordinal: responseType.table.ordinal, |
||||
header: `${responseType.table.emoji} ${responseType.table.title}`, |
||||
name: responseType.table.name, |
||||
}) |
||||
for (const response of responseType.contents) { |
||||
database.addResult({ |
||||
full: true, |
||||
text: response.result.text, |
||||
textId: response.result.textId, |
||||
mappingId: response.result.mappingId, |
||||
author: response.attribution.author, |
||||
set: (response.attribution.set && { |
||||
name: response.attribution.set.name, |
||||
id: response.attribution.set.id, |
||||
description: null, |
||||
global: response.attribution.set.global, |
||||
}), |
||||
table: table, |
||||
}) |
||||
} |
||||
} |
||||
const values = new RolledValues() |
||||
const selections = editable ? new RollSelections<RollTableDetailsAndResults>() : null |
||||
for (const element of generatorElements) { |
||||
const table: RollTable = (typeof element.table.id === 'number' ? database.tables.get(element.table.id) : null) ?? { |
||||
full: false, |
||||
header: `${element.table.emoji} ${element.table.title}`, |
||||
emoji: element.table.emoji, |
||||
title: element.table.title, |
||||
ordinal: element.table.ordinal, |
||||
} |
||||
const result: RollTableResult = (typeof element.textId === 'number' && table.full === "results" |
||||
? table.resultsById.get(element.textId) |
||||
: null) ?? { |
||||
full: false, |
||||
table, |
||||
text: element.text, |
||||
textId: element.textId, |
||||
} |
||||
values.add(result) |
||||
if (selections && element.selected && table.full === "results") { |
||||
selections.add(table) |
||||
} |
||||
} |
||||
return { |
||||
database, |
||||
values, |
||||
selections, |
||||
} |
||||
} |
||||
|
||||
export function reconstituteMainGeneratorAndResponses( |
||||
element: HTMLDivElement, partial?: Partial<MainGeneratorAndResponsesProps>): MainGeneratorAndResponsesProps { |
||||
const {creditsUrl} = reconstituteFooterProps(element.querySelector("footer")!, partial) |
||||
const {generatorTargetUrl, addToDiscordUrl, editable, elements: generatorElements} = |
||||
reconstituteGenerator(element.querySelector<HTMLDivElement>("#generator")!) |
||||
const {types: responseTypes} = |
||||
reconstituteResponses(element.querySelector<HTMLFormElement>("#responses")!) |
||||
|
||||
const {database, values, selections} = parseDatabaseFrom({responseTypes, generatorElements, editable}) |
||||
|
||||
return { |
||||
targetUrl: generatorTargetUrl, |
||||
addToDiscordUrl, |
||||
creditsUrl, |
||||
|
||||
database, |
||||
initialEditable: editable, |
||||
initialResults: values, |
||||
initialSelected: selections, |
||||
} |
||||
} |
||||
|
||||
export function MainGeneratorAndResponses({ |
||||
initialEditable, targetUrl, addToDiscordUrl, database, |
||||
creditsUrl, initialResults, initialSelected, |
||||
}: MainGeneratorAndResponsesProps) { |
||||
const [results, setResults] = |
||||
useState<ReadonlyMap<RollTable, RollTableResult>>(initialResults) |
||||
const [selected, setSelected] = |
||||
useState<ReadonlySet<RollTableDetailsAndResults>|null>(initialSelected) |
||||
const [editable, setEditable] = useState(initialEditable) |
||||
const [currentUrl, setCurrentUrl] = useState<URL|null>(null) |
||||
const responsesBase: Omit<ResponseTypeProps, "selectedTextId">[] = useMemo(() => |
||||
[...database.tables.values()].map((table) => ({ |
||||
table: { |
||||
name: table.name, |
||||
emoji: table.emoji, |
||||
ordinal: table.ordinal, |
||||
id: table.id, |
||||
title: table.title, |
||||
identifier: table.identifier, |
||||
}, |
||||
contents: [...table.resultsById.values()].map(result => ({ |
||||
attribution: { |
||||
set: result.set, |
||||
author: result.author |
||||
}, |
||||
result: { |
||||
text: result.text, |
||||
textId: result.textId, |
||||
mappingId: result.mappingId, |
||||
} |
||||
})) |
||||
})), [database]) |
||||
const responsesWithSelections: ResponseTypeProps[] = useMemo(() => |
||||
responsesBase.map(table => ({ |
||||
...table, |
||||
selectedTextId: results.get(database.tables.get(table.table.id)!)?.textId ?? null |
||||
})), [responsesBase, database, results]) |
||||
const generatedElements: GeneratedElementProps[] = useMemo(() => |
||||
[...results.values()].map(result => |
||||
result.full |
||||
? { |
||||
text: result.text, |
||||
textId: result.textId, |
||||
mappingId: result.mappingId, |
||||
set: result.set, |
||||
author: result.author, |
||||
selected: editable && result.table.full === "results" ? selected?.has(result.table) ?? null : null, |
||||
table: result.table, |
||||
} : { |
||||
text: result.text, |
||||
selected: null, |
||||
table: result.table, |
||||
set: null, |
||||
author: null, |
||||
textId: result.textId, |
||||
mappingId: null, |
||||
}), [results, selected]) |
||||
const historyKey = useMemo(() => |
||||
[...results.values()].map(result => result.textId ?? serializeLimitedText(result.table, result.text)) |
||||
, [results]) |
||||
const historyState = useMemo(() => { |
||||
return {selected: selected && [...selected].map(t => t.id), values: historyKey} |
||||
}, [historyKey, selected]) |
||||
const onHistoryState = useCallback((state: unknown, url: URL) => { |
||||
// TODO: validate that this is in fact one of the states that this version of the page created,
|
||||
// or at least is parseable by it
|
||||
const {values, selected} = state as {selected: number[]|null, values: (string|number)[]} |
||||
const results = new RolledValues() |
||||
for (const value of values) { |
||||
if (typeof value === 'string') { |
||||
const serialized = deserializeLimitedText(value) |
||||
const table: RollTable|undefined = serialized.id === null |
||||
? { |
||||
full: false, |
||||
title: serialized.title, |
||||
ordinal: serialized.ordinal, |
||||
emoji: serialized.emoji, |
||||
header: `${serialized.emoji} ${serialized.title}`, |
||||
} : database.tables.get(serialized.id) |
||||
if (!table) { |
||||
console.error(`invalid table id in history ${serialized.id}`) |
||||
continue |
||||
} |
||||
results.add({ |
||||
full: false, |
||||
table, |
||||
text: serialized.text, |
||||
textId: null, |
||||
}) |
||||
} else { |
||||
const result = database.results.get(value) |
||||
if (!result) { |
||||
console.error(`invalid text id in history ${value}`) |
||||
continue |
||||
} |
||||
results.add(result) |
||||
} |
||||
} |
||||
setResults(results) |
||||
if (selected) { |
||||
const selections = new RollSelections<RollTableDetailsAndResults>() |
||||
for (const selection of selected) { |
||||
const table = database.tables.get(selection) |
||||
if (!table) { |
||||
console.error(`invalid table id in history ${selection}`) |
||||
continue |
||||
} |
||||
selections.add(table) |
||||
} |
||||
setSelected(selections) |
||||
} |
||||
setCurrentUrl(url) |
||||
}, [setResults, setSelected, setCurrentUrl, database]) |
||||
useHistoryState({ |
||||
state: historyState, |
||||
key: historyKey, |
||||
url: currentUrl, |
||||
onState: onHistoryState, |
||||
}) |
||||
const onCopy = useCallback(async (format: ExportFormat) => { |
||||
return copyText(exportScenario(Array.from(results.values()), format)) |
||||
}, [results]) |
||||
const onSelectionChange = useCallback((tableId: number, state: boolean) => { |
||||
const table = database.tables.get(tableId) |
||||
if (!table) { |
||||
return |
||||
} |
||||
const newSelection = new RollSelections(selected) |
||||
if (state) { |
||||
newSelection.add(table) |
||||
} else { |
||||
newSelection.delete(table) |
||||
} |
||||
setSelected(newSelection) |
||||
}, [database, selected, setSelected]) |
||||
const onSelect = useCallback((select: GeneratorSelect) => { |
||||
switch (select) { |
||||
case GeneratorSelect.All: |
||||
setSelected(new RollSelections(database.tables.values())); |
||||
break; |
||||
case GeneratorSelect.None: |
||||
setSelected(new RollSelections()); |
||||
break; |
||||
} |
||||
}, [database, setSelected]) |
||||
const onGeneratorSubmitted = useCallback((formData: GeneratorFormInfo): GeneratorSubmitResult => { |
||||
switch (formData.action) { |
||||
case GeneratorFormAction.GoToOffline: |
||||
return { |
||||
allowSubmit: false, |
||||
status: Promise.reject(Error("Already in the offline version!")) |
||||
} |
||||
case GeneratorFormAction.GoToResponses: |
||||
return { |
||||
allowSubmit: false, |
||||
status: Promise.reject(Error("Already in a version with responses!")) |
||||
} |
||||
case GeneratorFormAction.OpenInGenerator: |
||||
// TODO: make the call to the server to get the URL
|
||||
setEditable(true) |
||||
setSelected(new RollSelections()) |
||||
return { |
||||
allowSubmit: false, |
||||
} |
||||
case GeneratorFormAction.SaveScenario: |
||||
// TODO: make the call to the server to get the URL, and copy it to the clipboard
|
||||
setEditable(false) |
||||
setSelected(null) |
||||
return { |
||||
allowSubmit: false, |
||||
status: copyText && copyText(window.location.href).then(() => Promise.resolve(`URL copied to clipboard!`)) |
||||
} |
||||
case GeneratorFormAction.Reroll: |
||||
case GeneratorFormAction.RerollAll: |
||||
if (formData.action === GeneratorFormAction.RerollAll) { |
||||
setResults(rollOnAll(database.tables.values())) |
||||
setSelected(new RollSelections()) |
||||
} else if (selected !== null && selected.size > 0) { |
||||
setResults(new RolledValues([...results, ...rollOnAll(selected)])) |
||||
} |
||||
return { |
||||
allowSubmit: false |
||||
}; |
||||
default: |
||||
return { |
||||
allowSubmit: true, |
||||
} |
||||
} |
||||
}, [database, results, selected, setResults, setSelected, setEditable]) |
||||
const onResponsesSubmitted = useCallback((data: ResponsesFormInfo): ResponsesFormResult => { |
||||
switch (data.action) { |
||||
case ResponsesSubmitType.ReturnToGenerator: |
||||
return { |
||||
allowSubmit: false, |
||||
status: Promise.reject(Error("Already in a version with a generator!")) |
||||
} |
||||
case ResponsesSubmitType.ChangeSelected: |
||||
const result = database.results.get(data.newSelectedTextId) |
||||
if (!result) { |
||||
return { |
||||
allowSubmit: false, |
||||
status: Promise.reject(Error(`no such text ID ${data.newSelectedTextId}`)) |
||||
} |
||||
} |
||||
setResults(new RolledValues([...results.values(), result])) |
||||
return { |
||||
allowSubmit: false, |
||||
} |
||||
default: |
||||
return { |
||||
allowSubmit: true |
||||
} |
||||
} |
||||
}, [database, results, setResults]) |
||||
|
||||
return ( |
||||
<div id="mainGeneratorAndResponses"> |
||||
<IncludesGenerator.Provider value={true}> |
||||
<IncludesResponses.Provider value={true}> |
||||
<GeneratorPage |
||||
generatorTargetUrl={targetUrl} |
||||
elements={generatedElements} |
||||
addToDiscordUrl={addToDiscordUrl} |
||||
editable={editable} |
||||
onCopy={onCopy} |
||||
onSelect={selected ? onSelect : undefined} |
||||
onSelectionChange={selected ? onSelectionChange : undefined} |
||||
onSubmit={onGeneratorSubmitted} /> |
||||
<ResponsesPage types={responsesWithSelections} targetUrl={targetUrl} target={editable ? ResponsesTarget.API : ResponsesTarget.Scenario} onSubmit={onResponsesSubmitted} /> |
||||
<PageFooter creditsUrl={creditsUrl} /> |
||||
</IncludesResponses.Provider> |
||||
</IncludesGenerator.Provider> |
||||
</div>) |
||||
} |
@ -0,0 +1,3 @@ |
||||
@import "Main.css"; |
||||
@import "GeneratorPage.css"; |
||||
@import "PageFooter.css"; |
@ -1,3 +0,0 @@ |
||||
@import "./Main"; |
||||
@import "./GeneratorPage"; |
||||
@import "./PageFooter"; |
@ -0,0 +1,3 @@ |
||||
@import "Main.css"; |
||||
@import "ResponsesPage.css"; |
||||
@import "PageFooter.css"; |
@ -0,0 +1,86 @@ |
||||
import { PageFooter, reconstituteFooterProps } from './PageFooter.js'; |
||||
import { |
||||
reconstituteResponses, |
||||
ResponsesPage, ResponsesTarget |
||||
} from './ResponsesPage.js'; |
||||
import type { ResponseTypeProps } from './ResponseType.js'; |
||||
import { useCallback, useMemo, useState } from 'preact/hooks'; |
||||
import { useHistoryState } from './useHistory.js'; |
||||
import { IncludesResponses } from './contexts.js'; |
||||
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js'; |
||||
|
||||
export interface MainResponsesProps { |
||||
creditsUrl: string |
||||
targetUrl: string |
||||
target: ResponsesTarget |
||||
oldState?: string |
||||
startingTypes: ResponseTypeProps[] |
||||
} |
||||
|
||||
export function reconstituteMainResponsesOnly( |
||||
element: HTMLDivElement, partial?: Partial<MainResponsesProps>): MainResponsesProps { |
||||
|
||||
const {creditsUrl} = reconstituteFooterProps(element.querySelector("footer")!, partial) |
||||
const {types, targetUrl, target, oldState} = |
||||
reconstituteResponses(element.querySelector<HTMLFormElement>("#responses")!) |
||||
|
||||
return { |
||||
creditsUrl, |
||||
targetUrl, |
||||
target, |
||||
oldState, |
||||
startingTypes: types, |
||||
} |
||||
} |
||||
|
||||
export function MainResponsesOnly({ |
||||
creditsUrl, startingTypes, targetUrl, target, oldState}: MainResponsesProps) { |
||||
const [types, setTypes] = useState<ResponseTypeProps[]>(startingTypes) |
||||
const selectedIDs = useMemo((): number[] => { |
||||
return types.map(type => { |
||||
return type.selectedTextId |
||||
}).filter((v): v is number => typeof v === 'number') |
||||
}, [types]) |
||||
useHistoryState({ |
||||
state: selectedIDs, |
||||
onState(state: unknown) { |
||||
// TODO: validate that this is in fact one of the states that this version of the page created
|
||||
const ids = new Set(state as number[]) |
||||
setTypes(types.map(type => { |
||||
return { |
||||
...type, |
||||
selectedTextId: type.contents.map(({result}) => result.textId).find(id => ids.has(id)) ?? null |
||||
} |
||||
})) |
||||
} |
||||
}) |
||||
const onSubmit = useCallback((d: ResponsesFormInfo): ResponsesFormResult => { |
||||
switch (d.action) { |
||||
case ResponsesSubmitType.ChangeSelected: |
||||
setTypes(types.map((props) => { |
||||
if (!props.contents.some((match) => match.result.textId === d.newSelectedTextId)) { |
||||
return props |
||||
} |
||||
return { |
||||
...props, |
||||
selectedTextId: d.newSelectedTextId, |
||||
} |
||||
})) |
||||
return { |
||||
allowSubmit: false |
||||
} |
||||
case ResponsesSubmitType.ReturnToGenerator: |
||||
default: |
||||
return { |
||||
allowSubmit: true |
||||
} |
||||
} |
||||
}, [types, setTypes]) |
||||
return ( |
||||
<div id="mainResponsesOnly"> |
||||
<IncludesResponses.Provider value={true}> |
||||
<ResponsesPage types={startingTypes} oldState={oldState} targetUrl={targetUrl} target={target} onSubmit={onSubmit} /> |
||||
<PageFooter creditsUrl={creditsUrl} /> |
||||
</IncludesResponses.Provider> |
||||
</div>) |
||||
} |
@ -0,0 +1,36 @@ |
||||
@import "ResultText.css"; |
||||
@import "Attribution.css"; |
||||
|
||||
.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; |
||||
} |
||||
.response.active::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; |
||||
} |
||||
|
||||
.response.active .resultText { |
||||
font-weight: bold; |
||||
} |
||||
|
||||
.response.active .attribution .button { |
||||
display: none; |
||||
} |
@ -1,36 +0,0 @@ |
||||
@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; |
||||
} |
||||
} |
@ -1,42 +1,106 @@ |
||||
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 { reconstituteResponseType, responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType.js'; |
||||
import { FormButton, LinkButton } from './Button.js'; |
||||
import { TableEmoji, tableIdentifier, TableName } from './TableHeader.js'; |
||||
import { useContext } from 'preact/hooks'; |
||||
|
||||
export const IncludesResponses = createContext(false) |
||||
import { type FormInfo, useSubmitCallback } from './useSubmitCallback.js'; |
||||
import { usePopup } from './usePopup.js'; |
||||
import { useCallback } from 'preact/hooks'; |
||||
import { EditableResponses, IncludesGenerator } from './contexts.js'; |
||||
import { type ResponsesFormInfo, type ResponsesFormResult, ResponsesSubmitType } from './responsesForm.js'; |
||||
|
||||
export interface ResponsesProps { |
||||
targetUrl: string |
||||
target: ResponsesTarget |
||||
oldState?: string |
||||
types: ResponseTypeProps[] |
||||
} |
||||
|
||||
export interface ResponsesEvents { |
||||
onSelectResponse: (tableId: number, mappingId: number) => void |
||||
export enum ResponsesTarget { |
||||
Scenario = "Scenario", |
||||
Generator = "Generator", |
||||
API = "API", |
||||
} |
||||
|
||||
// TODO: add a "reconstitute" function for ResponsesPage
|
||||
export function reconstituteResponses(element: HTMLFormElement): ResponsesProps { |
||||
return { |
||||
targetUrl: element.action, |
||||
target: element.dataset.target as ResponsesTarget, |
||||
oldState: element.querySelector<HTMLInputElement>('input#oldState')?.value, |
||||
types: Array.from(element.querySelectorAll<HTMLLIElement>("#responseLists li.responseType")) |
||||
.map(el => reconstituteResponseType(el)) |
||||
} |
||||
} |
||||
|
||||
export interface ResponsesEvents { |
||||
onSubmit?(i: ResponsesFormInfo): ResponsesFormResult |
||||
} |
||||
|
||||
export function ResponsesPage({ types, onSelectResponse }: ResponsesProps & ResponsesEvents) { |
||||
export function ResponsesPage({ targetUrl, target, oldState, types, onSubmit }: ResponsesProps & ResponsesEvents) { |
||||
const includesGenerator = useContext(IncludesGenerator); |
||||
return <div id="responses" class="page"> |
||||
<header id="responsesHeader" class="window"> |
||||
const [headerPopupRef, showHeaderPopup] = usePopup() |
||||
|
||||
const submitHandler = useCallback((d: FormInfo) => { |
||||
if (!onSubmit) { |
||||
return {allowSubmit: true} |
||||
} |
||||
const action = d.button?.name |
||||
let info: ResponsesFormInfo |
||||
switch (action) { |
||||
case ResponsesSubmitType.ChangeSelected: |
||||
info = { ...d, action: ResponsesSubmitType.ChangeSelected, newSelectedTextId: parseInt(d.button!.value) } |
||||
break |
||||
case ResponsesSubmitType.ReturnToGenerator: |
||||
info = { ...d, action: ResponsesSubmitType.ReturnToGenerator } |
||||
break |
||||
default: |
||||
return {allowSubmit: true} |
||||
} |
||||
const {allowSubmit, status} = onSubmit(info) |
||||
|
||||
if (status) { |
||||
status.then((text) => { |
||||
if (text) { |
||||
return showHeaderPopup(text, "success") |
||||
} |
||||
}).catch((ex) => { |
||||
if (ex instanceof Error) { |
||||
return showHeaderPopup(`Failed to ${action}: ${ex.message}`, 'error') |
||||
} else if (ex) { |
||||
return showHeaderPopup(`Failed to ${action}: ${ex}`, 'error') |
||||
} else { |
||||
return showHeaderPopup(`Failed to ${action}`, 'error') |
||||
} |
||||
}) |
||||
} |
||||
return {allowSubmit} |
||||
}, [onSubmit, showHeaderPopup]) |
||||
|
||||
const submitCallback = useSubmitCallback(submitHandler) |
||||
|
||||
return <form id="responses" class="page" enctype="multipart/form-data" method={target === ResponsesTarget.API ? "POST" : "GET"} action={targetUrl} data-target={target} onSubmit={submitCallback}> |
||||
{(oldState && target === ResponsesTarget.API) ? <input id="oldState" name="oldState" value={oldState} type="hidden" /> : null} |
||||
<header id="responsesHeader" class="window" ref={headerPopupRef}> |
||||
<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} /> |
||||
<TableEmoji emoji={type.table.emoji} /><TableName name={type.table.name} /> |
||||
</LinkButton>)} |
||||
{includesGenerator |
||||
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton> |
||||
: null} |
||||
? <LinkButton href="#generator" external={false} id="returnToGenerator">Return to {target === ResponsesTarget.API ? "Generator" : "Scenario"}</LinkButton> |
||||
: target === ResponsesTarget.API |
||||
? <FormButton type="submit" name={ResponsesSubmitType.ReturnToGenerator} value={"OK"} id="returnToGenerator">Return to Generator</FormButton> |
||||
: target === ResponsesTarget.Scenario |
||||
? <LinkButton href={targetUrl} external={false} id="returnToGenerator">Return to Scenario</LinkButton> |
||||
: <LinkButton href={targetUrl} external={false} id="returnToGenerator">Go to Generator</LinkButton>} |
||||
</nav> |
||||
</header> |
||||
<EditableResponses.Provider value={target === ResponsesTarget.API}> |
||||
<ul id="responseLists"> |
||||
{types.map(type => |
||||
<ResponseType key={tableIdentifier(type.table)} |
||||
onSelectResponse={onSelectResponse} |
||||
{...type} />)} |
||||
</ul> |
||||
</div> |
||||
</EditableResponses.Provider> |
||||
</form> |
||||
} |
||||
|
@ -0,0 +1,5 @@ |
||||
import { createContext } from 'preact'; |
||||
|
||||
export const IncludesGenerator = createContext(false) |
||||
export const IncludesResponses = createContext(false) |
||||
export const EditableResponses = createContext(false) |
@ -0,0 +1,19 @@ |
||||
import type { FormInfo, FormReturnType } from './useSubmitCallback.js'; |
||||
|
||||
export enum ResponsesSubmitType { |
||||
ChangeSelected = "change selected", |
||||
ReturnToGenerator = "return to generator", |
||||
} |
||||
export interface ResponsesReturnEvent extends FormInfo { |
||||
action: ResponsesSubmitType.ReturnToGenerator |
||||
} |
||||
export interface ResponsesSelectEvent extends FormInfo { |
||||
action: ResponsesSubmitType.ChangeSelected |
||||
newSelectedTextId: number |
||||
} |
||||
|
||||
export type ResponsesFormInfo = ResponsesReturnEvent|ResponsesSelectEvent |
||||
|
||||
export interface ResponsesFormResult extends FormReturnType { |
||||
status?: Promise<string|void> |
||||
} |
@ -0,0 +1,8 @@ |
||||
const clipboard = typeof window !== 'undefined' && window.navigator && window.navigator.clipboard |
||||
|
||||
export async function copyText(text: string): Promise<void> { |
||||
if (!clipboard) { |
||||
throw Error("Clipboard functionality not supported here") |
||||
} |
||||
return clipboard.writeText(text) |
||||
} |
@ -0,0 +1,73 @@ |
||||
import { useCallback, useEffect, useRef } from 'preact/hooks'; |
||||
|
||||
export interface HistoryProps { |
||||
state: {}|null, |
||||
key?: {}|null |
||||
url?: URL|null |
||||
onState(state: unknown, url: URL): void |
||||
} |
||||
|
||||
const history = typeof window !== 'undefined' && window.history |
||||
|
||||
export function getSameOriginURL(target: URL|null|undefined, base: URL): URL { |
||||
if (!target) { |
||||
return base |
||||
} |
||||
const isFile = base.protocol === "file" |
||||
if (isFile) { |
||||
return new URL(target?.hash, base) |
||||
} |
||||
const sameOrigin = !!target && base.origin === target.origin |
||||
if (sameOrigin) { |
||||
return target |
||||
} else { |
||||
return new URL(target?.search ?? "?" + target?.hash ?? "", base) |
||||
} |
||||
} |
||||
|
||||
export function useHistoryState({state: userState, key: givenKey = userState, url: givenUrl, onState}: HistoryProps): void { |
||||
const key = useRef(givenKey), |
||||
url = useRef<URL|null>(null), |
||||
firstRender = useRef(true), |
||||
popRender = useRef(false); |
||||
useEffect(() => { |
||||
if (history) { |
||||
const currentUrl = new URL(window.location.href) |
||||
const effectiveUrl = getSameOriginURL(givenUrl, currentUrl) |
||||
if (firstRender.current) { |
||||
firstRender.current = false |
||||
if (history.state !== null) { |
||||
popRender.current = true |
||||
onState(history.state, currentUrl) |
||||
} else { |
||||
history.replaceState(userState, "", effectiveUrl) |
||||
} |
||||
} else if (popRender) { |
||||
popRender.current = false |
||||
key.current = givenKey |
||||
url.current = effectiveUrl |
||||
history.replaceState(userState, "", effectiveUrl) |
||||
} else { |
||||
if (givenKey !== key.current || effectiveUrl !== url.current) { |
||||
key.current = givenKey |
||||
url.current = effectiveUrl |
||||
history.pushState(userState, "", effectiveUrl) |
||||
} else { |
||||
history.replaceState(userState, "", effectiveUrl) |
||||
} |
||||
} |
||||
} |
||||
}, [url, key, userState, givenKey, firstRender, popRender, givenUrl]) |
||||
const onPopState = useCallback(() => { |
||||
if (history) { |
||||
popRender.current = true |
||||
onState(history.state, new URL(window.location.href)) |
||||
} |
||||
}, [popRender, onState]) |
||||
useEffect(() => { |
||||
if (history) { |
||||
window.addEventListener("popstate", onPopState) |
||||
return () => window.removeEventListener("popstate", onPopState) |
||||
} |
||||
}, [onPopState]) |
||||
} |
@ -0,0 +1,44 @@ |
||||
import { useCallback } from 'preact/hooks'; |
||||
|
||||
export interface FormInfo { |
||||
data: FormData |
||||
method: string |
||||
url: string |
||||
enctype: string |
||||
target: string |
||||
button: HTMLButtonElement|HTMLInputElement|null |
||||
} |
||||
|
||||
export interface FormReturnType { |
||||
allowSubmit?: boolean |
||||
} |
||||
|
||||
export function useSubmitCallback(onSubmit?: (data: FormInfo) => FormReturnType|undefined): (e: SubmitEvent) => void { |
||||
return useCallback((e: SubmitEvent) => { |
||||
if (!onSubmit) { |
||||
return |
||||
} |
||||
const button = |
||||
e.submitter instanceof HTMLButtonElement || e.submitter instanceof HTMLInputElement ? e.submitter : null |
||||
const form = e.target instanceof HTMLFormElement ? e.target : null |
||||
if (!form) { |
||||
return |
||||
} |
||||
const data = new FormData(form, e.submitter) |
||||
const method = button?.formMethod ?? form.method |
||||
const url = button?.formAction ?? form.action |
||||
const enctype = button?.formEnctype ?? form.enctype |
||||
const target = button?.formTarget ?? form.target |
||||
const {allowSubmit} = onSubmit({ |
||||
data, |
||||
method, |
||||
url, |
||||
enctype, |
||||
target, |
||||
button, |
||||
}) ?? {allowSubmit: true} |
||||
if (!allowSubmit) { |
||||
e.preventDefault() |
||||
} |
||||
}, [onSubmit]) |
||||
} |
@ -1 +0,0 @@ |
||||
|
@ -1,18 +0,0 @@ |
||||
{ |
||||
"compilerOptions": { |
||||
"target": "es2015", |
||||
"module": "ES2015", |
||||
"esModuleInterop": true, |
||||
"forceConsistentCasingInFileNames": true, |
||||
"moduleResolution": "NodeNext", |
||||
"strict": true, |
||||
"skipLibCheck": true, |
||||
"lib": ["DOM"], |
||||
"jsx": "react-jsx", |
||||
"jsxImportSource": "preact", |
||||
"paths": { |
||||
"react": ["./node_modules/preact/compat/"], |
||||
"react-dom": ["./node_modules/preact/compat/"] |
||||
} |
||||
} |
||||
} |
@ -0,0 +1,219 @@ |
||||
import { AutoRouter, type IRequestStrict } from 'itty-router'; |
||||
import { type TypedDBWrapper, validatedDefinition } from './querytypes.js'; |
||||
import { guaranteedSingleton, jsonParser, rows, singleton } from './transformers.js'; |
||||
import { boolean, discordSnowflake, discordSnowflakeOrId, tableIdentifierSubstring } from './validators.js'; |
||||
|
||||
const DatabasePathBase = "/_internal/database/" |
||||
|
||||
async function checkCache(request: Request & {databaseCache: Cache}): Promise<void> { |
||||
if (request.method.toUpperCase() !== "GET") { |
||||
return |
||||
} |
||||
const cache = request.databaseCache |
||||
const response = await cache.match(request) |
||||
if (response) { |
||||
Object.assign(request, {cached: response}) |
||||
} else { |
||||
Object.assign(request, {cached: null}) |
||||
} |
||||
} |
||||
|
||||
async function storeCache(response: Response, request: Request & {databaseCache: Cache}): Promise<void> { |
||||
if (request.method.toUpperCase() !== "GET") { |
||||
return |
||||
} |
||||
await request.databaseCache.put(request, response) |
||||
} |
||||
|
||||
const router = AutoRouter<IRequestStrict & Request & { |
||||
databaseCache: Cache, |
||||
typedDB: TypedDBWrapper, |
||||
ctx: ExecutionContext, |
||||
cached: Response|null, |
||||
}, []>({ |
||||
before: [checkCache], |
||||
base: DatabasePathBase, |
||||
finally: [storeCache], |
||||
}) |
||||
|
||||
export function callDatabase(url: URL, {typedDB, ctx, databaseCache}: {typedDB: TypedDBWrapper, ctx: ExecutionContext, databaseCache: Cache}): Promise<Response> { |
||||
return router.fetch(Object.assign(new Request(url), {typedDB, ctx, databaseCache})) |
||||
} |
||||
|
||||
const TableRefStats = validatedDefinition({ |
||||
query: `SELECT
|
||||
(SELECT COUNT(*) FROM rollableTableIdentifiers) AS identifiers, |
||||
(SELECT COUNT(*) FROM rollableTableHeaders) AS headers, |
||||
(SELECT COUNT(*) FROM rollableTableBadges) AS badges`,
|
||||
parameters: {}, |
||||
output: guaranteedSingleton<{identifiers: number, headers: number, badges: number}>(), |
||||
} as const) |
||||
|
||||
export const TableStatsHeaderName = "Rollable-Table-Ref-Stats" |
||||
|
||||
function tableStatsToHeader(stats: {identifiers: number, headers: number, badges: number}): string { |
||||
return `${stats.identifiers},${stats.headers},${stats.badges}` |
||||
} |
||||
|
||||
export const GetAllTables = validatedDefinition({ |
||||
query: `SELECT rollableTables.id AS id,
|
||||
rollableTables.identifier AS identifier, |
||||
rollableTables.name AS name, |
||||
rollableTables.emoji AS emoji, |
||||
rollableTables.title AS title, |
||||
rollableTables.ordinal AS ordinal |
||||
FROM rollableTables;`,
|
||||
parameters: {}, |
||||
output: rows<{ id: number, identifier: string, name: string, emoji: string, title: string, ordinal: number }>(), |
||||
path: "/tables", |
||||
} as const) |
||||
|
||||
router.get(GetAllTables.path, async (req: { |
||||
typedDB: TypedDBWrapper, |
||||
cached: Response|null, |
||||
}): Promise<Response> => { |
||||
const {cached, typedDB} = req |
||||
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({}) |
||||
if (cached) { |
||||
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery)) |
||||
if (currentStats === cached.headers.get(TableStatsHeaderName)) { |
||||
return cached |
||||
} |
||||
} |
||||
const tableQuery = typedDB.prepareOrRetrieve(GetAllTables)({}) |
||||
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery) |
||||
return new Response(JSON.stringify(tables), { |
||||
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)], ["Content-Type", "application/json"]] |
||||
}) |
||||
}) |
||||
|
||||
export const AutocompleteTable = validatedDefinition({ |
||||
query: `WITH matchingIds (id) AS (SELECT DISTINCT rollableTableIdentifiers.tableId AS id
|
||||
FROM rollableTableIdentifiers |
||||
WHERE rollableTableIdentifiers.identifier LIKE substr(?1, 1) ESCAPE '\\' |
||||
UNION |
||||
SELECT DISTINCT rollableTableIdentifiers.tableId AS id |
||||
FROM rollableTableIdentifiers |
||||
WHERE rollableTableIdentifiers.identifier LIKE ?1 ESCAPE '\\' |
||||
UNION |
||||
SELECT DISTINCT rollableTableBadges.id AS id |
||||
FROM rollableTableBadges |
||||
WHERE rollableTableBadges.badge LIKE ?1 ESCAPE '\\' |
||||
UNION |
||||
SELECT DISTINCT rollableTableHeaders.tableId AS id |
||||
FROM rollableTableHeaders |
||||
WHERE rollableTableHeaders.header LIKE ?1 ESCAPE '\\') |
||||
SELECT rollableTables.id AS id, |
||||
rollableTables.identifier AS identifier, |
||||
rollableTables.name AS name, |
||||
rollableTables.emoji AS emoji |
||||
FROM matchingIds |
||||
INNER JOIN rollableTables ON matchingIds.id = rollableTables.id |
||||
LIMIT 25;`,
|
||||
parameters: { |
||||
'tableIdentifierSubstring': { validator: tableIdentifierSubstring, index: 1 } |
||||
}, |
||||
output: rows<{ id: number, identifier: string, name: string, emoji: string }>(), |
||||
pathPrefix: '/autocomplete/tables/' |
||||
}) |
||||
router.get(`${AutocompleteTable.pathPrefix}:partialTableIdent`, async (req: { |
||||
partialTableIdent: string, |
||||
typedDB: TypedDBWrapper, |
||||
cached: Response|null, |
||||
}) => { |
||||
const {cached, typedDB, partialTableIdent} = req |
||||
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({}) |
||||
if (cached) { |
||||
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery)) |
||||
if (currentStats === cached.headers.get(TableStatsHeaderName)) { |
||||
return cached |
||||
} |
||||
} |
||||
const tableQuery = typedDB.prepareOrRetrieve(AutocompleteTable)({tableIdentifierSubstring: partialTableIdent}) |
||||
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery) |
||||
return new Response(JSON.stringify(tables), { |
||||
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)]] |
||||
}) |
||||
}) |
||||
|
||||
export const LoadSetDetails = validatedDefinition({ |
||||
query: `SELECT
|
||||
json_object( |
||||
'id', resultSets.id, |
||||
'name', resultSets.name, |
||||
'description', resultSets.description, |
||||
'discordSnowflake', resultSets.discordSnowflake, |
||||
'global', CASE WHEN resultSets.global = FALSE THEN json('false') ELSE json('true') END, |
||||
'parentSets', (SELECT json_group_array(parentSets.id) |
||||
FROM resultSets AS parentSets |
||||
WHERE ?2 = FALSE |
||||
AND parentSets.global = TRUE)), |
||||
'lastModified', (SELECT max(setAuditLog.timestamp) |
||||
FROM setAuditLog |
||||
WHERE setAuditLog.setId = resultSets.id) |
||||
FROM resultSets |
||||
WHERE (discordSnowflake = ?1 OR resultSets.id = ?1) |
||||
AND global = ?2`,
|
||||
parameters: { |
||||
discordSnowflake: { |
||||
validator: discordSnowflakeOrId, |
||||
index: 1, |
||||
}, |
||||
global: { |
||||
validator: boolean, |
||||
index: 2, |
||||
}, |
||||
}, |
||||
output: rows(jsonParser<{ |
||||
id: number, |
||||
name: string|null, |
||||
description: string|null, |
||||
discordSnowflake: string|null, |
||||
global: boolean, |
||||
parentSets: number[], |
||||
lastModified: number, |
||||
}>(['id', 'name', 'description', 'discordSnowflake', 'global', 'parentSets', 'lastModified'])), |
||||
}) |
||||
|
||||
export const LoadSetResults = validatedDefinition({ |
||||
query: `SELECT
|
||||
json_object( |
||||
'mappingId', resultMappings.id, |
||||
'textId', rollableResults.id, |
||||
'tableId', rollableResults.tableId, |
||||
'text', rollableResults.text, |
||||
'setId', resultMappings.setId, |
||||
'authorId', resultMappings.authorId, |
||||
) |
||||
FROM resultMappings |
||||
INNER JOIN rollableResults ON rollableResults.id = resultMappings.resultId |
||||
WHERE resultMappings.setId = ?1`,
|
||||
parameters: {}, |
||||
output: singleton |
||||
}) |
||||
|
||||
export const AutocompleteText = { |
||||
setPathInfix: "/set/", |
||||
textPathInfix: "/text/", |
||||
} as const |
||||
router.get(`${AutocompleteTable.pathPrefix}:partialTableIdent${AutocompleteText.setPathInfix}:setId${AutocompleteText.textPathInfix}:partialText`, async (req: { |
||||
partialTableIdent: string, |
||||
setId: string, |
||||
partialText: string, |
||||
typedDB: TypedDBWrapper, |
||||
cached: Response|null, |
||||
}) => { |
||||
const {cached, typedDB, partialTableIdent} = req |
||||
const statsQuery = typedDB.prepareOrRetrieve(TableRefStats)({}) |
||||
if (cached) { |
||||
const currentStats = tableStatsToHeader(await typedDB.run(statsQuery)) |
||||
if (currentStats === cached.headers.get(TableStatsHeaderName)) { |
||||
return cached |
||||
} |
||||
} |
||||
const tableQuery = typedDB.prepareOrRetrieve(AutocompleteTable)({tableIdentifierSubstring: partialTableIdent}) |
||||
const [tableStats, tables] = await typedDB.batch(statsQuery, tableQuery) |
||||
return new Response(JSON.stringify(tables), { |
||||
headers: [[TableStatsHeaderName, tableStatsToHeader(tableStats)]] |
||||
}) |
||||
}) |
Loading…
Reference in new issue