diff options
Diffstat (limited to 'frontend')
-rw-r--r-- | frontend/Makefile | 11 | ||||
-rw-r--r-- | frontend/README.md | 17 | ||||
-rw-r--r-- | frontend/eslint.config.js | 116 | ||||
-rw-r--r-- | frontend/student.js | 142 | ||||
-rw-r--r-- | frontend/style.css | 353 |
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; +} |