Make frontend js-less

The frontend should work without having JS enabled. JS will enhance the
experience but should under no circumstance be necessary.
To achieve this, the entire entry system has been turned into a form
that posts its values to a new backend path specifically made to take
form responses. Instead of returning an API response, it also redirects
the browser to either the voting page on successful creation or the
frontpage with a bunch of get parameters that are used to prefill the
form in a server-side rendering process.
An error parameter is also given but there is no way to display said
error for now.
This commit is contained in:
Wolvan 2022-01-01 04:13:24 +01:00
parent 862fe9d2f2
commit 9fa3eabad1
6 changed files with 138 additions and 59 deletions

View file

@ -17,33 +17,27 @@
<main> <main>
<section class="notepad"> <section class="notepad">
<div class="notepad-border"></div> <div class="notepad-border"></div>
<section class="poll-title"> <form action="{{ BACKEND_BASE_PATH }}/_backend/poll-form" method="POST">
<input id="poll-title" type="text" maxlength="300" placeholder="Enter your question here"> <section class="poll-title">
</section> <input id="poll-title" name="poll-title" type="text" maxlength="300" placeholder="Enter your question here" value="{{ FORM_TITLE }}">
<section id="poll-options" class="poll-options"> </section>
<div class="poll-option"> <section id="poll-options" class="poll-options">
<input type="text" maxlength="300" placeholder="Enter your option here"> {{ FORM_OPTION_DIVS }}
</div> </section>
<div class="poll-option"> <section class="poll-footer">
<input type="text" maxlength="300" placeholder="Enter your option here"> <select id="dupe-check" name="dupe-check">
</div> <option value="ip" {{ FORM_DUPECHECK_IP }}>IP-based duplication checking</option>
<div class="poll-option"> <option value="cookie" {{ FORM_DUPECHECK_COOKIE }}>Cookie-based duplication checking</option>
<input type="text" maxlength="300" placeholder="Enter your option here"> <option value="none" {{ FORM_DUPECHECK_NONE }}>No duplication checking</option>
</div> </select>
</section> <br>
<section class="poll-footer"> <label>
<select id="dupe-check"> <input type="checkbox" name="multi-select" {{ FORM_MULTI_SELECT }} id="multiple">
<option value="ip" selected>IP-based duplication checking</option> <span>Allow multiple answers to be selected</span>
<option value="cookie">Cookie-based duplication checking</option> </label>
<option value="none">No duplication checking</option> <br>
</select> <input type="submit" name="submit" value="Create poll" id="create-poll">
<br> </form>
<label>
<input type="checkbox" id="multiple">
<span>Allow multiple answers to be selected</span>
</label>
<br>
<button id="create-poll">Create poll</button>
</section> </section>
</section> </section>
</main> </main>

View file

@ -88,6 +88,7 @@ main .notepad-border {
} }
/* #endregion notepad */ /* #endregion notepad */
/* #region notepad-footer */ /* #region notepad-footer */
main .notepad .poll-footer input[type="submit"],
main .notepad .poll-footer button { main .notepad .poll-footer button {
padding: 0 30px; padding: 0 30px;
height: 50px; height: 50px;
@ -100,6 +101,7 @@ main .notepad .poll-footer button {
color: #fff; color: #fff;
margin-right: 5px; margin-right: 5px;
} }
main .notepad .poll-footer input[type="submit"]:hover,
main .notepad .poll-footer button:hover { main .notepad .poll-footer button:hover {
background-color: #b6b6b6; background-color: #b6b6b6;
} }

View file

@ -1,9 +1,9 @@
"use strict"; "use strict";
type DupeCheckMode = "none" | "ip" | "cookie";
type BasePoll = { type BasePoll = {
id: string, id: string,
title: string, title: string,
dupeCheckMode: "none" | "ip" | "cookie", dupeCheckMode: DupeCheckMode,
multiSelect: boolean, multiSelect: boolean,
captcha: boolean, captcha: boolean,
creationTime: Date, creationTime: Date,
@ -30,5 +30,6 @@ type PollResult = {
export { export {
FrontendPoll, FrontendPoll,
BackendPoll, BackendPoll,
PollResult PollResult,
DupeCheckMode
}; };

View file

@ -4,7 +4,7 @@ import { Router } from "express";
import persist from "node-persist"; import persist from "node-persist";
import { program } from "commander"; import { program } from "commander";
import { resolve } from "path"; import { resolve } from "path";
import { BackendPoll as Poll } from "./Poll"; import { BackendPoll as Poll, DupeCheckMode } from "./Poll";
function randomString(length = 10, charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789") { function randomString(length = 10, charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789") {
let result = ""; let result = "";
@ -60,39 +60,96 @@ export default async function init(router: Router): Promise<void> {
} }
}); });
async function createPoll(pollData: {
title: string,
options: string[],
dupeCheckMode: DupeCheckMode,
multiSelect: boolean,
captcha: boolean
}): Promise<Poll | string> {
if (!Array.isArray(pollData.options) || pollData.options.filter(i => i).length < 2)
return "Options must be an array and have at least 2 entries";
let id = randomString(8);
while (await polls.getItem(id)) id = randomString(6);
await polls.setItem(id, {});
const dupeCheckMode = (
["none", "ip", "cookie"].includes((pollData.dupeCheckMode || "").toLowerCase()) ?
(pollData.dupeCheckMode || "").toLowerCase() : "ip"
) as DupeCheckMode;
const dupeData =
dupeCheckMode === "none" ? null :
dupeCheckMode === "ip" ? [] :
dupeCheckMode === "cookie" ? randomString(16) : null;
const poll: Poll = {
id,
title: (pollData.title || "").trim().slice(0, 300),
options: (() => {
const result: { [option: string]: number } = {};
for (const option of pollData.options.map(i => i.trim().slice(0, 300))) {
if (option) result[option] = 0;
}
return result;
})(),
dupeCheckMode,
dupeData,
multiSelect: pollData.multiSelect || false,
captcha: pollData.captcha || false,
creationTime: new Date()
};
await polls.setItem(id, poll);
return poll;
}
router.post("/poll", async (req, res) => { router.post("/poll", async (req, res) => {
try { try {
const options = req.body.options; const poll = await createPoll({
if (!Array.isArray(options) || options.filter(i => i).length < 2)
return res.status(400).json({ error: "Options must be an array and have at least 2 entries" });
let id = randomString(8);
while (await polls.getItem(id)) id = randomString(6);
await polls.setItem(id, {});
const dupeCheckMode = ["none", "ip", "cookie"].includes((req.body.dupeCheckMode || "").toLowerCase()) ? (req.body.dupeCheckMode || "").toLowerCase() : "ip";
const dupeData =
dupeCheckMode === "none" ? null :
dupeCheckMode === "ip" ? [] :
dupeCheckMode === "cookie" ? randomString(16) : null;
const poll: Poll = {
id,
title: (req.body.title || "").trim().slice(0, 300), title: (req.body.title || "").trim().slice(0, 300),
options: (() => { options: req.body.options,
const result: { [option: string]: number } = {}; dupeCheckMode: req.body.dupeCheckMode,
for (const option of options.map(i => i.trim().slice(0, 300))) {
if (option) result[option] = 0;
}
return result;
})(),
dupeCheckMode,
dupeData,
multiSelect: req.body.multiSelect || false, multiSelect: req.body.multiSelect || false,
captcha: req.body.captcha || false, captcha: req.body.captcha || false,
creationTime: new Date()
};
await polls.setItem(id, poll);
res.json({
id: id
}); });
if (typeof poll !== "string") res.json({
id: poll.id
});
else res.status(400).json({
error: poll
});
} catch (error) {
console.error(error);
if (error instanceof Error) res.status(500).json({
error: error.message
});
else res.status(500).json({
error: error
});
}
});
router.post("/poll-form", async (req, res) => {
try {
const poll = await createPoll({
title: (req.body["poll-title"] || "").trim().slice(0, 300),
options: req.body["poll-option"],
dupeCheckMode: req.body["dupe-check"],
multiSelect: req.body["multi-select"] === "on",
captcha: req.body["captcha"] === "on",
});
if (typeof poll !== "string") res.redirect("/" + poll.id);
else res.redirect(`/?error=${
encodeURIComponent(poll)
}&title=${
encodeURIComponent(req.body["poll-title"])
}&options=${
encodeURIComponent((req.body["poll-option"] || []).join("\uFFFE"))
}&dupecheck=${
encodeURIComponent(req.body["dupe-check"])
}&multiselect=${
req.body["multi-select"] === "on"
}&captcha=${
req.body["captcha"] === "on"
}`);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
if (error instanceof Error) res.status(500).json({ if (error instanceof Error) res.status(500).json({

View file

@ -126,5 +126,29 @@ export default function init(router: Router): void {
res.redirect(`/`); res.redirect(`/`);
} }
}); });
router.get("/", (req, res) => displayPage(req, res, "index.html"));
router.get("/", (req, res) => {
const options = (typeof req.query.options === "string" ? req.query.options.split("\uFFFE") : [])
.filter(i => i)
.concat(Array(3).fill(""))
.slice(0, 3);
const pollOptionDivs = options.map(option => `
<div class="poll-option">
<input type="text" name="poll-option" maxlength="300" placeholder="Enter your option here" value="${option}">
</div>
`).join("");
displayPage(req, res, "index.html", {
"BACKEND_BASE_PATH": (program.opts().backendBaseUrl || ""),
"FORM_SUBMISSION_ERROR": req.query.error,
"FORM_SUBMISSION_ERROR_SHOWN_CLASS": req.query.error ? "error-visible" : "",
"FORM_TITLE": req.query.title || "",
"FORM_DUPECHECK_IP": req.query.dupecheck === "ip" ? "selected" : "",
"FORM_DUPECHECK_COOKIE": req.query.dupecheck === "cookie" ? "selected" : "",
"FORM_DUPECHECK_NONE": req.query.dupecheck === "none" ? "selected" : "",
"FORM_MULTI_SELECT": req.query.multiselect === "true" ? "checked" : "",
"FORM_CAPTCHA": req.query.captcha === "true" ? "checked" : "",
"FORM_OPTION_DIVS": pollOptionDivs
});
});
} }

View file

@ -19,6 +19,7 @@ async function main(): Promise<void> {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use(express.urlencoded({ extended: true }));
app.use(compression()); app.use(compression());
app.use(cookiepaser()); app.use(cookiepaser());