summaryrefslogtreecommitdiff
path: root/frontend
diff options
context:
space:
mode:
Diffstat (limited to 'frontend')
-rw-r--r--frontend/Makefile11
-rw-r--r--frontend/README.md17
-rw-r--r--frontend/eslint.config.js116
-rw-r--r--frontend/student.js142
-rw-r--r--frontend/style.css353
5 files changed, 639 insertions, 0 deletions
diff --git a/frontend/Makefile b/frontend/Makefile
new file mode 100644
index 0000000..d863e84
--- /dev/null
+++ b/frontend/Makefile
@@ -0,0 +1,11 @@
+.PHONY: frontend
+
+frontend: ../dist/static/student.js ../dist/static/style.css
+
+../dist/static/student.js:
+ mkdir -p ../dist/static
+ minify student.js -o ../dist/static/student.js
+
+../dist/static/style.css:
+ mkdir -p ../dist/static
+ minify style.css -o ../dist/static/style.css
diff --git a/frontend/README.md b/frontend/README.md
new file mode 100644
index 0000000..d586f3a
--- /dev/null
+++ b/frontend/README.md
@@ -0,0 +1,17 @@
+# Frontend
+
+We do not use a JavaScript package manager because we don't use any JavaScript
+libraries at all.
+
+## JavaScript linting
+
+eslint may be installed separately via pgx if linting is desired.
+
+## Building
+
+Building is actually just minification.
+
+```sh
+go install github.com/tdewolff/minify/v2/cmd/minify@latest
+make
+```
diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
new file mode 100644
index 0000000..0245ab8
--- /dev/null
+++ b/frontend/eslint.config.js
@@ -0,0 +1,116 @@
+export default [
+ {
+ files: ["*.js"],
+ languageOptions: {
+ globals: {
+ document: "readonly",
+ alert: "readonly",
+ WebSocket: "readonly"
+ }
+ }
+ },
+ {
+ rules: {
+ indent: ["error", "tab"],
+ "no-negated-in-lhs": "error",
+ "no-cond-assign": ["error", "except-parens"],
+ curly: ["error", "all"],
+ "object-curly-spacing": ["error", "always"],
+ "computed-property-spacing": ["error", "never"],
+ "array-bracket-spacing": ["error", "never"],
+ eqeqeq: ["error", "smart"],
+ "no-unused-expressions": "error",
+ "no-sequences": "error",
+ "no-nested-ternary": "error",
+ "no-unreachable": "error",
+ "wrap-iife": ["error", "inside"],
+ "no-caller": "error",
+ quotes: ["error", "double"],
+ "no-undef": "error",
+ "no-unused-vars": [
+ "error",
+ {
+ args: "all",
+ argsIgnorePattern: "^_"
+ }
+ ],
+ "operator-linebreak": ["error", "after"],
+ "comma-style": ["error", "last"],
+ camelcase: [
+ "error",
+ {
+ properties: "never"
+ }
+ ],
+ "dot-notation": [
+ "error",
+ {
+ allowPattern: "^[a-z]+(_[a-z]+)+$"
+ }
+ ],
+ "max-len": [
+ "error",
+ {
+ code: 200,
+ ignoreComments: true,
+ ignoreUrls: true,
+ ignoreRegExpLiterals: true
+ }
+ ],
+ "no-mixed-spaces-and-tabs": "error",
+ "no-trailing-spaces": "error",
+ "no-irregular-whitespace": "error",
+ "no-multi-str": "error",
+ "comma-dangle": ["error", "never"],
+ "comma-spacing": [
+ "error",
+ {
+ before: false,
+ after: true
+ }
+ ],
+ "space-before-blocks": ["error", "always"],
+ "space-in-parens": ["error", "never"],
+ "keyword-spacing": [2],
+ "template-curly-spacing": ["error", "always"],
+ semi: ["error", "never"],
+ "semi-spacing": [
+ "error",
+ {
+ before: false,
+ after: true
+ }
+ ],
+ "no-extra-semi": "error",
+ "space-infix-ops": "error",
+ "eol-last": "error",
+ "lines-around-comment": [
+ "error",
+ {
+ beforeLineComment: true
+ }
+ ],
+ "linebreak-style": ["error", "unix"],
+ "no-with": "error",
+ "brace-style": "error",
+ "space-before-function-paren": ["error", "never"],
+ "no-loop-func": "error",
+ "no-spaced-func": "error",
+ "key-spacing": [
+ "error",
+ {
+ beforeColon: false,
+ afterColon: true
+ }
+ ],
+ "space-unary-ops": [
+ "error",
+ {
+ words: false,
+ nonwords: false
+ }
+ ],
+ "no-multiple-empty-lines": 2
+ }
+ }
+]
diff --git a/frontend/student.js b/frontend/student.js
new file mode 100644
index 0000000..9d220ef
--- /dev/null
+++ b/frontend/student.js
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2024 Runxi Yu <https://runxiyu.org>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+document.addEventListener("DOMContentLoaded", () => {
+ const socket = new WebSocket("wss://localhost.runxiyu.org:8080/ws")
+ socket.addEventListener("open", function() {
+ var _handleMessage = event => {
+ let msg = new String(event?.data)
+
+ /*
+ * Standard IRC Message format parsing without IRCv3 tags or prefixes.
+ * It's a simple enough protocol format suitable for our use-case.
+ * No need for protobuf or anything else nontrivial.
+ */
+ let mar = msg.split(" ")
+ for (let i = 0; i < mar.length; i++) {
+ if (mar[i].startsWith(":")) {
+ if (i === mar.length - 1) {
+ mar[i] = mar[i].substring(1)
+ break
+ }
+ mar[i] = mar[i].substring(1) + " " + mar.slice(i + 1).join(" ")
+ mar.splice(i + 1)
+ break
+ }
+ }
+
+ switch (mar[0]) {
+ case "E": /* unexpected error */
+ alert(`The server reported an unexpected error, "${ mar[1] }". The system might be in an inconsistent state.`)
+ break
+ case "HI":
+ document.querySelectorAll(".need-connection").forEach(c => {
+ c.style.display = "block"
+ })
+ document.querySelectorAll(".before-connection").forEach(c => {
+ c.style.display = "none"
+ })
+ if (mar[1] !== "") {
+ let courseIDs = mar[1].split(",")
+ for (let i = 0; i < courseIDs.length; i++) {
+ document.getElementById(`tick${ courseIDs[i] }`).checked = true
+ document.getElementById(`tick${ courseIDs[i] }`).disabled = false
+ }
+ }
+ break
+ case "U": /* unauthenticated */
+ /* TODO: replace this with a box on screen */
+ alert("Your session is broken or has expired. You are unauthenticated and the server will reject your commands.")
+ break
+ case "N":
+ document.getElementById(`tick${ mar[1] }`).checked = false
+ document.getElementById(`tick${ mar[1] }`).indeterminate = false
+ break
+ case "M":
+ document.getElementById(`selected${ mar[1] }`).textContent = mar[2]
+ if (mar[2] === document.getElementById(`max${ mar[1] }`).textContent && !(document.getElementById(`tick${ mar[1] }`).checked)) {
+ document.getElementById(`tick${ mar[1] }`).disabled = true
+ } else {
+ document.getElementById(`tick${ mar[1] }`).disabled = false
+ }
+ break
+ case "R": /* course selection rejected */
+ document.getElementById(`coursestatus${ mar[1] }`).textContent = mar[2]
+ document.getElementById(`coursestatus${ mar[1] }`).style.color = "red"
+ document.getElementById(`tick${ mar[1] }`).checked = false
+ document.getElementById(`tick${ mar[1] }`).indeterminate = false
+ if (mar[2] === "Full") {
+ document.getElementById(`tick${ mar[1] }`).disabled = true
+ }
+ break
+ case "Y": /* course selection approved */
+ document.getElementById(`coursestatus${ mar[1] }`).textContent = ""
+ document.getElementById(`coursestatus${ mar[1] }`).style.removeProperty("color")
+ document.getElementById(`tick${ mar[1] }`).checked = true
+ document.getElementById(`tick${ mar[1] }`).indeterminate = false
+ break
+ default:
+ alert(`Invalid command ${ mar[0] } received from socket. Something is wrong.`)
+ }
+ }
+ socket.addEventListener("message", _handleMessage)
+ var _handleClose = _event => {
+ document.querySelectorAll(".need-connection").forEach(c => {
+ c.style.display = "none"
+ })
+ document.querySelectorAll(".broken-connection").forEach(c => {
+ c.style.display = "block"
+ })
+ }
+ socket.addEventListener("close", _handleClose)
+ socket.send("HELLO")
+ })
+
+ document.querySelectorAll(".coursecheckbox").forEach(c => {
+ c.addEventListener("input", () => {
+ if (c.id.slice(0, 4) !== "tick") {
+ alert(`${ c.id } is not in the correct format.`)
+ return false
+ }
+ switch (c.checked) {
+ case true:
+ c.indeterminate = true
+ socket.send(`Y ${ c.id.slice(4) }`)
+ break
+ case false:
+ c.indeterminate = true
+ socket.send(`N ${ c.id.slice(4) }`)
+ break
+ default:
+ alert(`${ c.id }'s "checked" attribute is ${ c.checked } which is invalid.`)
+ }
+ return false
+ })
+ })
+
+ document.getElementById("confirmbutton").addEventListener("click", () => {
+ socket.send("C")
+ })
+
+ document.querySelectorAll(".script-required").forEach(c => {
+ c.style.display = "block"
+ })
+ document.querySelectorAll(".script-unavailable").forEach(c => {
+ c.style.display = "none"
+ })
+})
diff --git a/frontend/style.css b/frontend/style.css
new file mode 100644
index 0000000..15c3add
--- /dev/null
+++ b/frontend/style.css
@@ -0,0 +1,353 @@
+/*
+ * Copyright (c) 2024 Runxi Yu <https://runxiyu.org>
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+
+/*
+ * TODO: Remove all uses of !important. These are obviously bad practice, but
+ * it's not always trivial to get the precedence right.
+ */
+
+:root {
+ --primary-bg: white;
+ --primary-fg: #212529;
+ --border: #ced4da;
+ --anchor-underline-color: lightgray;
+ --anchor-color: #0062cc;
+ --theme: #0062cc;
+ --theme-contrast: white;
+ --box: #f2f2f2;
+ --box-contrast: var(--primary-fg);
+ --danger: #d32535;
+ --danger-contrast: white;
+ --white: white;
+ --white-contrast: #222222;
+ --header-fg: black;
+ --header-bg: #f2f2f2;
+}
+
+@media (prefers-color-scheme: dark) {
+ :root {
+ --primary-bg: #212529;
+ --primary-fg: #f8f9fa;
+ --border: #495057;
+ --anchor-underline-color: #4F4F4F;
+ --anchor-color: #3294fe;
+ --theme: #0062cc;
+ --theme-contrast: #f8f9fa;
+ --box: #30363B;
+ --box-contrast: #f8f9fa;
+ --danger: #d32535;
+ --danger-contrast: #f8f9fa;
+ --white: #202020;
+ --white-contrast: #f8f9fa;
+ --header-fg: #f8f9fa;
+ --header-bg: #30363b;
+ }
+}
+
+html {
+ font-family: system-ui, sans-serif;
+ line-height: 1.2;
+ background-color: var(--primary-bg);
+ color: var(--primary-fg);
+}
+
+body {
+ margin: 0;
+ padding: 0;
+}
+
+main,
+body > section,
+.reading-width,
+footer {
+ margin: 1rem auto;
+ padding-left: 1rem;
+ padding-right: 1rem;
+ max-width: 60rem;
+}
+
+/*
+ * For accessibility reasons, we still want anchors to be underlined, but
+ * perhaps not as profound of an underline as the default.
+ */
+a {
+ color: var(--anchor-color);
+ text-decoration: underline;
+ text-decoration-color: var(--anchor-underline-color);
+}
+
+/*
+ * However, although the site title will be an anchor, it should not be
+ * underlined.
+ */
+#site-title {
+ text-decoration: none;
+}
+
+/*
+ * Navigation is a simple bulleted list with bullets in the middle only.
+ * This should probably be revamped.
+ */
+nav ul {
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+}
+nav ul > li {
+ display: inline-block;
+}
+nav ul > li:not(:last-child)::after {
+ content:"\2000"
+}
+
+/*
+ * The header should stick to the top of the page.
+ */
+header {
+ position: -webkit-sticky;
+ position: sticky;
+ top: 0;
+ left: 0;
+ color: var(--header-fg);
+ background-color: var(--header-bg);
+ z-index: 1000;
+ width: 100%;
+}
+
+/*
+ * We don't want underlined anchors in the header in general, since it should
+ * be obvious that things in it are links.
+ */
+header a {
+ text-decoration: none;
+ color: var(--header-fg);
+}
+.header-content {
+ padding-left: 1rem;
+ padding-right: 1rem;
+ max-width: 60rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 auto;
+}
+header img {
+ vertical-align: middle;
+ max-height: 40px;
+}
+header h1 {
+ font-size: 25px; /* TODO: Specifying font sizes in pixels is bad */
+}
+
+/*
+ * The table, the most important element in my site design...
+ */
+table {
+ margin-top: 0.4em;
+ margin-bottom: 0.4em;
+ border-collapse: collapse;
+ border: 1px solid var(--border);
+}
+table.wide {
+ width: 100%;
+}
+th[scope~="row"] {
+ text-align: left;
+}
+th[scope~="col"] {
+}
+td {
+ border: 1px solid;
+ text-align: left;
+ height: 1.25rem;
+ border: 1px solid var(--border);
+ padding: 3px 5px;
+}
+table.fat td {
+ padding: 6px 5px;
+}
+td.th-like, th {
+ background-color: var(--box) !important;
+ border: 1px solid var(--border);
+ font-weight: bold;
+ padding: 3px 5px;
+}
+th.min, td.min {
+ width: 0;
+ min-width: fit-content;
+ white-space: nowrap;
+}
+
+/*
+ * Input elements, which are usually in tables anyway
+ */
+textarea {
+ box-sizing: border-box;
+ background-color: var(--box);
+ resize: vertical;
+}
+textarea,
+input[type=text],
+input[type=password] {
+ font-family: sans-serif;
+ font-size: smaller;
+ background-color: var(--box);
+ color: var(--box-contrast);
+ border: none;
+ padding: 0.3rem;
+ width: 100%;
+ box-sizing: border-box;
+}
+td.tdinput, th.tdinput {
+ padding: 0rem !important;
+}
+td.tdinput textarea,
+td.tdinput input[type=text],
+td.tdinput input[type=password],
+th.tdinput textarea,
+th.tdinput input[type=text],
+th.tdinput input[type=password] {
+ background-color: transparent !important;
+}
+
+/*
+ * Button definitions.
+ *
+ * Each button should contain the .btn class and a .btn-type class, where type
+ * is one of primary, danger, white, and normal.
+ */
+.btn-primary {
+ background: var(--theme);
+ color: var(--theme-contrast);
+ border: var(--border) 1px solid;
+ font-weight: bold;
+}
+.btn-danger {
+ background: var(--danger);
+ color: var(--danger-contrast);
+ border: var(--border) 1px solid;
+ font-weight: bold;
+}
+.btn-white {
+ background: var(--white);
+ color: var(--white-contrast);
+ border: var(--border) 1px solid;
+}
+.btn-normal,
+input[type=file]::file-selector-button {
+ background: var(--box-contrast);
+ border: var(--border) 1px solid !important;
+ color: var(--box-contrast);
+}
+.btn,
+input[type=submit],
+input[type=file]::file-selector-button {
+ display: inline-block;
+ width: auto;
+ min-width: fit-content;
+ border-radius: 0;
+ padding: .1rem .75rem;
+ font-size: 0.9rem;
+ transition: background .1s linear;
+ cursor: pointer;
+}
+a.btn {
+ text-decoration: none;
+}
+
+
+/*
+ * Multiple columns, flexible wrapping
+ */
+.multicols {
+ display: flex;
+ flex-direction: row;
+ @media(max-width: 50rem) {
+ flex-wrap: wrap;
+ gap: 0rem;
+ }
+ gap: 2rem;
+ align-items: stretch;
+}
+
+.multicols div {
+ min-width: 18em;
+ /* max-width: 40rem; */
+ width: 100%;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+/*
+ * Spanning elements across a flex container with equal space in between
+ */
+.flex-justify {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin: 0 auto;
+ border: none;
+}
+
+.message-box {
+ margin: auto;
+ max-width: 30rem;
+ border: solid 1px var(--border);
+ background-color: var(--box);
+ padding: 0rem 1rem;
+}
+
+table#table-of-courses {
+ width: 100%;
+}
+
+/*
+ * .need-connection is the content that should actually display when we are
+ * connected via WebSocket. The JavaScript would change display from none to
+ * block when fully connected to WebSocket.
+ */
+.need-connection {
+ display: none;
+}
+
+/*
+ * Same for script-required, though the JavaScript hides this as soon as it's
+ * loaded.
+ */
+.script-required {
+ display: none;
+}
+
+/*
+ * .broken-connection displays a message telling users to refresh the page,
+ * after their WebSocket connection breaks. It should be hidden by default.
+ */
+.broken-connection {
+ display: none;
+}
+
+/*
+ * This site heavily uses CSS styling to display and hide messages, so by
+ * default we put a big warning about CSS being broken, which disappears
+ * once the main CSS, i.e. this file, is completely loaded. Therefore it's
+ * probably best to put this at the bottom of this file.
+ */
+.broken-styling-warning {
+ display: none;
+}