diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 72a22a0..b7cee0d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -6,7 +6,7 @@ repos: hooks: - id: black language: python - types: [python] + types: [python3] args: ["--line-length=120"] - repo: https://github.com/PyCQA/autoflake diff --git a/README.md b/README.md index b9038ed..03c40cd 100644 --- a/README.md +++ b/README.md @@ -206,6 +206,10 @@ To see the bot logs when running with docker in detached mode (`-d`), use the [d Once the ULB role is set, when a new user joins the server, either they are already registered (from another of your servers) in which case they will get the `@ULB` role and get renamed, or they are not registered yet and will receive a DM message with the instructions to register themselves using the `/ulb` command. +* `/feedback` + +Send a feedback directly from discord. + ### Admin server * `/user add` @@ -224,6 +228,14 @@ Edit info of a user. Delete a user. +* `/server info` + +Get information about a guild (ULB role, number of registered members, ...) + +* `/stats` + +Get statistics about the bot usage (nombre of configured servers, number of registered users, ...) + * `/update` This forces a total update of the database and of all the servers. Since the bot already does this automatically at startup and after each reconnection, the only normal usecase for this would be if you manually add an entry (server or user) to the google sheet instead of using the `/user add` command above, we don't recommend manually editing the google sheet. diff --git a/bot/bot.py b/bot/bot.py index a308b33..0bd8bb0 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -96,11 +96,24 @@ def load_commands(self) -> None: ) self.cog_not_loaded.append(extension) - async def send_error_log(self, interaction: ApplicationCommandInteraction, error: Exception): + async def send_error_log(self, tb: str): + + n = len(tb) // 4050 + + #Logs need to be diveded into multiple embed due to size limitation + # TODO Check if we can use a list of embeds and one message + # TODO Make it dynamic base on the message size from the lib (check library version, maybe need to upgrade) + for i in range(n): + await self.log_channel.send(embed=disnake.Embed(description=f"```python\n{tb[4050*i:4050*(i+1)]}```")) + await self.log_channel.send(embed=disnake.Embed(description=f"```python\n{tb[4050*n:]}```")) + + async def send_cmd_error_log(self, interaction: ApplicationCommandInteraction, error: Exception): tb = self.tracebackEx(error) logging.error( f"{error} raised on command /{interaction.application_command.name} from {interaction.guild.name+'#'+interaction.channel.name if interaction.guild else 'DM'} by {interaction.author.name}.\n{tb}" ) + + #Send error msg to the user await interaction.send( content=self.owner.mention, embed=disnake.Embed( @@ -110,6 +123,8 @@ async def send_error_log(self, interaction: ApplicationCommandInteraction, error ), delete_after=10, ) + + #Send logs to admins await self.log_channel.send( embed=disnake.Embed(title=f":x: __** ERROR**__ :x:", description=f"```{error}```").add_field( name=f"Raised on command :", @@ -117,10 +132,7 @@ async def send_error_log(self, interaction: ApplicationCommandInteraction, error + (f" and target\n``'{interaction.target}``'." if interaction.target else "."), ) ) - n = len(tb) // 4050 - for i in range(n): - await self.log_channel.send(embed=disnake.Embed(description=f"```python\n{tb[4050*i:4050*(i+1)]}```")) - await self.log_channel.send(embed=disnake.Embed(description=f"```python\n{tb[4050*n:]}```")) + await self.send_error_log(tb) async def on_slash_command(self, interaction: disnake.ApplicationCommandInteraction) -> None: logging.trace( @@ -138,13 +150,13 @@ async def on_message_command(self, interaction: disnake.MessageCommandInteractio ) async def on_slash_command_error(self, interaction: ApplicationCommandInteraction, error: Exception) -> None: - await self.send_error_log(interaction, error) + await self.send_cmd_error_log(interaction, error) async def on_user_command_error(self, interaction: disnake.UserCommandInteraction, error: Exception) -> None: - await self.send_error_log(interaction, error) + await self.send_cmd_error_log(interaction, error) async def on_message_command_error(self, interaction: disnake.MessageCommandInteraction, error: Exception) -> None: - await self.send_error_log(interaction, error) + await self.send_cmd_error_log(interaction, error) async def on_slash_command_completion(self, interaction: disnake.ApplicationCommandInteraction) -> None: logging.trace( diff --git a/classes/database.py b/classes/database.py index 61d0c39..9313d65 100644 --- a/classes/database.py +++ b/classes/database.py @@ -94,7 +94,7 @@ def loaded(cls) -> bool: return cls._loaded @classmethod - def load(cls, bot: Bot) -> None: + async def load(cls, bot: Bot) -> bool: """Load the data from the google sheet. Returns @@ -104,31 +104,35 @@ def load(cls, bot: Bot) -> None: - Guild: `Dict[disnake.Guild, disnake.Role]` - Users: `Dict[disnake.User, UlbUser]]` """ - # First time this is call, we need to load the credentials and the sheet - if not cls._sheet: - cred_dict = {} - cred_dict["type"] = os.getenv("GS_TYPE") - cred_dict["project_id"] = os.getenv("GS_PROJECT_ID") - cred_dict["auth_uri"] = os.getenv("GS_AUTHOR_URI") - cred_dict["token_uri"] = os.getenv("GS_TOKEN_URI") - cred_dict["auth_provider_x509_cert_url"] = os.getenv("GS_AUTH_PROV") - cred_dict["client_x509_cert_url"] = os.getenv("GS_CLIENT_CERT_URL") - cred_dict["private_key"] = os.getenv("GS_PRIVATE_KEY").replace( - "\\n", "\n" - ) # Python add a '\' before any '\n' when loading a str - cred_dict["private_key_id"] = os.getenv("GS_PRIVATE_KEY_ID") - cred_dict["client_email"] = os.getenv("GS_CLIENT_EMAIL") - cred_dict["client_id"] = int(os.getenv("GS_CLIENT_ID")) - creds = ServiceAccountCredentials.from_json_keyfile_dict(cred_dict, cls._scope) - cls._client = gspread.authorize(creds) - logging.info("[Database] Google sheet credentials loaded.") - - # Open google sheet - cls._sheet = cls._client.open_by_url(os.getenv("GOOGLE_SHEET_URL")) - cls._users_ws = cls._sheet.worksheet("users") - cls._guilds_ws = cls._sheet.worksheet("guilds") - - logging.info("[Database] Spreadsheed loaded") + try: + # First time this is call, we need to load the credentials and the sheet + if not cls._sheet: + cred_dict = {} + cred_dict["type"] = os.getenv("GS_TYPE") + cred_dict["project_id"] = os.getenv("GS_PROJECT_ID") + cred_dict["auth_uri"] = os.getenv("GS_AUTHOR_URI") + cred_dict["token_uri"] = os.getenv("GS_TOKEN_URI") + cred_dict["auth_provider_x509_cert_url"] = os.getenv("GS_AUTH_PROV") + cred_dict["client_x509_cert_url"] = os.getenv("GS_CLIENT_CERT_URL") + cred_dict["private_key"] = os.getenv("GS_PRIVATE_KEY").replace( + "\\n", "\n" + ) # Python add a '\' before any '\n' when loading a str + cred_dict["private_key_id"] = os.getenv("GS_PRIVATE_KEY_ID") + cred_dict["client_email"] = os.getenv("GS_CLIENT_EMAIL") + cred_dict["client_id"] = int(os.getenv("GS_CLIENT_ID")) + creds = ServiceAccountCredentials.from_json_keyfile_dict(cred_dict, cls._scope) + cls._client = gspread.authorize(creds) + logging.info("[Database] Google sheet credentials loaded.") + + # Open google sheet + cls._sheet = cls._client.open_by_url(os.getenv("GOOGLE_SHEET_URL")) + cls._users_ws = cls._sheet.worksheet("users") + cls._guilds_ws = cls._sheet.worksheet("guilds") + + logging.info("[Database] Spreadsheed loaded") + except (ValueError, gspread.exceptions.SpreadsheetNotFound, gspread.exceptions.WorksheetNotFound) as err: + await bot.send_error_log(bot.tracebackEx(err)) + return logging.info("[Database] Loading data...") diff --git a/classes/user.py b/classes/user.py new file mode 100644 index 0000000..ac7144f --- /dev/null +++ b/classes/user.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +import asyncio +import logging +import os +from typing import Dict +from typing import Optional +#from flask_login import + +# TODO: compare with the database.py UlbUser class ? + +class User: +#class User(flask_login.UserMixin): + """Represent an UlbUser + + Parameters + ---------- + name: `str` + The full name of the user + email: `str` + The ulb email address of the user + site_lang: `str` + The website language preferred of the user (default: 'fr') + """ + def __init__(self, ulbid: str, name: str, email: str): + self.ulbid: str = ulbid + self.name: str = name + self.email: str = email + self.site_lang: str = "fr" + self.is_authenticated = False # for flask_login + self.is_active = True # for flask_login + self.is_anonymous = False + + def get_id() -> str: + # for flask_login + return self.ulbid diff --git a/cogs/Admin.py b/cogs/Admin.py index b6564b7..3ca1882 100644 --- a/cogs/Admin.py +++ b/cogs/Admin.py @@ -28,11 +28,12 @@ def __init__(self, bot: Bot): ) async def update(self, inter: disnake.ApplicationCommandInteraction): await inter.response.defer(ephemeral=True) - Database.load(self.bot) - await utils.update_all_guilds() - await inter.edit_original_response( - embed=disnake.Embed(description="All servers updated !", color=disnake.Color.green()) - ) + await Database.load(self.bot) + if (Database.loaded): + await utils.update_all_guilds() + await inter.edit_original_response( + embed=disnake.Embed(description="All servers updated !", color=disnake.Color.green()) + ) @commands.slash_command( name="yearly-update", diff --git a/cogs/Ulb.py b/cogs/Ulb.py index a1a6b53..7e42f46 100644 --- a/cogs/Ulb.py +++ b/cogs/Ulb.py @@ -19,10 +19,11 @@ def __init__(self, bot: Bot): @commands.Cog.listener("on_ready") async def on_ready(self): - Database.load(self.bot) - Registration.setup(self) - logging.info("[Cog:Ulb] Ready !") - await utils.update_all_guilds() + await Database.load(self.bot) + if (Database.loaded): + Registration.setup(self) + logging.info("[Cog:Ulb] Ready !") + await utils.update_all_guilds() async def wait_setup(self, inter: disnake.ApplicationCommandInteraction) -> None: """Async sleep until GoogleSheet is loaded and RegistrationForm is set""" diff --git a/docker-compose.yml b/docker-compose.yml index 782d599..801d484 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,3 +12,5 @@ services: volumes: - ./:/usr/src/ulbdiscordbot restart: always # or unless-stopped + mem_limit: 1g # memory limit for the container + # cpus: 0.5 # cpu limit for the container diff --git a/main_flask.py b/main_flask.py new file mode 100644 index 0000000..20260b9 --- /dev/null +++ b/main_flask.py @@ -0,0 +1,121 @@ +from flask import Flask, render_template, request, session, redirect, url_for +#from flask_login import LoginManager # TODO: user session management +from user import User # TODO: +from cas import CASClient # use python-cas : https://djangocas.dev/blog/python-cas-flask-example/ +import os + +# TODO: add rate-limiting (Flask-Limiter ?) + +app = Flask(__name__) +app.secret_key = os.getenv("FLASK_SECRET_KEY") +#login_manager = LoginManager() +#login_manager.init_app(app) + +cas_client = CASClient( + version=3, # 2 ? + service_url=os.getenv("CAS_SERVICE_URL") # i.e.: service_url='http://localhost:5000/login?next=%2Fprofile', + server_url=os.getenv("CAS_SERVER_URL") # i.e.: server_url='https://django-cas-ng-demo-server.herokuapp.com/cas/' +) + +@app.route("/") +def home(): + return render_template("home.html")#, user=user) + + +@app.route('/login') +def login(): + if "username" in session: + # Already logged in + if db.get(session["username"]) = None: + # Not linked to Discord yet + return redirect(url_for("discord_login")) + else: + return redirect(url_for("user")) + + next = request.args.get("next") + ticket = request.args.get("ticket") + if not ticket: + # No ticket, the request come from end user, send to CAS login + cas_login_url = cas_client.get_login_url() + app.logger.debug('CAS login URL: %s', cas_login_url) + return redirect(cas_login_url) + + # There is a ticket, the request come from CAS as callback. + # need call `verify_ticket()` to validate ticket and get user profile. + app.logger.debug('ticket: %s', ticket) + app.logger.debug('next: %s', next) + + user, attributes, pgtiou = cas_client.verify_ticket(ticket) + + app.logger.debug( + 'CAS verify ticket response: user: %s, attributes: %s, pgtiou: %s', user, attributes, pgtiou) + + if not user: + return 'Failed to verify ticket. Try to login again: Home' + #return redirect(url_for(("home")) + else: # Login successfully, redirect according `next` query parameter. + session['username'] = user + #session["attributes"] = attributes # TODO: check what attributes we get + return redirect(next) + +@app.route('/logout') +def logout(): + redirect_url = url_for('logout_callback', _external=True) + cas_logout_url = cas_client.get_logout_url(redirect_url) + app.logger.debug('CAS logout URL: %s', cas_logout_url) + + return redirect(cas_logout_url) + +@app.route('/logout_callback') +def logout_callback(): + # redirect from CAS logout request after CAS logout successfully + session.pop('username', None) + #return 'Logged out from CAS. Login' + return redirect(url_for(("home")) # ? + +@app.route("/discord_login") +def discord_login(): + if user.site_lang = "en": + return render_template("discord-login_en.html"; user=user) + else: + return render_template("discord-login_fr.html", user=user) + + +@app.route("/user") +def user(method=['GET']): + if 'username' in session: + user = session["username"] + #return 'Logged in as %s. Logout' % session['username'] + if user.site_lang = "en": + return render_template("user_en.html", user=user) + else: + return render_template("user_fr.html", user=user) + else: + return 'Login required. Back to Home', 403 + #return redirect(url_for("home")) + +@app.route("/test") +def test(): + test_user = User( + ulbid="test", + email="test@example.com" + ) + return render_template("test.html", user=test_user) + +@app.route("/ping") +def ping(): + return "pong" + + +@app.route("/bot") +def bot(): + # TODO: check if bot status is online + bot_status = "Online" + return render_template("bot.html", status=bot_status) + + +if __name__ == "__main__": + # TODO: run Disnake and Flask in different threads ? asyncio ? + app.run(debug=False) + + diff --git a/static/styles.css b/static/styles.css new file mode 100644 index 0000000..8fe7cb8 --- /dev/null +++ b/static/styles.css @@ -0,0 +1,698 @@ +* { + box-sizing: border-box; +} + +body { + margin: 0; + box-sizing: border-box; + min-height: 100vh; + display: flex; + flex-direction: column; +} + +button:hover { + cursor: pointer; + border: 3px solid blue; +} + +.lang-button:hover { + cursor: pointer; +} + +.clearfix { + clear: both; +} + +.container-width { + width: 90%; + max-width: 1150px; + margin: 0 auto; +} + +.flex-sect { + background-color: #fafafa; + padding: 100px 0; + font-family: Helvetica, serif; +} + +.flex-title { + //margin-bottom: 15px; + font-size: 2em; + text-align: center; + font-weight: 700; + color: #555; + padding: 5px; +} + +.flex-desc { + margin-bottom: 55px; + font-size: 1em; + color: rgba(0, 0, 0, 0.5); + text-align: center; + padding: 5px; +} + +.footer-under { + background-color: #312833; + padding-bottom: 100px; + padding-top: 100px; + min-height: 500px; + color: #eee; + position: relative; + font-weight: 100; + font-family: Helvetica, serif; + margin-top: auto; +} + +.copyright { + background-color: rgba(0, 0, 0, 0.15); + color: rgba(238, 238, 238, 0.5); + bottom: 0; + padding: 1em 0; + position: absolute; + width: 100%; + font-size: 0.75em; +} + +.made-with { + float: left; + width: 80%; + padding: 5px 0; +} + +.foot-social-btns { + display: none; + float: right; + width: 50%; + text-align: right; + padding: 5px 0; +} + +.footer-container { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: space-around; +} + +.foot-list { + float: left; + width: 200px; +} + +.foot-list-title { + font-weight: 700; + margin-bottom: 4em; + padding: 0.5em 0; + font-size: 1.2em; + line-height: 2.2em; +} + +.foot-list-item { + color: rgba(238, 238, 238, 0.8); + font-size: 0.8em; + padding: 0.5em 0; + text-decoration: none; + line-height: 2.2em; +} + +.foot-list-item:hover { + color: rgba(238, 238, 238, 1); + text-decoration: underline; + cursor: pointer; +} + +.gjs-row { + display: flex; + justify-content: flex-start; + align-items: stretch; + flex-wrap: nowrap; + padding: 10px; +} + +.gjs-title-row { + display: flex; + justify-content: flex-start; + align-items: stretch; + flex-wrap: nowrap; + padding: 1rem; + padding-top: 0; + margin-bottom: 2rem; + border-bottom: 2px solid black; +} + +.gjs-cell { + //min-height: 75px; + min-height: 60px; + flex-grow: 1; + flex-basis: 100%; +} + +#isl2q-2{ + text-align:right; +} + +#imt5cq{ + display:flex; + justify-content:flex-start; + align-self:flex-start; + flex:0 0 45%; + align-items:center; + min-height:100%; + padding:0px 0px 0px 2em; +} + +#i87qsk{ + align-self:stretch; + justify-content:center; + display:flex; + align-items:stretch; +} + +#iwp1uq { + +} + +#ijsy0p{ + font-size:1.2rem; + font-weight:500; + margin:0 1em 0 0; + padding:0.8em 0px 0px 0px; +} + +#iggerx{ + justify-content:center; + align-self:center; + align-items:center; +} + +#igc0y{ + font-size:1.2rem; + font-weight:500; +} + +#iqw1p { + padding: 32px 0px 32px 0px; +} + +#ikn8eu { + padding: 32px 0px 32px 0px; + min-height: 18rem; + opacity: 1; + background-repeat: repeat; + background-position: left top; + background-attachment: scroll; + background-size: auto; + background-image: linear-gradient(#070a4b 0%, #070a4b 100%); +} + +#iv0ipc { + text-align: left; + float: none; +} + +#iqzgmk { + padding: 0px 12em 0px 12em; +} + +#ia6p6n { + display: block; + justify-content: center; + align-self: center; + text-align: left; + float: none; +} + +#ieb6rq { + text-align: left; + justify-content: center; + align-self: flex-start; + display: flex; +} + +#ida3de { + display: flex; + text-align: left; + justify-content: center; + align-self: flex-start; +} + +#i5q8b3 { + padding: 1em 10em 1em 10em; +} + +#ixcsj { + max-width: 800px; +} + +#il3a7 { + margin: 0px 0px 16px 0px; + color: #000000; +} + +#if6j5 { + margin: 0px 0px 16px 0px; + color: #000000; + font-weight: 700; + font-family: Arial, Helvetica, sans-serif; +} + +#im26nw { + color: black; + text-align: center; + display: flex; + justify-content: center; + align-self: flex-end; + align-items: flex-end; + height: 8em; + width: 8em; +} + +#ibzh2s { + padding: 0px 10px 0px 10px; + margin: 0px 0px 0 0px; +} + +#iezu0x { + text-align: center; + justify-content: center; + align-items: flex-end; + align-self: flex-end; + display: flex; + margin: 0px 0px 0px 0px; +} + +#icpsj { + text-align:center; + font-size:1.2em; + padding:28px 32px 24px 28px; + font-weight:600; + opacity:1; + border:3px solid #ffffff; + border-radius:10px 10px 10px 10px; + color:#eeeeee; + background-repeat:repeat; + background-position:left top; + background-attachment:scroll; + background-size:auto; + background-image:linear-gradient(#070a4b 0%, #070a4b 100%); + display:flex; + justify-content:center; + align-self:center; +} + +#iwk22 { + display: flex; + justify-content: center; + align-self: center; + margin: 0 0 16px 0; +} + +#ilnd4 { + text-align: center; + font-size: 1.2em; + padding: 24px 32px 24px 32px; + font-weight: 600; + opacity: 1; + border: 3px solid #030049; + border-radius: 10px 10px 10px 10px; + color: #070a4b; +} + +#isl2q { + //margin: 0px 0px 16px 0px; + color: #000000; +} + +#ibxju { + padding: 10px; + text-align: center; +} + +#iu6vy { + padding: 10px; + margin: 0 0 16px 0; + text-align: center; +} + +#iwekp { + padding: 0 10px 0px 10px; +} + +#iupdl { + text-align: center; + font-size: 1.2em; + padding: 24px 32px 24px 32px; + font-weight: 600; + opacity: 1; + border: 3px solid #ffffff; + border-radius: 10px 10px 10px 10px; + color: #eeeeee; + background-repeat: repeat; + background-position: left top; + background-attachment: scroll; + background-size: auto; + background-image: linear-gradient(#070a4b 0%, #070a4b 100%); +} + +#iwc1yl { + align-self: center; + justify-content: center; + display: flex; + //margin: 0 0 16px 0; + align-items: flex-end; +} + +#ig7wcz { + text-align: center; + font-size: 1.2em; + padding: 24px 32px 24px 32px; + font-weight: 600; + opacity: 1; + border: 3px solid #030049; + border-radius: 10px 10px 10px 10px; + color: #070a4b; +} + +#imt5cq { + display: flex; + justify-content: center; + align-self: center; + margin: 0 0 16px 0; + flex-basis: 30%; +} + +#ivs5rm { + padding: 10px; + margin: 0px 0px 0px 0px; + text-align: center; +} + +#ieybnm { + min-height: 100%; +} + +#iddg46 { + padding: 10px 10px 0 10px; +} + +#i96zw { + justify-content: center; + align-self: center; + display: flex; + margin: 0 0 16px 0; +} + +#i5ybe4 { + margin: 0px 0px 16px 0px; + color: #000000; +} + +#i0xzhn { + align-self: center; + justify-content: center; + display: flex; + margin: 0 0 16px 0; + align-items: flex-end; +} + +#i9nhug { + text-align: center; + font-size: 1.2em; + padding: 24px 32px 24px 32px; + font-weight: 600; + opacity: 1; + border: 3px solid #030049; + border-radius: 10px 10px 10px 10px; + color: #070a4b; +} + +#iqgfan { + display: flex; + justify-content: center; + align-self: center; + margin: 0 0 16px 0; + flex-basis: 30%; +} + +#ipcq2 { + display: flex; + align-self: center; + justify-content: center; + margin: 0px 0px 0px 0px; +} + +#icf2hf { + padding: 10px; + margin: 0px 0px 0px 0px; + text-align: center; +} + +#im6ow7 { + min-height: 100%; + margin: 0 0 16px 0; +} + +#iy5iar { + padding: 0px 10px 10px 10px; +} + +#i7esag { + color: #070a4b; +} + +#ifoyzv { + color: #070a4b; +} + +#ibgfgw { + color: #070a4b; +} + +@media (max-width: 768px) { + .gjs-row { + flex-wrap: wrap; + } + + #ikn8eu { + min-height: 29rem; + } + + #iqzgmk { + padding: 0px 4em 0px 4em; + } + + #i5q8b3 { + padding: 1em 0em 1em 0em; + } + + #ijsy0p { + //padding-top: 1.4em; + font-size: 1rem; + } +} + +@media (max-width: 480px) { + .foot-lists { + display: none; + } + + #ikn8eu { + min-height: 30rem; + } + + #inesqo { + width: 100%; + } + + #iqzgmk { + padding: 0px 1em 0px 1em; + } + + #i5q8b3 { + padding: 1em 0em 1em 0em; + } + + #ijsy0p { + padding-top: 1.4em; + font-size: 0.8rem; + } + + #i2e4vk{ + font-size: 1em !important; + line-height: 1em; + } + + .logout-icon { + font-size: 1.2em !important; + margin-left: 0.8rem !important; + line-height: 1em; + + } + + .logout-button { + padding: 12px !important; + } +} + + +.unselected-lang { + background-repeat:repeat; + background-position:left top; + background-attachment:scroll; + background-size:auto; + background-color: transparent; + color:#ffffff; + text-align:center; + float:right; + margin:4px 6px 3px 6px; + padding: 4px; + border:1px solid #ffffff; + border-radius:2px 2px 2px 2px; + font-weight:400; + opacity:1; +} + +.selected-lang { + text-decoration: none; + background-repeat:repeat; + background-position:left top; + background-attachment:scroll; + background-size:auto; + background-image:linear-gradient(#ffffff 0%, #ffffff 100%); + color: #070a4b; + text-align:center; + padding-top: 3px; + padding-bottom: 3px; + padding-left: 7px; + padding-right: 7px; + float:right; + margin:4px 6px 3px 6px; + border:2px solid rgba(0,0,0,0); + border-radius:2px 2px 2px 2px; + font-weight:700; +} + +.button-dark { + font: inherit; + color: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 1.6rem; + height: 5rem; + //min-height: 4rem; + border-radius: 10px; + line-height: 1; + //border: 2px solid var(--c-border-primary); + color: #ffffff; + font-size: 1.4em; + transition: 0.15s ease; + background-color: #070a4b; + font-weight: 700; + + span { + font-weight: 700; + } +} + +.button-dark:hover { + cursor: pointer; + border: 3px solid #044b93; + transition: 0.15s ease-in-out; +} + +#ulb-login-button:hover { + //border: 3px solid #070a4b; + border-color: #070a4b; + background-color: #044b93; +} + +.button-light { + font: inherit; + color: inherit; + text-decoration: none; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 1.6rem; + height: 5rem; + //min-height: 4rem; + border-radius: 10px; + line-height: 1; + border: 3px solid #070a4b; + color: #070a4b; + font-size: 1.4em; + transition: 0.15s ease; + background-color: transparent; + font-weight: 700; +} + +.button-light:hover { + cursor: pointer; + border: 3px solid #044b93; + color: #044b93; + transition: 0.15s ease-in-out; +} + +.button-icon { + width: 2.8rem; + height: 2.8rem; + margin-right: 1.2rem; + } + +.logout-button { + padding: 0 1rem; + height: 3rem; + //min-height: 2rem; + font-size: 1em; +} + +.logout-button:hover { + #i2e4vk { + text-decoration: underline; + } +} + +#discord-button:hover { + background-color: #5c64f4; + border-color: #070a4b; +} + +#discord-unlink-button { + border-color: red; + color: red; +} + +#discord-unlink-button:hover { + background-color: red; + color: #ffffff; +} + +#iwp2uq { + +} + +#iwc2yl { + align-self: center; + justify-content: center; + display: flex; + //margin: 0 0 16px 0; + align-items: flex-end; + font-size: 4rem; + font-weight: 700; +} + +#if7j6 { + color: #000000; + font-weight: 500; + font-family: Arial, Helvetica, sans-serif; + margin: 1.4em; +} + +#if8j7 { + border: 3px solid black; + border-radius: 10px; + padding: 0.4em; +} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..b705091 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,73 @@ + + + +
+ + + + + + + +