Get the site functional for the game release

This commit is contained in:
Oliver-Akins 2024-03-27 21:14:01 -06:00
parent 4b61e73573
commit 33ab4afe52
16 changed files with 575 additions and 30 deletions

View file

@ -3,7 +3,7 @@ import { ServerRoute } from "@hapi/hapi";
import Joi from "joi";
const route: ServerRoute = {
method: `POST`, path: `/{channel}/questions/{question_id}`,
method: `PATCH`, path: `/{channel}/questions/{question_id}`,
options: {
validate: {
params: Joi.object({
@ -21,6 +21,7 @@ const route: ServerRoute = {
question: Joi.string().optional(),
asker: Joi.string().optional(),
answered: Joi.boolean().optional(),
hidden: Joi.boolean().optional(),
id: Joi.forbidden(),
})
.min(1),
@ -41,17 +42,24 @@ const route: ServerRoute = {
let values = [];
for (const key in payload) {
let v = payload[key];
if (v.startsWith(`__`)) {
let type = typeof v;
if (type == "boolean") {
setters.push(`${key} = ${v}`);
} else {
setters.push(`${key} = ?`);
values.push(v);
}
else if (type == "string") {
if (v.startsWith(`__`)) {
setters.push(`${key} = ${v.slice(2)}`);
} else {
setters.push(`${key} = ?`);
values.push(v);
};
};
};
await db.query(
`update tqna.questions
( ${setters.join(`, `)} )
set ${setters.join(`, `)}
where channel = ? and id = ?
limit 1`,
[...values, channel, question_id ]
@ -64,8 +72,9 @@ const route: ServerRoute = {
question = questions[0];
await conn.commit();
} catch {
log.error(`Failed to add the question`);
} catch (e) {
log.error(e)
log.error(`Failed to save the question`);
await conn.rollback();
} finally {
conn.release();

View file

@ -1,4 +1,5 @@
import { Server } from "@hapi/hapi";
import { isBoom } from "@hapi/boom";
import { globSync } from "glob";
import path from "path";
import { log } from "./main";
@ -10,6 +11,42 @@ const server = new Server({
debug: {
request: [ `*` ],
},
router: {
stripTrailingSlash: true,
},
routes: {
cors: {
origin: [ `*` ],
},
},
});
/*
This event listener makes it so that the error that is returned from the system
is more user-friendly when it's a validation error, and so that nothing gets
leaked accidentally through allowing other data to make it out of the API.
*/
server.ext(`onPreResponse`, (req, h) => {
if (isBoom(req.response)) {
let oldResponse = req.response.output.payload as any;
let newResponse: any = {
statusCode: oldResponse.statusCode,
error: oldResponse.error,
message: oldResponse.message,
};
let deets = (req.response as any).details as any[];
if (deets) {
let messages = deets.map(e => e.message);
newResponse.message = (req.response as any).output.payload.validation.source + ` failed to validate`;
newResponse.violations = messages;
};
req.response.output.payload = newResponse;
return h.continue;
}
return h.continue;
});

View file

@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"axios": "^1.5.0",
"svelte-i18n": "^3.7.0"
},
"devDependencies": {
@ -599,6 +600,21 @@
"dequal": "^2.0.3"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/axios": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.5.0.tgz",
"integrity": "sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==",
"dependencies": {
"follow-redirects": "^1.15.0",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/axobject-query": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz",
@ -716,6 +732,17 @@
"periscopic": "^3.1.0"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@ -768,6 +795,14 @@
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
@ -939,6 +974,38 @@
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
@ -1180,6 +1247,25 @@
"node": ">=8.6"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@ -1354,6 +1440,11 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/queue-microtask": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",

View file

@ -19,6 +19,7 @@
"vite": "^4.4.5"
},
"dependencies": {
"axios": "^1.5.0",
"svelte-i18n": "^3.7.0"
}
}

Binary file not shown.

Binary file not shown.

View file

@ -1,3 +1,17 @@
{
"docs.svelte": "Svelte Docs"
}
"option.streamer-mode": "Streamer Mode",
"option.dyslexic-font": "Use Dyslexic Font",
"option.refresh-interval": "Refresh Interval",
"option.locale": "Language",
"option.join-channel": "Join Twitch Channel",
"button.refresh": "Refresh",
"button.extra-settings": "Extra Settings",
"display.question.button.answered": "Answered",
"display.question.button.hide-from-streamer": "Hide from Streamer",
"display.question.button.show-for-streamer": "Show to Streamer",
"display.question.asked-by": "asked by:",
"display.modal.settings.title": "Extra Settings",
"display.modal.settings.description": "Any changes made to these options are automatically saved to your browser and will be restored on page re-load.",
"display.modal.close": "Close",
"option.delete-questions": "Delete All Questions"
}

View file

@ -1,19 +1,82 @@
<script lang="ts">
import { _, locale, locales } from "svelte-i18n";
import localeNames from "./locales";
import { t } from "svelte-i18n";
import Integer from "./components/integer.svelte";
import Question from "./components/question.svelte";
import { dyslexiaFont, isStreamer, questions, refreshInterval, visibleModal } from "./stores";
import { api } from "./main";
import SettingsModal from "./components/modals/settings.svelte";
async function loadQuestions() {
let r = await api.get(`./questions`, { validateStatus: null });
switch (r.status) {
case 200:
questions.set(r.data);
};
};
$: if ($dyslexiaFont) {
window.document.body.classList.add(`dyslexic`);
localStorage.setItem(`tqna-dyslexic`, `true`);
}
else {
window.document.body.classList.remove(`dyslexic`);
localStorage.setItem(`tqna-dyslexic`, `false`);
};
let intervalId = -1;
$: {
if (intervalId >= 0) {
clearInterval(intervalId);
};
if ($refreshInterval > 0) {
intervalId = setInterval(loadQuestions, $refreshInterval * 1_000);
};
};
$: visibleQuestions = $questions
.filter(q => !(
$isStreamer
&& (
q.answered
|| q.hidden
)));
</script>
<main>
<a href="https://svelte.dev/docs" target="_blank" rel="noreferrer">
{$_("docs.svelte")}
</a>
<br><br>
<select bind:value={$locale}>
{#each $locales as locale}
<option value="{locale}">{localeNames[locale]}</option>
<svelte:component this={$visibleModal} />
<div class="option-row">
<Integer
value={refreshInterval}
label="{$t("option.refresh-interval")}"
min={0}
max={30}
unitName="s"
/>
<button on:click|stopPropagation={loadQuestions}>
{$t("button.refresh")}
</button>
<button on:click|stopPropagation={() => $visibleModal = SettingsModal}>
{$t("button.extra-settings")}
</button>
</div>
<hr>
<div class="questions">
{#each visibleQuestions as q}
<Question {q} streamer={isStreamer} on:loadQuestions={loadQuestions} />
{/each}
</select>
</div>
</main>
<style>
.option-row {
display: flex;
flex-direction: row;
}
.questions {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-evenly;
}
</style>

View file

@ -1,17 +1,56 @@
html, body {
padding: 0;
margin: 0;
min-height: 100vh;
min-width: 100vw;
@font-face {
font-family: openDyslexic;
src: url(/OpenDyslexic3-Regular.ttf) format("truetype");
font-style: normal;
}
@font-face {
font-family: openDyslexic;
src: url(/OpenDyslexic3-Bold.ttf) format("truetype");
font-style: bold;
}
body {
background-color: #2c2c32;
body, html {
width: 100vw;
background: #2a2d2f;
color: white;
font-family: sans-serif;
padding: 0;
margin: 0;
}
body.dyslexic {
font-family: openDyslexic, sans-serif;
}
button {
font-size: inherit;
margin: 2px;
padding: 10px 14px;
border-radius: 5px;
outline: none;
border-style: none;
background: rgba(0,0,0, 0.25);
color: white;
font-family: inherit;
}
.modal-background {
position: fixed;
width: 100vw;
height: 100vh;
background: rgba(0,0,0, 0.25);
display: flex;
justify-content: center;
align-items: center;
}
a {
color: white;
.modal-content {
background: #2a2d2f;
min-width: 30%;
min-height: 25%;
padding: 25px;
}
h1, h2, h3 {
margin: 0;
}

View file

@ -0,0 +1,39 @@
<script lang="ts">
export let value;
export let label;
export let min = 0;
export let max = null;
export let unitName = ``;
let id = `input-number-${label.replace(/ /g, `-`)}`;
</script>
<div class="int-input">
<label for={id}>{label}</label>
<input
type="range"
{id}
{min}
{max}
bind:value={$value}
>
( {$value}{unitName} )
</div>
<style>
.int-input {
margin: 5px 10px;
border-radius: 7px;
border-style: none;
border-width: 0px;
background: rgba(0,0,0, 0.25);
padding: 5px 7px;
display: flex;
flex-direction: row;
align-items: center;
}
input {
margin: 0 6px;
}
</style>

View file

@ -0,0 +1,94 @@
<script lang="ts">
import { t, locales, locale } from "svelte-i18n";
import { isStreamer, dyslexiaFont, visibleModal } from "../../stores";
import localeNames from "../../locales";
import Toggle from "../toggle.svelte";
import { api } from "../../main";
async function startBot() {
let r = await api.post(`.`, { validateStatus: null });
switch (r.status) {
case 202:
alert(`Bot already listening to channel`);
break;
case 200:
alert(`Started bot in channel`);
break;
}
};
</script>
<div class="modal-background">
<div class="modal-content">
<h1>{$t("display.modal.settings.title")}</h1>
<p>{$t("display.modal.settings.description")}</p>
<div class="options">
<Toggle value={isStreamer} label="{$t("option.streamer-mode")}" />
<Toggle value={dyslexiaFont} label="{$t("option.dyslexic-font")}" />
<select bind:value={$locale}>
{#each $locales as lang}
<option value="{lang}">{localeNames[lang]}</option>
{/each}
</select>
<button
on:click={startBot}
>
{$t("option.join-channel")}
</button>
<!--
<button>
{$t("option.delete-questions")}
</button>
-->
</div>
<div class="button-row">
<button
on:click={() => $visibleModal = null}
>
{$t("display.modal.close")}
</button>
</div>
</div>
</div>
<style>
.modal-background {
position: absolute;
left: 0;
top: 0;
display: flex;
justify-content: center;
align-items: center;
}
.modal-content {
border-radius: 25px;
max-width: 60%;
text-align: center;
}
.options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: center;
}
.button-row {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
}
select {
margin: 5px 10px;
border-radius: 7px;
border-style: none;
border-width: 0px;
background: rgba(0,0,0, 0.25);
padding: 10px 14px;
display: flex;
flex-direction: row;
align-items: center;
color: white;
font-family: inherit;
font-size: inherit;
}
</style>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import { t } from "svelte-i18n";
import type { Readable } from "svelte/store";
import { api } from "../main";
import { createEventDispatcher } from "svelte";
export let q: Question;
export let streamer: Readable<boolean>;
const dispatch = createEventDispatcher();
async function markAsAnswered() {
await api.patch(
`./questions/${q.id}`,
{ answered: true }
);
dispatch("loadQuestions");
alert("Marked question as answered");
};
async function toggleVisibility() {
await api.patch(
`./questions/${q.id}`,
{ hidden: !q.hidden }
);
dispatch("loadQuestions");
alert("Toggled question visibility for streamer");
};
</script>
<div
class="question"
class:answered={q.answered}
class:hidden={q.hidden}
>
<q>{q.question}</q>
<br>
<span>{$t("display.question.asked-by")} <b>{q.asker}</b></span>
<hr>
<div class="options">
{#if !q.answered}
<button
on:click={markAsAnswered}
>
{$t("display.question.button.answered")}
</button>
{/if}
{#if !$streamer}
<button
on:click={toggleVisibility}
>
{#if q.hidden}
{$t("display.question.button.hide-from-streamer")}
{:else}
{$t("display.question.button.show-for-streamer")}
{/if}
</button>
{/if}
</div>
</div>
<style>
.question {
width: 25%;
margin: 4px;
padding: 15px;
background: rgba(0,0,0, 0.25);
display: flex;
flex-direction: column;
}
q {
flex-grow: 1;
}
hr {
width: 100%
}
.options {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
button {
flex-grow: 1;
}
</style>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import type { Writable } from "svelte/store";
export let value: Writable<boolean>;
export let label: string;
let id = `checkbox-${label.replace(/ /g, `-`)}`;
</script>
<div class="toggle">
<input
type="checkbox"
{id}
bind:checked={$value}
>
<label for={id}>{label}</label>
</div>
<style>
.toggle {
margin: 5px 10px;
border-radius: 7px;
border-style: none;
border-width: 0px;
background: rgba(0,0,0, 0.25);
padding: 10px 14px;
display: flex;
flex-direction: row;
align-items: center;
}
input {
margin-right: 7px;
}
</style>

View file

@ -1,6 +1,11 @@
import { getLocaleFromNavigator, init, register } from "svelte-i18n";
import "./app.css";
import App from "./App.svelte";
import axios from "axios";
import "./app.css";
export const api = axios.create({
baseURL: import.meta.env.DEV ? `http://localhost:6969${window.location.pathname}` : import.meta.env.BASE_URL,
});
// Get all of the internationalization stuff registered and operational
import locales from "./locales";

23
frontend/src/stores.ts Normal file
View file

@ -0,0 +1,23 @@
import { writable } from "svelte/store";
export const questions = writable<Question[]>([]);
export const visibleModal = writable(null);
/*
These stores are persisted between page reloads by updating the values in local
storage as soon as the value gets updated
*/
let sm = localStorage.getItem(`tqna-streamerMode`) ?? `false`;
export const isStreamer = writable<boolean>(sm == `true`);
isStreamer.subscribe(v => localStorage.setItem(`tqna-streamerMode`, String(v)));
let df = localStorage.getItem(`tqna-dyslexic`) ?? `false`;
export const dyslexiaFont = writable<boolean>(df == `true`);
dyslexiaFont.subscribe(v => localStorage.setItem(`tqna-dyslexic`, String(v)))
let ri = JSON.parse(localStorage.getItem(`tqna-refreshRate`) ?? `5`);
export const refreshInterval = writable<number>(ri ?? 5);
refreshInterval.subscribe(v => localStorage.setItem(`tqna-refreshRate`, JSON.stringify(v)));

View file

@ -0,0 +1,7 @@
interface Question {
id: number;
question: string;
asker: string;
answered: boolean;
hidden: boolean;
};