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": { |
"compilerOptions": { |
||||||
"module": "commonjs", |
"module": "ESNext", |
||||||
"esModuleInterop": true, |
"esModuleInterop": true, |
||||||
"allowSyntheticDefaultImports": true, |
|
||||||
"target": "ESNext", |
"target": "ESNext", |
||||||
|
"strict": true, |
||||||
"noEmit": true, |
"noEmit": true, |
||||||
"noImplicitAny": true, |
"noImplicitAny": true, |
||||||
"moduleResolution": "node", |
"allowSyntheticDefaultImports": true, |
||||||
|
"allowJs": true, |
||||||
|
"moduleResolution": "Bundler", |
||||||
"sourceMap": true, |
"sourceMap": true, |
||||||
"baseUrl": "./" |
"baseUrl": "./", |
||||||
|
"importsNotUsedAsValues": "remove", |
||||||
}, |
}, |
||||||
"include": [ |
"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 "Attribution.css"; |
||||||
@import "ResultText"; |
@import "pulseElement.css"; |
||||||
|
@import "ResultText.css"; |
||||||
|
|
||||||
.generatedResult { |
.generatedResult { |
||||||
margin: 0; |
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 { reconstituteResponseType, responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType.js'; |
||||||
import { responseListIdPrefix, ResponseType, type ResponseTypeProps } from './ResponseType'; |
import { FormButton, LinkButton } from './Button.js'; |
||||||
import { IncludesGenerator } from './GeneratorPage'; |
import { TableEmoji, tableIdentifier, TableName } from './TableHeader.js'; |
||||||
import { LinkButton } from './Button'; |
|
||||||
import { TableEmoji, tableIdentifier, TableName } from './TableHeader'; |
|
||||||
import { useContext } from 'preact/hooks'; |
import { useContext } from 'preact/hooks'; |
||||||
|
import { type FormInfo, useSubmitCallback } from './useSubmitCallback.js'; |
||||||
export const IncludesResponses = createContext(false) |
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 { |
export interface ResponsesProps { |
||||||
|
targetUrl: string |
||||||
|
target: ResponsesTarget |
||||||
|
oldState?: string |
||||||
types: ResponseTypeProps[] |
types: ResponseTypeProps[] |
||||||
} |
} |
||||||
|
|
||||||
export interface ResponsesEvents { |
export enum ResponsesTarget { |
||||||
onSelectResponse: (tableId: number, mappingId: number) => void |
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); |
const includesGenerator = useContext(IncludesGenerator); |
||||||
return <div id="responses" class="page"> |
const [headerPopupRef, showHeaderPopup] = usePopup() |
||||||
<header id="responsesHeader" class="window"> |
|
||||||
|
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> |
<h1 id="responsesHead">Possible Responses</h1> |
||||||
<nav id="responsesHeaderNav" class="buttons"> |
<nav id="responsesHeaderNav" class="buttons"> |
||||||
{types.map(type => |
{types.map(type => |
||||||
<LinkButton key={tableIdentifier(type.table)} href={`#${responseListIdPrefix}${tableIdentifier(type.table)}`} external={false}> |
<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>)} |
</LinkButton>)} |
||||||
{includesGenerator |
{includesGenerator |
||||||
? <LinkButton href={"#generator"} external={false} id="returnToGenerator">Return to Generator</LinkButton> |
? <LinkButton href="#generator" external={false} id="returnToGenerator">Return to {target === ResponsesTarget.API ? "Generator" : "Scenario"}</LinkButton> |
||||||
: null} |
: 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> |
</nav> |
||||||
</header> |
</header> |
||||||
|
<EditableResponses.Provider value={target === ResponsesTarget.API}> |
||||||
<ul id="responseLists"> |
<ul id="responseLists"> |
||||||
{types.map(type => |
{types.map(type => |
||||||
<ResponseType key={tableIdentifier(type.table)} |
<ResponseType key={tableIdentifier(type.table)} |
||||||
onSelectResponse={onSelectResponse} |
|
||||||
{...type} />)} |
{...type} />)} |
||||||
</ul> |
</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