Fix Commit history
57
TFR/server/__init__.py
Normal file
|
@ -0,0 +1,57 @@
|
|||
from random import randint
|
||||
|
||||
from flask import Flask, render_template, abort
|
||||
from flask_assets import Bundle
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from server.extensions import db, migrate, cache, assets, login_manager
|
||||
from server.models import Users
|
||||
from server import views, auth, api
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_pyfile("config.py")
|
||||
|
||||
db.init_app(app)
|
||||
migrate.init_app(app, db)
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
login_manager.init_app(app)
|
||||
login_manager.login_view = "auth.auth"
|
||||
|
||||
assets.init_app(app)
|
||||
|
||||
scripts = Bundle("js/*.js", filters="jsmin", output="gen/scripts.js", depends="js/*.js")
|
||||
assets.register("scripts", scripts)
|
||||
|
||||
styles = Bundle(
|
||||
"sass/style.sass",
|
||||
filters="libsass, cssmin",
|
||||
output="gen/styles.css",
|
||||
depends="sass/*.sass",
|
||||
)
|
||||
assets.register("styles", styles)
|
||||
|
||||
cache.init_app(app)
|
||||
app.register_blueprint(views.blueprint)
|
||||
app.register_blueprint(auth.blueprint)
|
||||
app.register_blueprint(api.blueprint)
|
||||
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
return Users.query.filter_by(alt_id=user_id).first()
|
||||
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def error_page(err):
|
||||
if not isinstance(err, HTTPException):
|
||||
abort(500)
|
||||
return (
|
||||
render_template(
|
||||
"error.html", error=err.code, msg=err.description, image=str(randint(1, 3))
|
||||
),
|
||||
err.code,
|
||||
)
|
97
TFR/server/api.py
Normal file
|
@ -0,0 +1,97 @@
|
|||
import shortuuid
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required, current_user
|
||||
|
||||
from server.models import Tokens, Scores, Users
|
||||
from server.extensions import db
|
||||
from server.config import GAME_VERSION, GAME_VERSIONS, GAME_DIFFICULTIES, USER_MAX_TOKENS, MAX_SEARCH_RESULTS
|
||||
|
||||
|
||||
blueprint = Blueprint("api", __name__, url_prefix="/api")
|
||||
|
||||
|
||||
@blueprint.route("/tokens", methods=["DELETE", "POST"])
|
||||
@login_required
|
||||
def tokens():
|
||||
if request.method == "DELETE":
|
||||
token_id = request.form["token_id"]
|
||||
if not token_id:
|
||||
return jsonify({"error": "No token ID provided!"}), 400
|
||||
|
||||
token = Tokens.query.filter_by(id=token_id).first()
|
||||
if not token:
|
||||
return jsonify({"error": "Token not found!"}), 404
|
||||
if token.user_id != current_user.id:
|
||||
return jsonify({"error": "You do not own this token!"}), 403
|
||||
|
||||
db.session.delete(token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": "Token deleted!"}), 200
|
||||
elif request.method == "POST":
|
||||
if len(Tokens.query.filter_by(user_id=current_user.id).all()) >= USER_MAX_TOKENS:
|
||||
return jsonify({"error": f"You already have {USER_MAX_TOKENS} tokens!"}), 403
|
||||
|
||||
new_string = str(shortuuid.ShortUUID().random(length=20))
|
||||
token = Tokens(token=new_string, user_id=current_user.id)
|
||||
db.session.add(token)
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({"success": "Token added!"}), 200
|
||||
|
||||
|
||||
@blueprint.route("/post", methods=["POST"])
|
||||
def post():
|
||||
form = request.form
|
||||
errors = []
|
||||
|
||||
if not form:
|
||||
errors += "No form data provided!"
|
||||
if not form["token"]:
|
||||
errors += "No token provided!"
|
||||
if not form["version"]:
|
||||
errors += "No version provided!"
|
||||
|
||||
if errors:
|
||||
return jsonify(errors), 400
|
||||
|
||||
try:
|
||||
int(form["score"])
|
||||
int(form["difficulty"])
|
||||
except TypeError:
|
||||
errors += "Invalid score and difficulty must be valid numbers!"
|
||||
|
||||
if int(form["difficulty"]) not in GAME_DIFFICULTIES:
|
||||
errors += "Invalid difficulty!"
|
||||
|
||||
token_data = Tokens.query.filter_by(token=form["token"]).first()
|
||||
if not token_data:
|
||||
errors += "Authentication failed!"
|
||||
|
||||
if errors:
|
||||
return jsonify(errors), 400
|
||||
|
||||
score = Scores(
|
||||
score=int(form["score"]),
|
||||
difficulty=int(form["difficulty"]),
|
||||
version=form["version"],
|
||||
user_id=token_data.user_id,
|
||||
)
|
||||
|
||||
db.session.add(score)
|
||||
db.session.commit()
|
||||
|
||||
return "Success!", 200
|
||||
|
||||
|
||||
@blueprint.route("/users", methods=["GET"])
|
||||
def users():
|
||||
search = request.args.get("search")
|
||||
|
||||
if not search:
|
||||
return "No search query provided!", 400
|
||||
|
||||
search_results = Users.query.filter(Users.username.contains(search)).limit(MAX_SEARCH_RESULTS).all()
|
||||
|
||||
return jsonify([result.username for result in search_results]), 200
|
103
TFR/server/auth.py
Normal file
|
@ -0,0 +1,103 @@
|
|||
import re
|
||||
import uuid
|
||||
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for
|
||||
from flask_login import login_required, login_user, logout_user, current_user
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from server.extensions import db
|
||||
from server.models import Users, Tokens
|
||||
|
||||
|
||||
blueprint = Blueprint("auth", __name__)
|
||||
|
||||
|
||||
@blueprint.route("/auth", methods=["GET"])
|
||||
def auth():
|
||||
return render_template("auth.html")
|
||||
|
||||
|
||||
@blueprint.route("/account", methods=["GET"])
|
||||
@login_required
|
||||
def account():
|
||||
action = request.args.get("action", None)
|
||||
|
||||
if action == "logout":
|
||||
logout_user()
|
||||
flash("Successfully logged out!", "success")
|
||||
return redirect(url_for("views.index"))
|
||||
if action == "delete":
|
||||
flash("Insert delete function", "error")
|
||||
if action == "password":
|
||||
flash("Insert password change function", "error")
|
||||
|
||||
token_list = Tokens.query.filter_by(user_id=current_user.id).all()
|
||||
return render_template("account.html", token_list=token_list)
|
||||
|
||||
|
||||
@blueprint.route("/register", methods=["POST"])
|
||||
def register():
|
||||
# Get the form data
|
||||
username = request.form["username"].strip()
|
||||
password = request.form["password"].strip()
|
||||
username_regex = re.compile(r"\b[A-Za-z0-9._-]+\b")
|
||||
|
||||
error = []
|
||||
|
||||
# Validate the form
|
||||
if not username or not username_regex.match(username):
|
||||
error.append(
|
||||
"Username is empty or invalid! Must be alphanumeric, and can contain ._-"
|
||||
)
|
||||
if not password:
|
||||
error.append("Password is empty!")
|
||||
elif len(password) < 8:
|
||||
error.append("Password is too short! Must be at least 8 characters long.")
|
||||
if Users.query.filter_by(username=username).first():
|
||||
error.append("Username already exists!")
|
||||
|
||||
# If there are errors, return them
|
||||
if error:
|
||||
for err in error:
|
||||
flash(err, "error")
|
||||
return redirect(url_for("auth.auth"))
|
||||
|
||||
register_user = Users(
|
||||
alt_id=str(uuid.uuid4()),
|
||||
username=username,
|
||||
password=generate_password_hash(password, method="scrypt"),
|
||||
)
|
||||
db.session.add(register_user)
|
||||
db.session.commit()
|
||||
|
||||
flash("Successfully registered!", "success")
|
||||
return redirect(url_for("auth.auth"))
|
||||
|
||||
|
||||
@blueprint.route("/login", methods=["POST"])
|
||||
def login():
|
||||
# Get the form data
|
||||
username = request.form["username"].strip()
|
||||
password = request.form["password"].strip()
|
||||
username_regex = re.compile(r"\b[A-Za-z0-9._-]+\b")
|
||||
|
||||
error = []
|
||||
|
||||
# Validate the form
|
||||
if not username or not username_regex.match(username) or not password:
|
||||
error.append("Username or Password is incorrect!")
|
||||
|
||||
user = Users.query.filter_by(username=username).first()
|
||||
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
error.append("Username or Password is incorrect!")
|
||||
|
||||
# If there are errors, return them
|
||||
if error:
|
||||
for err in error:
|
||||
flash(err, "error")
|
||||
return redirect(url_for("auth.account"))
|
||||
|
||||
login_user(user, remember=True)
|
||||
flash("Successfully logged in!", "success")
|
||||
return redirect(url_for("views.index"))
|
32
TFR/server/config.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import os
|
||||
|
||||
|
||||
GAME_VERSION = "alpha"
|
||||
GAME_VERSIONS = ["alpha"]
|
||||
GAME_DIFFICULTIES = [0, 1, 2, 3, 4]
|
||||
|
||||
USER_MAX_TOKENS = 3
|
||||
|
||||
MAX_TOP_SCORES = 15
|
||||
MAX_SEARCH_RESULTS = 5
|
||||
|
||||
"""
|
||||
# Postgres
|
||||
SECRET_KEY = os.getenv("FLASK_KEY")
|
||||
|
||||
user = os.getenv("DB_USER")
|
||||
password = os.getenv("DB_PASSWORD")
|
||||
host = os.getenv("DB_HOST")
|
||||
db = os.getenv("DB_NAME")
|
||||
|
||||
SQLALCHEMY_DATABASE_URI = f"postgresql+psycopg2://{user}:{password}@{host}:5432/{db}"
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
SQLALCHEMY_POOL_RECYCLE = 621
|
||||
|
||||
MIGRATION_DIR = "/data/storage/migrations"
|
||||
INSTANCE_DIR = "/data/storage/instance"
|
||||
"""
|
||||
|
||||
# SQLite
|
||||
SECRET_KEY = "dev"
|
||||
SQLALCHEMY_DATABASE_URI = "sqlite:///tfr.db"
|
11
TFR/server/extensions.py
Normal file
|
@ -0,0 +1,11 @@
|
|||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_assets import Environment
|
||||
from flask_caching import Cache
|
||||
from flask_login import LoginManager
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
assets = Environment()
|
||||
cache = Cache(config={"CACHE_TYPE": "simple"})
|
||||
login_manager = LoginManager()
|
70
TFR/server/models.py
Normal file
|
@ -0,0 +1,70 @@
|
|||
"""
|
||||
Database models for the server
|
||||
"""
|
||||
import uuid
|
||||
from flask_login import UserMixin
|
||||
from server.extensions import db
|
||||
from server.config import GAME_VERSION
|
||||
|
||||
|
||||
class Scores(db.Model):
|
||||
"""
|
||||
Post table
|
||||
Scores supports anonymous posting, and instead just wants to post a score,
|
||||
then the username must be provided. Otherwise, it's grabbed from the user
|
||||
table
|
||||
"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
|
||||
score = db.Column(db.Float, nullable=False)
|
||||
difficulty = db.Column(db.Integer, nullable=False)
|
||||
scored_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
posted_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
|
||||
version = db.Column(db.String, default=GAME_VERSION)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id", use_alter=True))
|
||||
|
||||
|
||||
class Tokens(db.Model):
|
||||
"""
|
||||
Token table
|
||||
"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey("users.id", use_alter=True))
|
||||
token = db.Column(db.String, nullable=False, unique=True)
|
||||
created_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
|
||||
|
||||
class Users(db.Model, UserMixin):
|
||||
"""
|
||||
User table
|
||||
"""
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
alt_id = db.Column(db.String, nullable=False, unique=True)
|
||||
|
||||
username = db.Column(db.String(32), unique=True, nullable=False)
|
||||
password = db.Column(db.String, nullable=False)
|
||||
joined_at = db.Column(
|
||||
db.DateTime,
|
||||
nullable=False,
|
||||
server_default=db.func.now(),
|
||||
)
|
||||
|
||||
|
||||
scores = db.relationship("Scores", backref=db.backref('users', lazy=True))
|
||||
tokens = db.relationship("Tokens", backref=db.backref('users', lazy=True))
|
||||
|
||||
def get_id(self):
|
||||
return str(self.alt_id)
|
BIN
TFR/server/static/background.png
Normal file
After Width: | Height: | Size: 570 KiB |
BIN
TFR/server/static/background.webp
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
TFR/server/static/error-images/1.jpg
Normal file
After Width: | Height: | Size: 400 KiB |
BIN
TFR/server/static/error-images/2.jpg
Normal file
After Width: | Height: | Size: 165 KiB |
BIN
TFR/server/static/error-images/3.jpg
Normal file
After Width: | Height: | Size: 708 KiB |
54
TFR/server/static/js/main.js
Normal file
|
@ -0,0 +1,54 @@
|
|||
function addFlashMessage(message, type='success') {
|
||||
let flask = document.createElement('p');
|
||||
flask.onclick = () => flask.remove();
|
||||
flask.classList.add(type);
|
||||
flask.innerHTML = message;
|
||||
|
||||
let close = document.createElement('span');
|
||||
close.innerHTML = '<i class="ph-bold ph-x"></i>';
|
||||
|
||||
flask.appendChild(close);
|
||||
document.querySelector('.flash').appendChild(flask);
|
||||
}
|
||||
|
||||
function ajax(url, form, callback, method='POST') {
|
||||
console.log(form)
|
||||
fetch(url, {
|
||||
method: method,
|
||||
body: form,
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => callback(data))
|
||||
.catch(error => addFlashMessage(error.error, 'error'));
|
||||
}
|
||||
|
||||
function deleteToken(id) {
|
||||
let form = new FormData();
|
||||
form.append('token_id', id);
|
||||
|
||||
ajax('/api/tokens', form, (data) => {
|
||||
if (data.success) {
|
||||
addFlashMessage(data.success, 'success');
|
||||
document.querySelector(`#token-${id}`).remove();
|
||||
} else {
|
||||
addFlashMessage(data.error, 'error');
|
||||
}
|
||||
}, 'DELETE');
|
||||
}
|
||||
|
||||
function addToken() {
|
||||
ajax('/api/tokens', null, (data) => {
|
||||
if (data.success) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
addFlashMessage(data.error, 'error');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function viewToken(id) {
|
||||
let token = document.querySelector(`#token-${id}`);
|
||||
let hidden = token.children[2];
|
||||
|
||||
hidden.classList.toggle('hidden');
|
||||
}
|
88
TFR/server/static/js/search.js
Normal file
|
@ -0,0 +1,88 @@
|
|||
function showHint() {
|
||||
let search = document.querySelector('.search > input');
|
||||
let searchPos = search.getBoundingClientRect();
|
||||
let hint = document.querySelector('.search-hint');
|
||||
|
||||
hint.style.width = search.offsetWidth + 'px';
|
||||
hint.style.left = searchPos.left + 'px';
|
||||
hint.style.top = searchPos.bottom + 'px';
|
||||
|
||||
hint.style.display = 'flex';
|
||||
}
|
||||
|
||||
|
||||
function hideHint() {
|
||||
let hint = document.querySelector('.search-hint');
|
||||
hint.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
function updateHint() {
|
||||
let search = document.querySelector('.search > input');
|
||||
let searchPos = search.getBoundingClientRect();
|
||||
let hint = document.querySelector('.search-hint');
|
||||
|
||||
hint.style.width = search.offsetWidth + 'px';
|
||||
hint.style.left = searchPos.left + 'px';
|
||||
hint.style.top = searchPos.bottom + 'px';
|
||||
}
|
||||
|
||||
|
||||
function getSearch() {
|
||||
let search = document.querySelector('.search > input').value;
|
||||
let hint = document.querySelector('.search-hint');
|
||||
|
||||
if (search.length === 0) {
|
||||
hint.innerHTML = '<p>Start typing to see results...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('/api/users?search=' + search.toString(), {
|
||||
method: 'GET',
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.length === 0) {
|
||||
hint.innerHTML = '<p>No results found...</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
hint.innerHTML = '';
|
||||
|
||||
data.forEach(user => {
|
||||
let el = document.createElement('button');
|
||||
el.innerHTML = user;
|
||||
el.onmousedown = function (event) {
|
||||
event.preventDefault();
|
||||
search = document.querySelector('.search > input');
|
||||
search.value = user.toString();
|
||||
}
|
||||
hint.appendChild(el);
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
hint.innerHTML = '<p>Something went wrong...</p>';
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
window.onload = () => {
|
||||
let typingTimer;
|
||||
let search = document.querySelector('.search > input');
|
||||
|
||||
if (search === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', updateHint);
|
||||
|
||||
search.addEventListener('focus', showHint);
|
||||
search.addEventListener('blur', hideHint);
|
||||
search.addEventListener('keydown', () => {
|
||||
clearTimeout(typingTimer);
|
||||
});
|
||||
search.addEventListener('keyup', () => {
|
||||
clearTimeout(typingTimer);
|
||||
typingTimer = setTimeout(getSearch, 250);
|
||||
});
|
||||
}
|
87
TFR/server/static/sass/button.sass
Normal file
|
@ -0,0 +1,87 @@
|
|||
.button
|
||||
margin: auto 0
|
||||
padding: 0.5rem 0.7rem
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 0.5rem
|
||||
|
||||
text-decoration: none
|
||||
white-space: nowrap
|
||||
font-size: 0.9em
|
||||
|
||||
background-color: RGBA($white, 0.02)
|
||||
color: RGB($white)
|
||||
|
||||
border-radius: 2px
|
||||
border: 0 solid transparent
|
||||
|
||||
transition: background-color 0.1s ease-in-out
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($white, 0.3)
|
||||
|
||||
&.primary
|
||||
background-color: RGBA($primary, 0.02)
|
||||
color: RGB($primary)
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($primary, 0.3)
|
||||
|
||||
&.secondary
|
||||
background-color: RGBA($secondary, 0.02)
|
||||
color: RGB($secondary)
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($secondary, 0.3)
|
||||
|
||||
> i
|
||||
font-size: 1.25em
|
||||
display: block
|
||||
|
||||
.search
|
||||
margin: auto 0
|
||||
width: 100%
|
||||
|
||||
position: relative
|
||||
display: flex
|
||||
flex-direction: row
|
||||
|
||||
> label
|
||||
padding: 0.5rem 0.7rem
|
||||
|
||||
text-decoration: none
|
||||
white-space: nowrap
|
||||
font-size: 0.9em
|
||||
|
||||
background-color: RGBA($white, 0.02)
|
||||
color: RGB($white)
|
||||
border-radius: 2px 0 0 2px
|
||||
|
||||
> input
|
||||
margin: 0
|
||||
padding: 0.5rem 0.7rem
|
||||
|
||||
width: 100%
|
||||
|
||||
text-decoration: none
|
||||
white-space: nowrap
|
||||
font-size: 0.9em
|
||||
|
||||
background-color: RGBA($white, 0.02)
|
||||
color: RGB($white)
|
||||
|
||||
border: 0 solid transparent
|
||||
border-left: 1px solid RGBA($white, 0.1)
|
||||
border-radius: 0 2px 2px 0
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($white, 0.1)
|
||||
border-left: 1px solid transparent
|
||||
|
||||
&:focus-visible, &:focus
|
||||
background-color: RGBA($white, 0.1)
|
||||
|
||||
outline: 0 solid transparent
|
||||
border-left: 1px solid transparent
|
||||
border-radius: 0 2px 0 0
|
51
TFR/server/static/sass/hint.sass
Normal file
|
@ -0,0 +1,51 @@
|
|||
.search-hint
|
||||
position: absolute
|
||||
|
||||
display: none
|
||||
flex-direction: column
|
||||
|
||||
text-decoration: none
|
||||
white-space: nowrap
|
||||
font-size: 0.9em
|
||||
|
||||
background-color: RGBA($white, 0.1)
|
||||
backdrop-filter: blur(10px)
|
||||
|
||||
border-top: 1px solid RGBA($white, 0.1)
|
||||
border-radius: 0 0 2px 2px
|
||||
|
||||
overflow: hidden
|
||||
z-index: 999
|
||||
|
||||
> p
|
||||
margin: 0
|
||||
padding: 0.5rem 0.75rem
|
||||
|
||||
width: 100%
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
color: RGB($white)
|
||||
|
||||
overflow: hidden
|
||||
|
||||
> button
|
||||
padding: 0.5rem 0.75rem
|
||||
|
||||
width: 100%
|
||||
white-space: nowrap
|
||||
text-overflow: ellipsis
|
||||
|
||||
font-size: 0.9rem
|
||||
text-align: left
|
||||
|
||||
background-color: transparent
|
||||
color: RGB($white)
|
||||
border: 0 solid transparent
|
||||
|
||||
overflow: hidden
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($white, 0.1)
|
||||
color: RGB($primary)
|
||||
border: 0 solid transparent
|
357
TFR/server/static/sass/style.sass
Normal file
|
@ -0,0 +1,357 @@
|
|||
$black: var(--black)
|
||||
$white: var(--white)
|
||||
$primary: var(--primary)
|
||||
$secondary: var(--secondary)
|
||||
$gold: var(--gold)
|
||||
$silver: var(--silver)
|
||||
$bronze: var(--bronze)
|
||||
|
||||
\:root
|
||||
--black: 31, 27, 21
|
||||
--white: 232, 227, 227
|
||||
--primary: 210, 206, 97
|
||||
--secondary: 185, 77, 77
|
||||
--gold: 255, 222, 70
|
||||
--silver: 229, 220, 206
|
||||
--bronze: 193, 145, 69
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Merriweather:ital,wght@0,300;0,400;0,700;0,900;1,300;1,400;1,700&display=swap')
|
||||
@import "button"
|
||||
@import "hint"
|
||||
|
||||
*
|
||||
box-sizing: border-box
|
||||
font-family: 'Merriweather', serif
|
||||
|
||||
html
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
body
|
||||
margin: 0
|
||||
padding: 0
|
||||
display: flex
|
||||
flex-direction: row
|
||||
background-color: RGB($black)
|
||||
color: RGB($white)
|
||||
|
||||
.background
|
||||
position: fixed
|
||||
inset: 0
|
||||
overflow: hidden
|
||||
z-index: 1
|
||||
|
||||
> img
|
||||
width: 100%
|
||||
height: 100%
|
||||
object-fit: cover
|
||||
|
||||
.app
|
||||
margin: 0 auto
|
||||
padding: 0
|
||||
|
||||
width: 800px
|
||||
min-height: 100vh
|
||||
|
||||
position: relative
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
background-color: rgba($black, 0.4)
|
||||
backdrop-filter: blur(5px)
|
||||
z-index: 2
|
||||
|
||||
.table
|
||||
width: 100%
|
||||
height: auto
|
||||
background-color: rgba($black, 0.7)
|
||||
border-radius: 2px
|
||||
overflow: hidden
|
||||
|
||||
> table
|
||||
width: 100%
|
||||
border-collapse: collapse
|
||||
|
||||
> tbody
|
||||
> tr
|
||||
> th
|
||||
padding: 0.6rem
|
||||
background-image: linear-gradient(to bottom, RGBA($white, 0.05), transparent)
|
||||
text-align: left
|
||||
font-weight: bolder
|
||||
|
||||
> td
|
||||
padding: 0.5rem
|
||||
text-align: left
|
||||
|
||||
&.player
|
||||
color: RGB($secondary)
|
||||
text-shadow: 0 0 5px RGBA($secondary, 0.7)
|
||||
|
||||
i
|
||||
padding: 0.25rem
|
||||
color: RGB($black)
|
||||
border-radius: 2px
|
||||
|
||||
&.first
|
||||
background-color: RGBA($gold, 0.6)
|
||||
|
||||
&.second
|
||||
background-color: RGBA($silver, 0.6)
|
||||
|
||||
&.third
|
||||
background-color: RGBA($bronze, 0.6)
|
||||
|
||||
&:nth-child(odd) > td
|
||||
background-color: RGBA($white, 0.01)
|
||||
|
||||
header
|
||||
padding: 1rem 1rem 0
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 0.4rem
|
||||
background-image: linear-gradient(to bottom, RGB(var(--black)), transparent)
|
||||
|
||||
> hr
|
||||
margin: 0
|
||||
width: 100%
|
||||
border: none
|
||||
border-bottom: 1px solid RGBA($white, 0.1)
|
||||
|
||||
.title
|
||||
margin-bottom: 1rem
|
||||
height: auto
|
||||
> img
|
||||
width: 100%
|
||||
height: auto
|
||||
max-width: 100%
|
||||
|
||||
nav, nav > form
|
||||
padding: 0
|
||||
width: 100%
|
||||
display: flex
|
||||
flex-direction: row
|
||||
justify-content: center
|
||||
gap: 0.4rem
|
||||
|
||||
.spacer
|
||||
width: 100%
|
||||
|
||||
.flash
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
> p
|
||||
margin: 0
|
||||
padding: 0.75rem 1rem
|
||||
|
||||
width: 100%
|
||||
position: relative
|
||||
|
||||
background-color: RGB($black)
|
||||
color: RGB($primary)
|
||||
|
||||
transition: background-color 0.2s ease-in-out, padding 0.2s ease-in-out
|
||||
|
||||
> span
|
||||
position: absolute
|
||||
top: 0
|
||||
left: 0
|
||||
bottom: 0
|
||||
width: 0.25rem
|
||||
|
||||
display: flex
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
background-color: RGB($primary)
|
||||
color: transparent
|
||||
|
||||
overflow: hidden
|
||||
transition: width 0.2s ease-in-out, color 0.2s ease-in-out, background-color 0.2s ease-in-out
|
||||
|
||||
> i
|
||||
font-size: 1.25em
|
||||
|
||||
&:hover
|
||||
padding: 0.75rem 1rem 0.75rem 4rem
|
||||
background-color: RGBA($primary, 0.1)
|
||||
cursor: pointer
|
||||
|
||||
> span
|
||||
width: 3rem
|
||||
color: RGB($black)
|
||||
|
||||
&.success
|
||||
color: RGB($primary)
|
||||
|
||||
> span
|
||||
background-color: RGB($primary)
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($primary, 0.1)
|
||||
|
||||
&.error
|
||||
color: RGB($secondary)
|
||||
|
||||
> span
|
||||
background-color: RGB($secondary)
|
||||
|
||||
&:hover
|
||||
background-color: RGBA($secondary, 0.1)
|
||||
|
||||
main
|
||||
padding: 1rem
|
||||
height: 100%
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
> h2
|
||||
margin: 0 0 1rem 0
|
||||
font-size: 1.5em
|
||||
color: RGB($white)
|
||||
|
||||
.center-text
|
||||
height: 100%
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
justify-content: center
|
||||
align-items: center
|
||||
|
||||
> h2
|
||||
margin: 0
|
||||
text-align: center
|
||||
font-size: 2em
|
||||
color: RGB($white)
|
||||
|
||||
> p
|
||||
margin: 0
|
||||
text-align: center
|
||||
font-size: 1em
|
||||
color: RGB($white)
|
||||
|
||||
> img
|
||||
margin: 1rem auto 0
|
||||
max-width: 100%
|
||||
max-height: 15rem
|
||||
border-radius: 2px
|
||||
|
||||
.block
|
||||
margin-bottom: 1rem
|
||||
padding: 1rem
|
||||
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
background-color: rgba($black, 0.7)
|
||||
border-radius: 2px
|
||||
|
||||
> h2
|
||||
margin: 0 0 0.2rem 0
|
||||
font-size: 1.3em
|
||||
color: RGB($white)
|
||||
|
||||
> p
|
||||
margin: 0 0 1rem 0
|
||||
font-size: 1em
|
||||
|
||||
> table
|
||||
width: 100%
|
||||
border-collapse: collapse
|
||||
> tbody > tr > td
|
||||
padding: 0 0.5rem 0.5rem 0
|
||||
|
||||
text-align: left
|
||||
font-size: 0.9em
|
||||
|
||||
color: RGB($white)
|
||||
|
||||
transition: filter 0.2s ease-in-out
|
||||
|
||||
&:last-child
|
||||
width: 100%
|
||||
|
||||
&.hidden
|
||||
filter: blur(5px)
|
||||
|
||||
&.secondary
|
||||
border: 1px solid RGB($secondary)
|
||||
|
||||
> h2
|
||||
color: RGB($secondary)
|
||||
|
||||
form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
|
||||
> input
|
||||
margin: 0 0 1rem 0
|
||||
padding: 0.7rem 1rem
|
||||
|
||||
border: 1px solid RGB($white)
|
||||
border-radius: 2px
|
||||
|
||||
background-color: RGB($black)
|
||||
color: RGB($white)
|
||||
|
||||
&:focus
|
||||
outline: none
|
||||
border-color: RGB($primary)
|
||||
|
||||
&.error
|
||||
border-color: RGB($secondary)
|
||||
|
||||
> button
|
||||
margin: 0
|
||||
padding: 0.75rem 1rem
|
||||
|
||||
font-weight: bolder
|
||||
|
||||
border: transparent
|
||||
border-radius: 2px
|
||||
|
||||
background-color: RGB($primary)
|
||||
color: RGB($black)
|
||||
|
||||
&:focus-visible, &:hover
|
||||
outline: none
|
||||
background-color: RGBA($primary, 0.3)
|
||||
color: RGB($primary)
|
||||
|
||||
&.disabled
|
||||
pointer-events: none
|
||||
opacity: 0.5
|
||||
|
||||
&.secondary
|
||||
background-color: RGB($secondary)
|
||||
color: RGB($black)
|
||||
|
||||
&:focus-visible, &:hover
|
||||
background-color: RGBA($secondary, 0.3)
|
||||
color: RGB($secondary)
|
||||
|
||||
footer
|
||||
padding: 0.5rem 1rem
|
||||
width: 100%
|
||||
display: flex
|
||||
flex-direction: row
|
||||
background-image: linear-gradient(to top, RGB(var(--black)), transparent)
|
||||
|
||||
> p
|
||||
margin: 0
|
||||
width: 100%
|
||||
text-align: center
|
||||
font-size: 0.8em
|
||||
white-space: nowrap
|
||||
color: RGB($white)
|
||||
|
||||
> a
|
||||
color: RGB($secondary)
|
||||
text-decoration: none
|
||||
|
||||
&:hover
|
||||
text-decoration: underline
|
BIN
TFR/server/static/title.png
Normal file
After Width: | Height: | Size: 152 KiB |
BIN
TFR/server/static/title.webp
Normal file
After Width: | Height: | Size: 44 KiB |
5
TFR/server/templates/about.html
Normal file
|
@ -0,0 +1,5 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<h2>What is The Front Rooms?</h2>
|
||||
<h2>Project Redacted</h2>
|
||||
{% endblock %}
|
32
TFR/server/templates/account.html
Normal file
|
@ -0,0 +1,32 @@
|
|||
{% extends "base.html" %}
|
||||
{% block nav %}
|
||||
<hr>
|
||||
<nav>
|
||||
<a href="{{ url_for('auth.account', action='logout') }}" class="button">Your Profile</a>
|
||||
<span class="spacer"></span>
|
||||
<a href="{{ url_for('auth.account', action='logout') }}" class="button secondary">Logout</a>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h2>Tokens</h2>
|
||||
<p>These are your API tokens. Used to link the uploaded scores with your account.</p>
|
||||
<table>
|
||||
{% for token in token_list %}
|
||||
<tr id="token-{{ token.id }}">
|
||||
<td><button onclick="deleteToken({{ token.id }})" class="button secondary"><i class="ph ph-trash"></i></button></td>
|
||||
<td><button onclick="viewToken({{ token.id }})" class="button primary"><i class="ph ph-eye"></i></button></td>
|
||||
<td class="hidden">{{ token.token }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<button onclick="addToken()" class="button primary">Create New Token</button>
|
||||
</div>
|
||||
|
||||
<div class="block secondary">
|
||||
<h2>Danger Zone</h2>
|
||||
<p>These actions are irreversible. Be careful!</p>
|
||||
<a href="{{ url_for('auth.account', action='delete') }}" class="button secondary">Delete Account</a>
|
||||
<a href="{{ url_for('auth.account', action='password') }}" class="button secondary">Reset Password</a>
|
||||
</div>
|
||||
{% endblock %}
|
22
TFR/server/templates/auth.html
Normal file
|
@ -0,0 +1,22 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="block">
|
||||
<h2>Login</h2>
|
||||
<p>Welcome back!</p>
|
||||
<form action="{{ url_for('auth.login') }}" method="POST">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Login</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="block">
|
||||
<h2>Register</h2>
|
||||
<p>Don't have an account? Register here!</p>
|
||||
<form action="{{ url_for('auth.register') }}" method="POST">
|
||||
<input type="text" name="username" placeholder="Username" required>
|
||||
<input type="password" name="password" placeholder="Password" required>
|
||||
<button type="submit">Register</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
64
TFR/server/templates/base.html
Normal file
|
@ -0,0 +1,64 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Front Rooms Highscores</title>
|
||||
|
||||
<script src="https://unpkg.com/@phosphor-icons/web"></script>
|
||||
{% assets "scripts" %}<script src="{{ ASSET_URL }}"></script>{% endassets %}
|
||||
{% assets "styles" %}<link rel="stylesheet" href="{{ ASSET_URL }}" type="text/css">{% endassets %}
|
||||
</head>
|
||||
<body>
|
||||
<div class="search-hint">
|
||||
<p>Start typing to see results...</p>
|
||||
</div>
|
||||
|
||||
<picture class="background">
|
||||
<source srcset="{{ url_for('static', filename='background.webp') }}">
|
||||
<img src="{{ url_for('static', filename='background.png') }}" alt="The Front Rooms Level select render">
|
||||
</picture>
|
||||
|
||||
<div class="app">
|
||||
<!-- Get flashed lol -->
|
||||
<div class="flash">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<p class="{{ category }}" onclick="this.remove()"><span><i class="ph-bold ph-x"></i></span>{{ message }}</p>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<header>
|
||||
<picture class="title">
|
||||
<source srcset="{{ url_for('static', filename='title.webp') }}">
|
||||
<img src="{{ url_for('static', filename='title.png') }}" alt="The Front Rooms logo">
|
||||
</picture>
|
||||
|
||||
<nav>
|
||||
<a href="{{ url_for('views.index') }}" class="button">Scores</a>
|
||||
<a href="{{ url_for('views.about') }}" class="button">About</a>
|
||||
<a href="{{ url_for('views.about') }}" class="button">Updates</a>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('auth.account') }}" class="button primary">{{ current_user.username }}</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.auth') }}" class="button primary"><i class="ph ph-user-circle"></i></a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
|
||||
<!-- Secondary nav bar for page specific content -->
|
||||
{% block nav %}{% endblock %}
|
||||
</header>
|
||||
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
|
||||
<footer><p>By Project Redacted | <a href="https://github.com/Fluffy-Bean/GameExpo23">Server Source</a></p></footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
8
TFR/server/templates/error.html
Normal file
|
@ -0,0 +1,8 @@
|
|||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
<div class="center-text">
|
||||
<h2>{{ error }}</h2>
|
||||
<p>{{ msg }}</p>
|
||||
<image src="{{ url_for('static', filename='error-images/' + image + '.jpg') }}" alt="Error">
|
||||
</div>
|
||||
{% endblock %}
|
72
TFR/server/templates/scores.html
Normal file
|
@ -0,0 +1,72 @@
|
|||
{% extends "base.html" %}
|
||||
{% block nav %}
|
||||
<hr>
|
||||
<nav>
|
||||
<form method="GET" action="{{ url_for('views.index') }}">
|
||||
<select name="diff" class="button">
|
||||
<option value="0" {% if diff==0 %}selected{% endif %}>Level 1</option>
|
||||
<option value="1" {% if diff==1 %}selected{% endif %}>Level 2</option>
|
||||
<option value="2" {% if diff==2 %}selected{% endif %}>Level 3</option>
|
||||
<option value="3" {% if diff==3 %}selected{% endif %}>Normal</option>
|
||||
<option value="4" {% if diff==4 %}selected{% endif %}>Hard</option>
|
||||
</select>
|
||||
|
||||
<select name="ver" class="button">
|
||||
<option value="alpha" {% if ver=="alpha" %}selected{% endif %}>Alpha</option>
|
||||
<option value="beta" {% if ver=="beta" %}selected{% endif %}>Beta</option>
|
||||
<option value="1.0" {% if ver=="1.0" %}selected{% endif %}>1.0</option>
|
||||
<option value="1.1" {% if ver=="1.1" %}selected{% endif %}>1.1</option>
|
||||
</select>
|
||||
|
||||
<span class="search">
|
||||
<label for="user">Username</label>
|
||||
<input type="text" name="user" {% if user %}value="{{ user }}"{% endif %} autocomplete="off"/>
|
||||
</span>
|
||||
|
||||
<button class="button"><i class="ph ph-magnifying-glass"></i></button>
|
||||
</form>
|
||||
</nav>
|
||||
{% endblock %}
|
||||
{% block content %}
|
||||
{% if scores %}
|
||||
<div class="table">
|
||||
<table>
|
||||
<tr>
|
||||
<th>Position</th>
|
||||
<th>Player</th>
|
||||
<th>Time</th>
|
||||
<th>Scored at</th>
|
||||
<th>Version</th>
|
||||
</tr>
|
||||
{% for score in scores %}
|
||||
<tr>
|
||||
{% if loop.index == 1 %}
|
||||
<td><i class="first ph-fill ph-crown"></i></td>
|
||||
{% elif loop.index == 2 %}
|
||||
<td><i class="second ph-duotone ph-crown"></i></td>
|
||||
{% elif loop.index == 3 %}
|
||||
<td><i class="third ph ph-crown"></i></td>
|
||||
{% else %}
|
||||
<td>{{ loop.index }}</td>
|
||||
{% endif %}
|
||||
|
||||
{% if score.users.id == current_user.id %}
|
||||
<td class="player">{{ score.users.username }}</td>
|
||||
{% else %}
|
||||
<td>{{ score.users.username }}</td>
|
||||
{% endif %}
|
||||
|
||||
<td>{{ score.score }}</td>
|
||||
<td>{{ score.scored_at }}</td>
|
||||
<td>{{ score.version }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="center-text">
|
||||
<h2>No scores</h2>
|
||||
<p>Go set some!</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
33
TFR/server/views.py
Normal file
|
@ -0,0 +1,33 @@
|
|||
from flask import Blueprint, request, render_template, abort
|
||||
from server.models import Scores, Users
|
||||
from server.config import GAME_VERSION, MAX_TOP_SCORES
|
||||
|
||||
|
||||
blueprint = Blueprint("views", __name__)
|
||||
|
||||
|
||||
@blueprint.route("/")
|
||||
def index():
|
||||
diff_arg = request.args.get("diff", 0)
|
||||
ver_arg = request.args.get("ver", GAME_VERSION)
|
||||
user_arg = request.args.get("user", None)
|
||||
|
||||
scores = Scores.query.filter_by(difficulty=diff_arg)
|
||||
|
||||
if ver_arg:
|
||||
scores = scores.filter_by(version=ver_arg)
|
||||
if user_arg:
|
||||
if user := Users.query.filter_by(username=user_arg).first():
|
||||
scores = scores.filter_by(user_id=user.id)
|
||||
print(user.id)
|
||||
else:
|
||||
abort(404, "User not found")
|
||||
|
||||
scores = scores.order_by(Scores.score.asc()).limit(MAX_TOP_SCORES).all()
|
||||
|
||||
return render_template("scores.html", scores=scores, diff=int(diff_arg), ver=ver_arg, user=user_arg)
|
||||
|
||||
|
||||
@blueprint.route("/about")
|
||||
def about():
|
||||
return render_template("about.html")
|