diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..1a6bd45
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+package-lock.json binary
diff --git a/.gitignore b/.gitignore
index 9c691df..decda56 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,109 @@
-.fo76-dumps-ids.db
+## NPM
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# TypeScript v1 declaration files
+typings/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+*.tgz
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variables file
+.env
+.env.test
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+
+# Next.js build output
+.next
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and *not* Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+
+## Custom
+src/main/.fo76-dumps-ids.db
diff --git a/Gruntfile.js b/Gruntfile.js
new file mode 100644
index 0000000..436645e
--- /dev/null
+++ b/Gruntfile.js
@@ -0,0 +1,126 @@
+const path = require("path");
+
+module.exports = grunt => {
+ grunt.initConfig({
+ pkg: grunt.file.readJSON("package.json"),
+ clean: {
+ default: ["dist/"],
+ },
+ copy: {
+ db: {
+ files: [{expand: true, cwd: "src/main/", src: "**/.*.db", dest: "dist/"}]
+ },
+ html: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/"}]
+ },
+ php: {
+ files: [{expand: true, cwd: "src/main/", src: "**/*.php", dest: "dist/"}]
+ },
+ },
+ focus: {
+ dev: {
+ include: ["html", "js", "link", "php"],
+ },
+ },
+ replace: {
+ dev: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>+" + new Date().toISOString().slice(0, 19).replace(/[-:T]/g, "")
+ }
+ ],
+ overwrite: true
+ },
+ deploy: {
+ src: ["./dist/*.html", "./dist/*.js"],
+ replacements: [
+ {
+ from: "%%VERSION_NUMBER%%",
+ to: "<%= pkg.version %>"
+ }
+ ],
+ overwrite: true
+ },
+ },
+ watch: {
+ html: {
+ files: ["src/main/**/*.html"],
+ tasks: ["copy:html"],
+ },
+ js: {
+ files: ["src/main/**/*.js"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ link: {
+ files: ["node_modules/@fwdekker/*/dist/**"],
+ tasks: ["webpack:dev", "replace:dev"],
+ },
+ php: {
+ files: ["src/main/**/*.php"],
+ tasks: ["copy:php"],
+ },
+ },
+ webpack: {
+ options: {
+ entry: "./src/main/js/index.js",
+ module: {
+ rules: [
+ {
+ test: /\.js$/,
+ exclude: /node_modules/,
+ },
+ ],
+ },
+ resolve: {
+ extensions: [".js"],
+ },
+ output: {
+ filename: "bundle.js",
+ path: path.resolve(__dirname, "dist/"),
+ },
+ },
+ dev: {
+ mode: "development",
+ devtool: "inline-source-map",
+ },
+ deploy: {
+ mode: "production",
+ },
+ },
+ });
+
+ grunt.loadNpmTasks("grunt-contrib-clean");
+ grunt.loadNpmTasks("grunt-contrib-copy");
+ grunt.loadNpmTasks("grunt-contrib-watch");
+ grunt.loadNpmTasks("grunt-focus");
+ grunt.loadNpmTasks("grunt-text-replace");
+ grunt.loadNpmTasks("grunt-webpack");
+
+ grunt.registerTask("dev", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:db",
+ "copy:html",
+ "copy:php",
+ // Compile JS
+ "webpack:dev",
+ "replace:dev",
+ ]);
+ grunt.registerTask("dev:server", ["dev", "focus:dev"]);
+ grunt.registerTask("deploy", [
+ // Pre
+ "clean",
+ // Copy files
+ "copy:db",
+ "copy:html",
+ "copy:php",
+ // Compile JS
+ "webpack:deploy",
+ "replace:deploy",
+ ]);
+
+ grunt.registerTask("default", ["dev"]);
+};
diff --git a/index.html b/index.html
deleted file mode 100644
index 531f124..0000000
--- a/index.html
+++ /dev/null
@@ -1,274 +0,0 @@
-
-
-
-
-
-
-
-
-
-
- Random Fallout 76 record
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..bf8b285
Binary files /dev/null and b/package-lock.json differ
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..fbeb2ae
--- /dev/null
+++ b/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "random-fo76",
+ "version": "1.0.8",
+ "description": "Random Fallout 76 record.",
+ "author": "Felix W. Dekker",
+ "browser": "dist/bundle.js",
+ "repository": {
+ "type": "git",
+ "url": "git@git.fwdekker.com:FWDekker/random-fo76.git"
+ },
+ "private": true,
+ "scripts": {
+ "clean": "grunt clean",
+ "dev": "grunt dev",
+ "dev:server": "grunt dev:server",
+ "deploy": "grunt deploy"
+ },
+ "dependencies": {
+ "@fwdekker/template": "^0.0.14",
+ "js-cookie": "^2.2.1"
+ },
+ "devDependencies": {
+ "grunt": "^1.1.0",
+ "grunt-cli": "^1.3.2",
+ "grunt-contrib-clean": "^2.0.0",
+ "grunt-contrib-copy": "^1.0.0",
+ "grunt-contrib-watch": "^1.1.0",
+ "grunt-focus": "^1.0.0",
+ "grunt-text-replace": "^0.4.0",
+ "grunt-webpack": "^3.1.3",
+ "webpack": "^4.42.1",
+ "webpack-cli": "^3.3.11"
+ }
+}
diff --git a/api.php b/src/main/api.php
similarity index 100%
rename from api.php
rename to src/main/api.php
diff --git a/src/main/index.html b/src/main/index.html
new file mode 100644
index 0000000..15023ff
--- /dev/null
+++ b/src/main/index.html
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+ Random Fallout 76 record
+
+
+
+
+
+
+ This website does not function if JavaScript is disabled.
+ Please check the
+ instructions on how to enable JavaScript in your web browser .
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/main/js/index.js b/src/main/js/index.js
new file mode 100644
index 0000000..a94a953
--- /dev/null
+++ b/src/main/js/index.js
@@ -0,0 +1,221 @@
+import {$, doAfterLoad, footer, header, nav} from "@fwdekker/template";
+import Cookies from "js-cookie";
+
+
+const signatureColCount = 8;
+
+
+/**
+ * Returns an array of the signatures that are currently selected.
+ */
+const getSelectedSignatures = () => {
+ const signatures = [];
+
+ const selectedCheckboxes = document.querySelectorAll("#signatures input:checked");
+ for (let i = 0; i < selectedCheckboxes.length; i++) {
+ const selectedCheckbox = selectedCheckboxes[i];
+ signatures.push(selectedCheckbox.value);
+ }
+
+ return signatures;
+};
+
+/**
+ * Selects the indicated signatures, and deselects all others.
+ *
+ * @param signatures the array of signatures to select
+ */
+const setSelectedSignatures = signatures => {
+ const checkboxes = document.querySelectorAll("#signatures input");
+ for (let i = 0; i < checkboxes.length; i++)
+ checkboxes[i].checked = false;
+
+ for (let i = 0; i < signatures.length; i++)
+ $(`#signature-${signatures[i]}`).checked = true;
+
+ updateSignatureToggle();
+};
+
+/**
+ * Selects all signatures.
+ */
+const setAllSignatures = checked => {
+ const checkboxes = document.querySelectorAll("#signatures input");
+ for (let i = 0; i < checkboxes.length; i++)
+ checkboxes[i].checked = checked;
+ saveSelectedSignaturesToCookie();
+
+ updateSignatureToggle();
+};
+
+/**
+ * (De)selects signatures based on the selection stored in a cookie.
+ */
+const loadSelectedSignaturesFromCookie = () => {
+ const cookie = Cookies.get("selectedSignatures");
+ let signatures;
+ if (cookie === undefined)
+ signatures = [];
+ else
+ signatures = cookie.split(",");
+
+ setSelectedSignatures(signatures);
+};
+
+/**
+ * Saves the currently-selected signatures to a cookie.
+ */
+const saveSelectedSignaturesToCookie =
+ () => Cookies.set("selectedSignatures", getSelectedSignatures().join(","), {
+ expires: 5 * 365,
+ secure: true,
+ sameSite: "lax"
+ });
+
+/**
+ * Updates the button used to toggle all signatures on or off.
+ */
+const updateSignatureToggle = () => {
+ const signatureToggle = $("#signatureToggle");
+
+ if (getSelectedSignatures().length === document.querySelectorAll("#signatures input").length) {
+ signatureToggle.innerHTML = "Deselect all signatures";
+ signatureToggle.onclick = () => setAllSignatures(false);
+ } else {
+ signatureToggle.innerHTML = "Select all signatures";
+ signatureToggle.onclick = () => setAllSignatures(true);
+ }
+};
+
+
+/**
+ * Downloads an array of signatures from the API.
+ *
+ * @param callback the function to execute with the array of signatures
+ * @param handle the function to execute if signatures could not be downloaded
+ */
+const downloadSignatures = (callback, handle) => {
+ fetch("api.php?action=list-signatures")
+ .then(response => {
+ if (!response.ok) {
+ if (handle) handle(response);
+ console.error(response);
+ throw new Error("Failed to fetch list of signatures.");
+ }
+
+ return response.json();
+ })
+ .then(signatures => callback(signatures));
+};
+
+/**
+ * Creates buttons for the signatures and adds them to the form.
+ *
+ * @param signatures an array of signatures to create buttons for
+ */
+const createSignatureButtons = signatures => {
+ const form = $("#signatures");
+ form.innerHTML = "";
+
+ let row;
+ for (let i = 0; i < signatures.length; i++) {
+ const signature = signatures[i];
+
+ if (i % signatureColCount === 0) {
+ if (row !== undefined)
+ form.appendChild(row);
+
+ row = document.createElement("div");
+ row.className = "row";
+ }
+
+ const col = document.createElement("div");
+ col.className = "column";
+
+ const label = document.createElement("label");
+ label.htmlFor = `signature-${signature}`;
+ label.innerHTML = signature;
+ col.appendChild(label);
+
+ const checkbox = document.createElement("input");
+ checkbox.type = "checkbox";
+ checkbox.id = `signature-${signature}`;
+ checkbox.name = `signature-${signature}`;
+ checkbox.value = signature;
+ checkbox.onclick = () => {
+ updateSignatureToggle();
+ saveSelectedSignaturesToCookie();
+ };
+ col.appendChild(checkbox);
+
+ row.appendChild(col);
+ }
+};
+
+/**
+ * Downloads a random record from the API.
+ *
+ * @param callback the function to execute with the record
+ * @param handle the function to execute if signatures could not be downloaded
+ */
+const downloadRandomRecord = (callback, handle) => {
+ const selectedSignatures = getSelectedSignatures();
+
+ fetch(`api.php?action=get-random&signatures=${selectedSignatures.join(",")}`)
+ .then(response => {
+ if (!response.ok) {
+ if (handle) handle(response);
+ console.error(response);
+ throw new Error("Failed to fetch random record.");
+ }
+
+ return response.text();
+ })
+ .then(record => callback(record));
+};
+
+/**
+ * Displays a record on the page.
+ *
+ * @param record the record to display
+ */
+const showRecord = (record) => {
+ $("#output").innerHTML = record;
+
+ const scrollingElement = (document.scrollingElement || document.body);
+ scrollingElement.scrollTop = scrollingElement.scrollHeight;
+};
+
+
+doAfterLoad(() => {
+ $("#nav").appendChild(nav());
+ $("#header").appendChild(header({
+ title: "Random Fallout 76 record",
+ description: "Retrieve a random record from the Fallout 76 game files"
+ }));
+ $("#footer").appendChild(footer({
+ author: "Felix W. Dekker",
+ authorURL: "https://fwdekker.com/",
+ license: "MIT License",
+ licenseURL: "https://git.fwdekker.com/FWDekker/interlanguage-checker/src/branch/master/LICENSE",
+ vcs: "git",
+ vcsURL: "https://git.fwdekker.com/FWDekker/interlanguage-checker/",
+ version: "v%%VERSION_NUMBER%%"
+ }));
+ $("main").style.display = null;
+});
+
+doAfterLoad(() => {
+ $("#submit").onclick = () => downloadRandomRecord(record => showRecord(record));
+
+ downloadSignatures(
+ signatures => {
+ createSignatureButtons(signatures);
+ loadSelectedSignaturesFromCookie();
+ },
+ errorResponse => {
+ const form = $("#signatureForm");
+ form.style.color = "red";
+ form.innerHTML = "Error: Failed to download signatures."
+ });
+});