Jiří Kalvoda
2 years ago
commit
5f3da84f71
9 changed files with 858 additions and 0 deletions
@ -0,0 +1,16 @@ |
|||
# Patří do hra/config.py |
|||
|
|||
SQLALCHEMY_DATABASE_URI = "postgresql:///ksp-strathra" |
|||
SQLALCHEMY_TRACK_MODIFICATIONS = False |
|||
SQLALCHEMY_ECHO = False |
|||
|
|||
# Vytvořte pomocí python3 -c 'import secrets; print(secrets.token_hex(32))' |
|||
SECRET_KEY = "8aeffbdf14d441f40359708cbbae9b47926db8b08e4ea9e0cdf78071bee7b788" |
|||
|
|||
SESSION_COOKIE_NAME = 'ksp_strathra__session' |
|||
|
|||
# Druh webu (devel/test/pub), z toho CSS třída elementu <header> |
|||
WEB_FLAVOR = 'devel' |
|||
|
|||
# Nutné pro registraci |
|||
CAPTCHA = 'hroch' |
@ -0,0 +1,2 @@ |
|||
#!/bin/sh |
|||
exec flask --app hra.web --debug "$@" |
@ -0,0 +1,347 @@ |
|||
from flask import Flask, redirect, flash, render_template, session, g, request |
|||
from wtforms import Form, BooleanField, StringField, PasswordField, validators, SubmitField, IntegerField, DateTimeField |
|||
from wtforms.validators import ValidationError |
|||
from flask_wtf import FlaskForm |
|||
from flask_bootstrap import Bootstrap |
|||
import time |
|||
from datetime import datetime |
|||
from flask_sqlalchemy import SQLAlchemy |
|||
from sqlalchemy import exc, update |
|||
import hashlib |
|||
import bcrypt |
|||
import os |
|||
import werkzeug.exceptions |
|||
import wtforms |
|||
from wtforms.fields import EmailField |
|||
from wtforms.widgets import NumberInput |
|||
|
|||
import hra.config as config |
|||
|
|||
|
|||
class OptionalIntField(wtforms.IntegerField): |
|||
widget = NumberInput() |
|||
|
|||
def process_formdata(self, valuelist): |
|||
self.data = None |
|||
if valuelist: |
|||
if valuelist[0]: |
|||
try: |
|||
self.data = int(valuelist[0]) |
|||
except ValueError: |
|||
raise wtforms.ValidationError('Nejedná se o číslo.') |
|||
|
|||
|
|||
import logging |
|||
logging.basicConfig() |
|||
logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) |
|||
|
|||
static_dir = os.path.abspath('static') |
|||
app = Flask(__name__, static_folder=static_dir) |
|||
|
|||
app.config.from_object(config) |
|||
Bootstrap(app) |
|||
db = SQLAlchemy(app) |
|||
|
|||
|
|||
|
|||
class NeedLoginError(werkzeug.exceptions.Forbidden): |
|||
description = 'Need to log in' |
|||
|
|||
class MenuItem: |
|||
url: str |
|||
name: str |
|||
|
|||
def __init__(self, url: str, name: str): |
|||
self.url = url |
|||
self.name = name |
|||
|
|||
|
|||
def init_request(): |
|||
path = request.path |
|||
# XXX: Když celá aplikace běží v adresáři, request.path je relativní ke kořeni aplikace, ne celého webu |
|||
if path.startswith('/static/') or path.startswith('/assets/'): |
|||
# Pro statické soubory v development nasazení nepotřebujeme nastavovat |
|||
# nic dalšího (v ostrém nasazení je servíruje uwsgi) |
|||
return |
|||
|
|||
if 'uid' in session: |
|||
user = db.session.query(Users).filter_by(id=session['uid']).first() |
|||
else: |
|||
user = None |
|||
path = request.path |
|||
if path.startswith('/org/'): |
|||
if not user: |
|||
raise werkzeug.exceptions.Forbidden() |
|||
if not user.org: |
|||
raise werkzeug.exceptions.Forbidden() |
|||
g.user = user |
|||
|
|||
g.menu = [ |
|||
MenuItem('/', "Domů"), |
|||
MenuItem('/bonuses', "Bonusy"), |
|||
] |
|||
if g.user and g.user.org: |
|||
g.menu += [ |
|||
MenuItem('/org/ranking', "Výsledky"), |
|||
MenuItem('/org/act', "Aktuální kolo"), |
|||
MenuItem('/org/admin', "Admin"), |
|||
MenuItem('/org/su', "Vtělování se"), |
|||
MenuItem('/org/users', "Uživatelé"), |
|||
] |
|||
else: |
|||
g.menu += [ |
|||
MenuItem('/submitted', "Odevzdané kódy"), |
|||
] |
|||
|
|||
|
|||
|
|||
app.before_request(init_request) |
|||
|
|||
|
|||
class Users(db.Model): |
|||
id = db.Column(db.Integer, primary_key=True) |
|||
org = db.Column(db.Boolean) |
|||
username = db.Column(db.String(80), unique=True, nullable=False) |
|||
passwd = db.Column(db.String(80), nullable=False) |
|||
|
|||
def __repr__(self): |
|||
return '<User %r>' % self.username |
|||
|
|||
|
|||
def code_points(code, time): |
|||
r = 1 |
|||
if code in bonus: |
|||
for b in bonus[code]: |
|||
if not b.is_out(time): |
|||
r = max(r, b.eval(time)) |
|||
return r |
|||
|
|||
|
|||
class Findings(db.Model): |
|||
id = db.Column(db.Integer, primary_key=True) |
|||
user = db.Column(db.Integer, db.ForeignKey('users.id')) |
|||
round = db.Column(db.Integer) |
|||
code = db.Column(db.String(10)) |
|||
time = db.Column(db.Integer) |
|||
delete = db.Column(db.Boolean, nullable=False, default=False) |
|||
def points(self): |
|||
return code_points(self.code, self.time) |
|||
|
|||
class ActualRound(db.Model): |
|||
id = db.Column(db.Integer, primary_key=True) |
|||
|
|||
class Round(db.Model): |
|||
id = db.Column(db.Integer, primary_key=True) |
|||
start_time = db.Column(db.DateTime) |
|||
|
|||
# db.create_all() |
|||
|
|||
# if db.session.query(ActualRound).one_or_none() is None: |
|||
# db.session.add(ActualRound(id=0)) |
|||
# db.session.commit() |
|||
|
|||
|
|||
|
|||
|
|||
class RegistrationForm(FlaskForm): |
|||
username = StringField('Jméno týmu', [validators.Length(min=2, max=25), validators.DataRequired()]) |
|||
captcha = StringField('Captcha', validators=[validators.DataRequired()]) |
|||
passwd = PasswordField('Heslo', [ |
|||
validators.DataRequired(), |
|||
validators.EqualTo('confirm', message='Passwords must match') |
|||
]) |
|||
confirm = PasswordField('Heslo znovu', validators=[validators.DataRequired()]) |
|||
submit = SubmitField("Založit") |
|||
|
|||
def validate_captcha(form, field): |
|||
if field.data != config.CAPTCHA: |
|||
raise ValidationError("Chyba!") |
|||
|
|||
|
|||
class LoginForm(FlaskForm): |
|||
username = StringField('Jméno týmu', [validators.DataRequired()]) |
|||
passwd = PasswordField('Heslo', [validators.DataRequired()]) |
|||
submit = SubmitField("Přihlásit") |
|||
|
|||
|
|||
def hash_passwd(a): |
|||
salt = b'$2b$12$V2aIKSJC/uozaodwYnQX3e' |
|||
hashed = bcrypt.hashpw(a.encode('utf-8'), salt) |
|||
return hashed.decode('us-ascii') |
|||
|
|||
|
|||
@app.route("/registration", methods=['GET', 'POST']) |
|||
def registration(): |
|||
f = RegistrationForm() |
|||
if f.validate_on_submit(): |
|||
u = Users(org=False, username=f.username.data, passwd=hash_passwd(f.passwd.data)) |
|||
try: |
|||
db.session.add(u) |
|||
db.session.commit() |
|||
except exc.IntegrityError: |
|||
flash("Uživatelské jméno již existuje") |
|||
return render_template('registration.html', form=f) |
|||
flash("Přidán nový uživatel.", 'success') |
|||
return redirect("login") |
|||
return render_template('registration.html', form=f) |
|||
|
|||
@app.route("/login", methods=['GET', 'POST']) |
|||
def login(): |
|||
f = LoginForm() |
|||
if f.validate_on_submit(): |
|||
p_hash=hash_passwd(f.passwd.data) |
|||
user = db.session.query(Users).filter_by(username=f.username.data).one_or_none() |
|||
print(user, p_hash) |
|||
if user and user.passwd == p_hash: |
|||
session.clear() |
|||
session['uid'] = user.id |
|||
flash("Přihlášení hotovo.", 'success') |
|||
return redirect("/") |
|||
flash("Chybné jméno nebo heslo.", 'danger') |
|||
return render_template('login.html', form=f) |
|||
|
|||
|
|||
@app.route("/logout") |
|||
def logout(): |
|||
session.clear() |
|||
return redirect('/') |
|||
|
|||
|
|||
@app.template_filter() |
|||
def none_as_minus(x): |
|||
return x if x is not None else '-' |
|||
|
|||
|
|||
@app.template_filter() |
|||
def round_points(x): |
|||
if x is None: |
|||
return None |
|||
return round(x,3) |
|||
|
|||
|
|||
@app.template_filter() |
|||
def print_time(t): |
|||
if t == None: |
|||
return "-" |
|||
return ("-" if t<0 else "") + str(abs(t)//1000//60) + ":" + ("00{0:d}".format(abs(t)//1000%60))[-2:] |
|||
|
|||
|
|||
|
|||
|
|||
@app.route("/", methods=['GET', 'POST']) |
|||
def web_index(): |
|||
return render_template('index.html') |
|||
|
|||
|
|||
|
|||
@app.route("/org/users") |
|||
def web_users(): |
|||
users = db.session.query(Users).all() |
|||
return render_template("org_users.html", users=users) |
|||
|
|||
@app.route("/org/user/<int:user_id>", methods=['GET', 'POST']) |
|||
def web_org_user(user_id): |
|||
user = db.session.query(Users).filter_by(id=user_id).one_or_none() |
|||
f = FindingForm() |
|||
del f.user |
|||
if f.validate_on_submit(): |
|||
f.fill_empty() |
|||
find = Findings(user=user.id, code=f.f_code, time=f.f_time, round=get_round_id()) |
|||
db.session.add(find) |
|||
db.session.commit() |
|||
flash(f"Kód {find.code}… přijat", 'success') |
|||
f.code.data = "" |
|||
return redirect(f"/org/user/{user_id}") |
|||
|
|||
if not user: |
|||
raise werkzeug.exceptions.NotFound() |
|||
calc_point(user) |
|||
findings = db.session.query(Findings).filter_by(user=user.id, round=get_round_id()).order_by(Findings.time).all() |
|||
return render_template("org_user.html", user=user, findings=findings, form=f) |
|||
|
|||
@app.route("/org/admin", methods=['GET', 'POST']) |
|||
def web_admin(): |
|||
obj_round = get_round() |
|||
f_round = FormRound(obj=obj_round, prefix="r") |
|||
if f_round.validate_on_submit(): |
|||
f_round.populate_obj(obj_round) |
|||
db.session.commit() |
|||
|
|||
return render_template("org_admin.html", f_round=f_round) |
|||
|
|||
@app.route("/org/act", methods=['GET', 'POST']) |
|||
def web_act(): |
|||
obj_act_round = db.session.query(ActualRound).one() |
|||
f_act_round = FormActRound(obj=obj_act_round, prefix="act") |
|||
if f_act_round.validate_on_submit(): |
|||
f_act_round.populate_obj(obj_act_round) |
|||
db.session.commit() |
|||
return render_template("org_act.html", f_act_round=f_act_round) |
|||
|
|||
|
|||
class SuForm(FlaskForm): |
|||
username = StringField('Uživatel') |
|||
time_move = OptionalIntField('Časový posun') |
|||
round = OptionalIntField('Kolo') |
|||
submit = SubmitField("Změnit") |
|||
|
|||
def validate_username(form, field): |
|||
form.f_user = db.session.query(Users).filter_by(username=field.data).one_or_none() |
|||
if form.f_user == None and field.data: |
|||
raise ValidationError("Uživatel neexistuje.") |
|||
|
|||
|
|||
|
|||
|
|||
@app.route("/org/su", methods=['GET', 'POST']) |
|||
def web_su(): |
|||
f = SuForm() |
|||
if not f.is_submitted(): |
|||
f.username.data = g.user.username |
|||
|
|||
if f.validate_on_submit(): |
|||
if not f.f_user or session['uid'] != f.f_user.id: |
|||
session['uid'] = f.f_user.id if f.f_user else None |
|||
flash("Uživatel vtělen!") |
|||
if f.time_move.data is not None: |
|||
session['time_move'] = f.time_move.data * 1000 |
|||
flash("Čas vtělen!") |
|||
if f.round.data is not None: |
|||
session['round'] = f.round.data |
|||
flash("Kolo vtěleno!") |
|||
return redirect('/') |
|||
|
|||
return render_template("org_su.html", f=f) |
|||
|
|||
from hra.web.html import * |
|||
@app.route("/test", methods=['GET', 'POST']) |
|||
def test(): |
|||
b = HtmlBuilder() |
|||
with b.head(): |
|||
b.title()("KSP hra") |
|||
b.meta(name="viewport", content="width=device-width, initial-scale=1.0") |
|||
b.link(rel="stylesheet", href=app.url_for('static', filename='bootstrap.min.css'), type='text/css', media="all") |
|||
b.link(rel="stylesheet", href=app.url_for('static', filename='ksp-mhd.css'), type='text/css', media="all") |
|||
# b.link(rel="stylesheet", href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css", integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm", crossorigin="anonymous") |
|||
b.link(rel="icon", type="image/png", sizes="16x16", href="static/favicon.ico") |
|||
b.link(rel="shortcut icon", href=app.url_for('static', filename='img/favicon.ico')) |
|||
# b.link(rel="stylesheet", href="https://mo.mff.cuni.cz/osmo/assets/a373a8f2/mo.css", type='text/css', media="all") |
|||
with b.body(): |
|||
with b.header(_class=f"flavor-{config.WEB_FLAVOR}"): |
|||
with b.div(_class="content"): |
|||
with b.a(href="/", title="Na hlavní stránku"): |
|||
b.img(src=app.url_for('static', filename='hippo.png'), style="width: 60px;height: auto;", alt="KSP") |
|||
b.h1()("Hra na soustředění KSP") |
|||
with b.div(id="nav-wrapper"): |
|||
with b.nav(id="main-menu", _class="content"): |
|||
for item in g.menu: |
|||
b.a()(item.name) |
|||
if g.user: |
|||
b.a(_class="right", href="/")(f"Přihlášen: {{ g.user.username }}") |
|||
b.a(_class="right", href="/logout")(f"Odhlásit") |
|||
else: |
|||
b.a(_class="right", href="/login")(f"Přihlásit") |
|||
b.a(_class="right", href="/registration")(f"Registrovat") |
|||
|
|||
return b._print_file() |
|||
|
@ -0,0 +1,146 @@ |
|||
from flask import Markup, escape |
|||
from typing import List, Optional, Union, Tuple |
|||
import threading |
|||
|
|||
|
|||
class EscapeError(RuntimeError): |
|||
pass |
|||
|
|||
|
|||
def escape_attribute(x:str) -> str: |
|||
return x.replace("&", "&ersand;").replace('"', """) |
|||
|
|||
|
|||
def escape_attribute_name(x:str) -> str: |
|||
for c in "<>='\" ": |
|||
if c in x: |
|||
raise EscapeError |
|||
return x |
|||
|
|||
|
|||
def escape_tag_name(x:str) -> str: |
|||
return escape_attribute_name(x) |
|||
|
|||
Element = Union[str, Markup, 'Tag'] |
|||
|
|||
|
|||
class Tag: |
|||
name: str |
|||
attributes: List[Tuple[str, str]] |
|||
content: List[Element] |
|||
is_paired: bool |
|||
builder: 'Builder' |
|||
|
|||
def __init__(self, builder, name:str, attributes: List[Tuple[str, str]]): |
|||
self.builder = builder |
|||
self.name = name |
|||
self.attributes = attributes |
|||
self.is_paired = False |
|||
self.content = [] |
|||
self.before_tag = None |
|||
|
|||
def add(self, x: Element): |
|||
self.is_paired = True |
|||
self.content.append(x) |
|||
|
|||
def add_attribute(k, v): |
|||
self.attributes.append((k,v)) |
|||
|
|||
def __call__(self, *arg, **kvarg): |
|||
for i in arg: |
|||
self.add(i) |
|||
for k, v in kvarg.items(): |
|||
self.add_attribute(remove_leading_underscore(k), v) |
|||
return self |
|||
|
|||
def add_tag(self, name:str, attributes: List[Tuple[str, str]]): |
|||
t = Tag(self.builder, name, attributes) |
|||
self.add(t) |
|||
return t |
|||
|
|||
def format_attributes(self): |
|||
return " ".join(f'{escape_attribute_name(i[0])}="{escape_attribute(i[1])}"' for i in self.attributes) |
|||
|
|||
def serialize_append_to_list(self, out, indent): |
|||
indent_str = " " |
|||
if self.is_paired: |
|||
out.append(indent_str*indent + f"<{escape_tag_name(self.name)} {self.format_attributes()}>\n") |
|||
indent += 1 |
|||
for i in self.content: |
|||
if isinstance(i, Tag): |
|||
i.serialize_append_to_list(out, indent) |
|||
elif isinstance(i, Markup): |
|||
for j in i.__html__.split("\n"): |
|||
out.append(indent_str*indent + j + "\n") |
|||
else: |
|||
for j in str(i).split("\n"): |
|||
out.append(indent_str*indent + j + "\n") |
|||
indent -= 1 |
|||
out.append(indent_str*indent + f"</{escape_tag_name(self.name)}>\n") |
|||
else: |
|||
out.append(indent_str*indent + f"<{escape_tag_name(self.name)} {self.format_attributes()} \>\n") |
|||
|
|||
def print(self): |
|||
out = [] |
|||
self.serialize_append_to_list(out, 0) |
|||
return Markup("".join(out)) |
|||
|
|||
def print_file(self): |
|||
out = ["<!DOCTYPE html>\n"] |
|||
self.serialize_append_to_list(out, 0) |
|||
return Markup("".join(out)) |
|||
|
|||
def __enter__(self): |
|||
if self.before_tag is not None: |
|||
raise RuntimeError("Duplicit __enter__") |
|||
self.before_tag = self.builder._current_tag |
|||
self.builder._current_tag = self |
|||
|
|||
def __exit__(self, exc_type, exc_value, exc_traceback): |
|||
if self.before_tag is None: |
|||
raise RuntimeError("__exit__ before __enter__") |
|||
self.builder._current_tag = self.before_tag |
|||
self.before_tag = None |
|||
|
|||
class Builder: |
|||
_current_tag: Tag |
|||
_root_tag: Tag |
|||
def __init__(self, tag: Tag): |
|||
self._root_tag = tag |
|||
self._current_tag = tag |
|||
|
|||
def _tag(self, name: str, attributes: List[Tuple[str,str]] = []): |
|||
return self._current_tag.add_tag(name, attributes) |
|||
|
|||
def __call__(self, x: Element): |
|||
self._current_tag.add(x) |
|||
return x |
|||
|
|||
def _print(self): |
|||
return self._root_tag.print() |
|||
|
|||
def _print_file(self): |
|||
return self._root_tag.print_file() |
|||
|
|||
class HtmlBuilder(Builder): |
|||
def __init__(self): |
|||
super().__init__(Tag(self, "html", [])) |
|||
|
|||
|
|||
tag_names = ["a", "abbr", "acronym", "address", "applet", "area", "article", "aside", "audio", "b", "base", "basefont", "bdi", "bdo", "big", "blockquote", "body", "br", "button", "canvas", "caption", "center", "cite", "code", "col", "colgroup", "data", "datalist", "dd", "del", "details", "dfn", "dialog", "dir", "div", "dl", "dt", "em", "embed", "fieldset", "figcaption", "figure", "font", "footer", "form", "frame", "frameset", "head", "header", "hgroup", "h1", "h2", "h3", "h4", "h5", "h6", "hr", "html", "i", "iframe", "img", "input", "ins", "kbd", "keygen", "label", "legend", "li", "link", "main", "map", "mark", "menu", "menuitem", "meta", "meter", "nav", "noframes", "noscript", "object", "ol", "optgroup", "option", "output", "p", "param", "picture", "pre", "progress", "q", "rp", "rt", "ruby", "s", "samp", "script", "section", "select", "small", "source", "span", "strike", "strong", "style", "sub", "summary", "sup", "svg", "table", "tbody", "td", "template", "textarea", "tfoot", "th", "thead", "time", "title", "tr", "track", "tt", "u", "ul", "var", "video", "wbr"] |
|||
|
|||
|
|||
def remove_leading_underscore(s): |
|||
if s == "": |
|||
return s |
|||
if s[0] == "_": |
|||
return s[1:] |
|||
return s |
|||
|
|||
|
|||
for name in tag_names: |
|||
def run(name): |
|||
def l(self, **kvarg): |
|||
return self._tag(name, [(remove_leading_underscore(k), v) for k, v in kvarg.items()]) |
|||
setattr(HtmlBuilder, name, l) |
|||
run(name) |
@ -0,0 +1,43 @@ |
|||
#!/usr/bin/env python3 |
|||
|
|||
import setuptools |
|||
|
|||
setuptools.setup( |
|||
name='hra', |
|||
version='1.0', |
|||
description='Hra na soustředění KSP', |
|||
packages=['hra', 'hra/web'], |
|||
scripts=[ |
|||
], |
|||
include_package_data=True, |
|||
zip_safe=False, |
|||
install_requires=[ |
|||
# Udržujte prosím seřazené |
|||
'Flask', |
|||
'Flask-WTF', |
|||
'WTForms', |
|||
'bcrypt', |
|||
'bleach', |
|||
'blinker', |
|||
'click', |
|||
'dateutils', |
|||
'flask_bootstrap', |
|||
'flask_sqlalchemy', |
|||
'pikepdf', |
|||
'pillow', |
|||
'psycopg2', |
|||
'pyzbar', |
|||
'sqlalchemy[mypy]', |
|||
'uwsgidecorators', |
|||
# Používáme pro vývoj, ale aby je pylsp našel, musí být ve stejném virtualenvu |
|||
# jako ostatní knihovny. |
|||
'sqlalchemy-stubs', |
|||
'types-Markdown', |
|||
'types-bleach', |
|||
'types-flask_sqlalchemy', |
|||
'types-pillow', |
|||
'types-python-dateutil', |
|||
'types-requests', |
|||
'types-setuptools', |
|||
], |
|||
) |
File diff suppressed because one or more lines are too long
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 139 KiB |
@ -0,0 +1,297 @@ |
|||
body { |
|||
display: flex; |
|||
flex-direction: column; |
|||
background-color: white; |
|||
color: black; |
|||
margin: 0; |
|||
min-height: 100vh; |
|||
} |
|||
|
|||
.content, main { |
|||
display: block; |
|||
margin: 0 auto; |
|||
padding: 0 1em; |
|||
max-width: 1000px; |
|||
width: 100%; |
|||
} |
|||
|
|||
header { |
|||
padding: 10px 0px; |
|||
} |
|||
|
|||
header.flavor-test { |
|||
background-color: #33cc33; |
|||
} |
|||
|
|||
header.flavor-devel { |
|||
background-color: #cc77cc; |
|||
} |
|||
|
|||
main { |
|||
padding: 1em; |
|||
} |
|||
|
|||
header .content { |
|||
display: flex; |
|||
} |
|||
|
|||
header img { height: 60px; } |
|||
|
|||
header h1 { margin: auto 20px 0px; color: #222; } |
|||
|
|||
#nav-wrapper { |
|||
position: sticky; |
|||
top: 0; |
|||
background-color: #222; |
|||
border: 1px #222 solid; |
|||
z-index: 10; |
|||
} |
|||
|
|||
footer { |
|||
margin-top: auto; |
|||
} |
|||
|
|||
.error { |
|||
color: red; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.okay { |
|||
color: green; |
|||
font-weight: bold; |
|||
} |
|||
|
|||
.hint { |
|||
color: #737373; |
|||
} |
|||
|
|||
span.unknown { |
|||
font-weight: bold; |
|||
color: red; |
|||
} |
|||
|
|||
table.data { |
|||
border-collapse: collapse; |
|||
margin-top: 2ex; |
|||
margin-bottom: 2ex; |
|||
} |
|||
|
|||
table.data.full { |
|||
width: 100%; |
|||
} |
|||
|
|||
table.data tbody tr:hover { |
|||
background-color: #ddd; |
|||
} |
|||
|
|||
table.data tr td, table.data tr th { |
|||
border: 1px solid #222; |
|||
padding: 0.1ex 0.5ex; |
|||
} |
|||
|
|||
table.data.center th, table.data.center td { |
|||
text-align: center; |
|||
} |
|||
|
|||
table.data thead, table.data tfoot { |
|||
background-color: #aaa; |
|||
} |
|||
table.data thead a, table.data tfoot a { |
|||
color: rgb(0, 22, 121); |
|||
} |
|||
|
|||
table.data thead th { |
|||
padding: 0.4ex 0.5ex; |
|||
} |
|||
|
|||
table.data td.sol { |
|||
background-color: lightgreen; |
|||
} |
|||
table.data td.sol a { |
|||
color: black; |
|||
} |
|||
table.data td.sol-warn { |
|||
background-color: #ffaaaa; |
|||
} |
|||
|
|||
table tr.bonus--1{ |
|||
color: red; |
|||
} |
|||
table tr.bonus-0{ |
|||
font-weight: bold; |
|||
} |
|||
table tr.bonus-1{ |
|||
color: blue; |
|||
} |
|||
nav#main-menu { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
nav#main-menu a, nav#main-menu input[type=submit] { |
|||
background: none; |
|||
border: none; |
|||
margin: 0px; |
|||
color: white; |
|||
font-size: 1em; |
|||
text-align: center; |
|||
padding: 14px 16px; |
|||
text-decoration: none; |
|||
} |
|||
|
|||
/* Only the first item with .right class does the margin trick */ |
|||
nav#main-menu > .right { margin-left: auto; } |
|||
nav#main-menu > .right ~ .right { margin-left: 0px; } |
|||
|
|||
nav#main-menu a:hover:not(.active), nav#main-menu input[type=submit]:hover { |
|||
cursor: pointer; |
|||
background-color: #555; |
|||
color: white; |
|||
} |
|||
|
|||
nav#main-menu a.active { |
|||
background-color: #ddd; |
|||
color: black; |
|||
} |
|||
|
|||
.form-group.required .control-label:after { |
|||
content:"*"; |
|||
color:red; |
|||
} |
|||
|
|||
.form-frame { |
|||
padding: 10px; |
|||
border: 1px #ddd solid; |
|||
border-radius: 4px 4px; |
|||
} |
|||
|
|||
.checked_toggle input.toggle:checked ~ .checked_hide { |
|||
display: none; |
|||
} |
|||
.checked_toggle input.toggle:not(:checked) ~ .checked_show { |
|||
display: none; |
|||
} |
|||
|
|||
/* Tabs - source: https://codepen.io/MPDoctor/pen/mpJdYe */ |
|||
.tabbed { |
|||
margin: 15px 0px; |
|||
} |
|||
|
|||
.tabbed [type="radio"] { |
|||
/* hiding the inputs */ |
|||
display: none; |
|||
} |
|||
|
|||
.tabs { |
|||
display: flex; |
|||
align-items: stretch; |
|||
list-style: none; |
|||
padding: 0; |
|||
margin-bottom: 0px; |
|||
border-bottom: 1px solid #ddd; |
|||
} |
|||
.tab > label { |
|||
display: block; |
|||
margin-bottom: -1px; |
|||
padding: 12px 15px; |
|||
border: 1px solid #ddd; |
|||
background: #eee; |
|||
color: #666; |
|||
font-size: 16px; |
|||
cursor: pointer; |
|||
transition: all 0.3s; |
|||
} |
|||
.tab:hover label { |
|||
border-top-color: #333; |
|||
color: #333; |
|||
} |
|||
|
|||
.tab-content { |
|||
display: none; |
|||
margin: 0px; |
|||
padding: 10px; |
|||
border: 1px solid #ddd; |
|||
border-top: 0px; |
|||
} |
|||
|
|||
/* As we cannot replace the numbers with variables or calls to element properties, the number of this selector parts is our tab count limit */ |
|||
.tabbed [type="radio"]:nth-of-type(1):checked ~ .tabs .tab:nth-of-type(1) label, |
|||
.tabbed [type="radio"]:nth-of-type(2):checked ~ .tabs .tab:nth-of-type(2) label, |
|||
.tabbed [type="radio"]:nth-of-type(3):checked ~ .tabs .tab:nth-of-type(3) label, |
|||
.tabbed [type="radio"]:nth-of-type(4):checked ~ .tabs .tab:nth-of-type(4) label, |
|||
.tabbed [type="radio"]:nth-of-type(5):checked ~ .tabs .tab:nth-of-type(5) label { |
|||
border-bottom-color: #fff; |
|||
border-top-color: #999; |
|||
background: #fff; |
|||
color: #222; |
|||
} |
|||
|
|||
.tabbed [type="radio"]:nth-of-type(1):checked ~ .tab-content:nth-of-type(1), |
|||
.tabbed [type="radio"]:nth-of-type(2):checked ~ .tab-content:nth-of-type(2), |
|||
.tabbed [type="radio"]:nth-of-type(3):checked ~ .tab-content:nth-of-type(3), |
|||
.tabbed [type="radio"]:nth-of-type(4):checked ~ .tab-content:nth-of-type(4) { |
|||
display: block; |
|||
} |
|||
|
|||
/* Icons */ |
|||
|
|||
@font-face { |
|||
font-family: 'icomoon'; |
|||
src: url('fonts/icomoon.ttf?azc5ov') format('truetype'), |
|||
url('fonts/icomoon.woff?azc5ov') format('woff'); |
|||
font-weight: normal; |
|||
font-style: normal; |
|||
font-display: block; |
|||
} |
|||
|
|||
.icon { |
|||
/* Use !important to prevent issues with browser extensions that change fonts */ |
|||
font-family: 'icomoon' !important; |
|||
font-style: normal; |
|||
font-weight: normal; |
|||
font-variant: normal; |
|||
text-transform: none; |
|||
line-height: 1; |
|||
|
|||
/* Better font rendering */ |
|||
-webkit-font-smoothing: antialiased; |
|||
-moz-osx-font-smoothing: grayscale; |
|||
} |
|||
|
|||
/* Collapsible */ |
|||
|
|||
.collapsible { |
|||
position: relative; |
|||
} |
|||
.collapsible input[type="checkbox"].toggle { |
|||
position: absolute; |
|||
top: 0; |
|||
left: 0; |
|||
opacity: 0; |
|||
} |
|||
.collapsible label.toggle { |
|||
cursor: pointer; |
|||
margin-left: 15px; |
|||
} |
|||
.collapsible label.toggle::before { |
|||
position: absolute; |
|||
content: ""; |
|||
width: 0; |
|||
height: 0; |
|||
left: 0px; |
|||
border-left: 8px solid black; |
|||
border-top: 8px solid transparent; |
|||
border-bottom: 8px solid transparent; |
|||
transition: 0.5s ease; |
|||
} |
|||
.collapsible input[type="checkbox"].toggle:checked ~ label.toggle::before { |
|||
transform: rotate(90deg); |
|||
} |
|||
.collapsible .collapsible-inner { |
|||
max-height: 0; |
|||
overflow-y: hidden; |
|||
transition: 0.5s ease; |
|||
} |
|||
.collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner { |
|||
max-height: 100vh; |
|||
} |
Loading…
Reference in new issue