Updates to web page (add bootstrap), initial DB setup

This commit is contained in:
Flare Starfall 2026-03-06 10:57:30 +01:00
parent a9a418f301
commit fa8f24638e
24 changed files with 14331 additions and 133 deletions

2
.gitignore vendored
View File

@ -176,4 +176,4 @@ cython_debug/
# ---> StarfallBot # ---> StarfallBot
config.toml config.toml
web/static/style/*.scss web/static/css/

13
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,13 @@
{
"basedpyright.analysis.diagnosticMode": "workspace",
"basedpyright.analysis.diagnosticSeverityOverrides": {
"reportAny": false,
"reportExplicitAny": false,
"reportMissingTypeStubs": false,
"reportUnknownMemberType": false,
"reportUnknownVariableType": false
},
"basedpyright.analysis.extraPaths": [
"starfallbot/.venv/lib/**/site-packages"
]
}

12
app.py
View File

@ -11,7 +11,7 @@ from starfall.log import Log
from starfall.types import SnapshotQueue from starfall.types import SnapshotQueue
from starfall.web import WebUI from starfall.web import WebUI
CURRENT_VERSION = "0.1.0-alpha.1" CURRENT_VERSION = "0.1.0-alpha.2"
@final @final
@ -23,6 +23,7 @@ class Application:
threads: dict[str, Thread] threads: dict[str, Thread]
def __init__(self): def __init__(self):
"""Initializes the application and prepares all threads for launch."""
self.log = Log() self.log = Log()
logging.getLogger("app").debug( logging.getLogger("app").debug(
@ -48,7 +49,11 @@ class Application:
self.app = WebUI() self.app = WebUI()
def start(self): def run(self):
"""
Starts all threads and monitors their active state.
This function runs indefinitely.
"""
self.threads["flask"] = Thread( self.threads["flask"] = Thread(
target=self.app.run, target=self.app.run,
args=( args=(
@ -64,9 +69,10 @@ class Application:
self.monitor_queue() self.monitor_queue()
def monitor_queue(self): def monitor_queue(self):
"""Monitors and logs all incoming messages in `self.queue`."""
for entry in self.queue.get_all_for_receiver("app"): for entry in self.queue.get_all_for_receiver("app"):
logging.getLogger("app").info("Received queue message: %r", entry) logging.getLogger("app").info("Received queue message: %r", entry)
if __name__ == "__main__": if __name__ == "__main__":
Application().start() Application().run()

View File

@ -1,5 +1,8 @@
discord-py-interactions discord-py-interactions
flask flask
flask_assets
libsass
livereload
flask-sqlalchemy flask-sqlalchemy
rich rich
twitchio twitchio

View File

@ -39,7 +39,7 @@ class Config:
error, error,
) )
def get(self, key: str, default: Any = None): def get(self, key: str, default: Any = None) -> Any:
"""Recursively calls `get(key, default)` to search nested dictionary. """Recursively calls `get(key, default)` to search nested dictionary.
Examples: Examples:

View File

@ -1,3 +1,30 @@
from flask_sqlalchemy import SQLAlchemy import importlib
import logging
import os
from inspect import isclass
from pkgutil import iter_modules
from typing import final
db = SQLAlchemy() from flask_sqlalchemy import SQLAlchemy
from sqlalchemy.orm import DeclarativeBase
@final
class BaseModel(DeclarativeBase):
pass
db = SQLAlchemy(model_class=BaseModel)
def load_schema():
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "schema")
for _, module_name, _ in iter_modules([path], f"{__name__}.schema."):
logging.getLogger("db").debug("Parsing module: %s" % module_name)
module = importlib.import_module(module_name)
for attribute_name in dir(module):
attribute = getattr(module, attribute_name)
if isclass(attribute) and issubclass(attribute, db.Table):
globals()[attribute_name] = attribute

View File

@ -0,0 +1,10 @@
from sqlalchemy.orm import Mapped, mapped_column
from starfall.db import db
class Users(db.Table):
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(unique=True)
username: Mapped[str] = mapped_column()
password: Mapped[str] = mapped_column()

View File

@ -6,7 +6,10 @@ from rich.logging import RichHandler
class Log: class Log:
def __init__(self) -> None: def __init__(self) -> None:
handler = RichHandler(level=logging.DEBUG, rich_tracebacks=True) handler: logging.Handler = RichHandler(
level=logging.DEBUG, rich_tracebacks=True
)
logging.basicConfig( logging.basicConfig(
level=logging.DEBUG, level=logging.DEBUG,
format="%(message)s [%(name)s]", format="%(message)s [%(name)s]",

View File

@ -2,14 +2,15 @@ import importlib
import logging import logging
import os import os
from inspect import isclass from inspect import isclass
from pathlib import Path
from pkgutil import iter_modules from pkgutil import iter_modules
from typing import final from typing import final
from flask import Blueprint, Flask from flask import Blueprint, Flask
from flask_assets import Bundle, Environment
from livereload import Server
from starfall.config import Config from starfall.config import Config
from starfall.db import db from starfall.db import db, load_schema
from starfall.types import SnapshotQueue from starfall.types import SnapshotQueue
from starfall.web.blueprints.base import BaseBlueprint from starfall.web.blueprints.base import BaseBlueprint
@ -21,6 +22,8 @@ class WebUI:
self.queue: SnapshotQueue | None = None self.queue: SnapshotQueue | None = None
self.app: Flask | None = None self.app: Flask | None = None
self.blueprint: Blueprint = Blueprint("main", __name__) self.blueprint: Blueprint = Blueprint("main", __name__)
self.assets: Environment | None = None
self.server: Server | None = None
def run(self, config: Config, queue: SnapshotQueue): def run(self, config: Config, queue: SnapshotQueue):
self.config = config self.config = config
@ -28,20 +31,58 @@ class WebUI:
logging.getLogger("web").debug("Hello from %r", type(self)) logging.getLogger("web").debug("Hello from %r", type(self))
self.app = Flask(__name__) self.app = Flask(
import_name=__name__,
root_path=os.path.realpath("."),
static_folder=os.path.realpath("./web/static"),
template_folder="web/templates",
)
self.app.config.update( self.app.config.update(
SECRET_KEY=str(self.config.get("web.secret_key")), SECRET_KEY=str(self.config.get("web.secret_key")),
SQLALCHEMY_DATABASE_URI=str(self.config.get("web.database_url")), SQLALCHEMY_DATABASE_URI=str(self.config.get("web.database_url")),
TEMPLATES_AUTO_RELOAD=True,
) )
self.app.jinja_env.auto_reload = True
self.app.root_path = os.path.realpath(".") self.assets = Environment(self.app)
self.app.static_folder = os.path.realpath("./web/static") self.assets.load_path = ["web/static/scss", "web/static/css"]
self.app.template_folder = "web" scss = Bundle(
"main.scss",
"fonts.scss",
"bootstrap.min.scss",
filters="libsass",
output="css/main.css",
depends=["**/*.scss"],
)
_ = self.assets.register("scss", scss)
db.init_app(self.app) db.init_app(self.app)
load_schema()
with self.app.app_context():
db.create_all()
self.import_blueprints() self.import_blueprints()
self.app.register_blueprint(self.blueprint, options={"queue": self.queue}) self.app.register_blueprint(
self.app.run(host=self.config.get("web.host"), port=self.config.get("web.port")) self.blueprint,
options={"queue": self.queue},
)
self.server = Server(self.app.wsgi_app)
self.server.watch(
filepath=os.path.join(str(self.app.template_folder), "**/*.html"),
)
self.server.watch(
filepath=os.path.join(str(self.app.static_folder), "**/*.js"),
)
self.server.watch(
filepath=os.path.join(str(self.app.static_folder), "**/*.scss"),
)
self.server.serve(
host=self.config.get("web.host"),
port=self.config.get("web.port"),
)
def import_blueprints(self): def import_blueprints(self):
path = os.path.realpath(os.path.dirname(__file__) + os.sep + "blueprints") path = os.path.realpath(os.path.dirname(__file__) + os.sep + "blueprints")
@ -49,6 +90,7 @@ class WebUI:
if module_name.endswith("base"): if module_name.endswith("base"):
continue continue
logging.getLogger("web").debug("Parsing module: %s" % module_name)
module = importlib.import_module(module_name) module = importlib.import_module(module_name)
for attribute_name in dir(module): for attribute_name in dir(module):
@ -56,4 +98,4 @@ class WebUI:
if isclass(attribute) and issubclass(attribute, BaseBlueprint): if isclass(attribute) and issubclass(attribute, BaseBlueprint):
globals()[attribute_name] = attribute globals()[attribute_name] = attribute
globals()[attribute_name](self.blueprint) globals()[attribute_name](self.blueprint, self.assets)

View File

@ -1,30 +1,42 @@
import inspect import inspect
import logging import logging
from abc import ABC from abc import ABC
from types import FrameType
from flask import Blueprint from flask import Blueprint
from flask.blueprints import BlueprintSetupState from flask.blueprints import BlueprintSetupState
from flask_assets import Environment
from starfall.types import SnapshotQueue from starfall.types import SnapshotQueue
class BaseBlueprint(ABC): class BaseBlueprint(ABC):
def __init__(self, blueprint: Blueprint): def __init__(self, blueprint: Blueprint, assets: Environment):
self.queue: SnapshotQueue | None = None
self.assets: Environment = assets
blueprint.record(self.on_blueprint_setup) blueprint.record(self.on_blueprint_setup)
if type(self) != BaseBlueprint: # noqa: E721 if type(self) is not BaseBlueprint:
self._debug("Attaching blueprint of type %r to web service", type(self)) self._debug("Attaching blueprint of type %r to web service", type(self))
def on_blueprint_setup(self, state: BlueprintSetupState): def on_blueprint_setup(self, state: BlueprintSetupState):
self.queue: SnapshotQueue = state.options["options"]["queue"] self.queue = state.options["options"]["queue"]
if type(self) != BaseBlueprint: # noqa: E721 if type(self) is not BaseBlueprint:
self._info("Blueprint of type %r successfully set up", type(self)) self._info("Blueprint of type %r successfully set up", type(self))
def _log_access(self): def _log_access(self):
# There is always a previous frame - This function is called when a # There is always a previous frame - This function is called when a
# route is accessed by a user through a method unknown to this one. # route is accessed by a user through a method unknown to this one.
self._info("Route access: %r", inspect.currentframe().f_back.f_code.co_name) frame: FrameType | None = inspect.currentframe()
if not isinstance(frame, FrameType):
return
f_back: FrameType | None = frame.f_back
if not isinstance(f_back, FrameType):
return
self._info("Route access: %r", f_back.f_code.co_name)
def _debug(self, msg: str, *args: object): def _debug(self, msg: str, *args: object):
logging.getLogger("web").debug(msg, *args) logging.getLogger("web").debug(msg, *args)

View File

@ -1,13 +1,14 @@
from flask import Blueprint, render_template from flask import Blueprint, render_template
from flask_assets import Environment
from starfall.web.blueprints.base import BaseBlueprint from starfall.web.blueprints.base import BaseBlueprint
class HomeBlueprint(BaseBlueprint): class HomeBlueprint(BaseBlueprint):
def __init__(self, blueprint: Blueprint) -> None: def __init__(self, blueprint: Blueprint, assets: Environment) -> None:
super().__init__(blueprint) super().__init__(blueprint, assets)
blueprint.add_url_rule("/", view_func=self.index) blueprint.add_url_rule("/", view_func=self.index)
def index(self): def index(self):
self._log_access() self._log_access()
return render_template("home.html") return render_template("home.html", assets=self.assets, route="/")

View File

@ -1,11 +0,0 @@
{% extends "index.html" %}
{% block titleSuffix %}Overview{% endblock %}
{% block body %}
<div class="flex center">
<h1>StarfallBot - Overview</h1>
</div>
{% endblock %}

View File

@ -1,61 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<title>StarfallBot - {% block titleSuffix %}Unassigned{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="StarfallBot administation interface" />
<link rel="preload" href="{{ url_for('static', filename='img/logo.png') }}" as="image" />
<link rel="preload" href="{{ url_for('static', filename='style/fonts.css') }}" as="style" />
<link rel="preload" href="{{ url_for('static', filename='style/main.css') }}" as="style" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/fonts.css') }}" />
<link rel="stylesheet" href="{{ url_for('static', filename='style/main.css') }}" />
{% block head %}
{% endblock %}
</head>
<body>
{% block beforeMain %}
{% endblock %}
<header>
<div class="container">
<nav>
{% block beforeNav %}
{% endblock %}
<a href="/">Home</a>
{% block afterNav %}
{% endblock %}
</nav>
</div>
</header>
<main>
{% block beforeMainContainer %}
{% endblock %}
<div class="container">
{% block body %}
<p>No content has been defined for this page.</p>
{% endblock %}
</div>
{% block afterMainContainer %}
{% endblock %}
</main>
{% block afterMain %}
{% endblock %}
</body>
</html>

7
web/static/js/bootstrap.bundle.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,80 @@
/*!
* Color mode toggler for Bootstrap's docs (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors
* Licensed under the Creative Commons Attribution 3.0 Unported License.
*/
(() => {
'use strict'
const getStoredTheme = () => localStorage.getItem('theme')
const setStoredTheme = theme => localStorage.setItem('theme', theme)
const getPreferredTheme = () => {
const storedTheme = getStoredTheme()
if (storedTheme) {
return storedTheme
}
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
const setTheme = theme => {
if (theme === 'auto') {
document.documentElement.setAttribute('data-bs-theme', (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'))
} else {
document.documentElement.setAttribute('data-bs-theme', theme)
}
}
setTheme(getPreferredTheme())
const showActiveTheme = (theme, focus = false) => {
const themeSwitcher = document.querySelector('#bd-theme')
if (!themeSwitcher) {
return
}
const themeSwitcherText = document.querySelector('#bd-theme-text')
const activeThemeIcon = document.querySelector('.theme-icon-active use')
const btnToActive = document.querySelector(`[data-bs-theme-value="${theme}"]`)
const svgOfActiveBtn = btnToActive.querySelector('svg use').getAttribute('href')
document.querySelectorAll('[data-bs-theme-value]').forEach(element => {
element.classList.remove('active')
element.setAttribute('aria-pressed', 'false')
})
btnToActive.classList.add('active')
btnToActive.setAttribute('aria-pressed', 'true')
activeThemeIcon.setAttribute('href', svgOfActiveBtn)
const themeSwitcherLabel = `${themeSwitcherText.textContent} (${btnToActive.dataset.bsThemeValue})`
themeSwitcher.setAttribute('aria-label', themeSwitcherLabel)
if (focus) {
themeSwitcher.focus()
}
}
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
const storedTheme = getStoredTheme()
if (storedTheme !== 'light' && storedTheme !== 'dark') {
setTheme(getPreferredTheme())
}
})
window.addEventListener('DOMContentLoaded', () => {
showActiveTheme(getPreferredTheme())
document.querySelectorAll('[data-bs-theme-value]')
.forEach(toggle => {
toggle.addEventListener('click', () => {
const theme = toggle.getAttribute('data-bs-theme-value')
setStoredTheme(theme)
setTheme(theme)
showActiveTheme(theme, true)
})
})
})
})()

BIN
web/static/logo.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

13871
web/static/scss/bootstrap.min.scss vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,43 @@
/* exo-2-regular - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 400;
src: url('../fonts/exo-2-v26-latin-regular.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* exo-2-700 - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 700;
src: url('../fonts/exo-2-v26-latin-700.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-regular - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 400;
src: url('../fonts/overpass-v19-latin-regular.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-700 - latin */
@font-face {
font-display: swap;
/* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 700;
src: url('../fonts/overpass-v19-latin-700.woff2') format('woff2');
/* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

93
web/static/scss/main.scss Normal file
View File

@ -0,0 +1,93 @@
$siteHeadingFontFamily: 'Exo 2', sans-serif;
$siteFontFamily: 'Overpass', sans-serif;
$siteFontSize: 18px;
$mainBorderRadius: 16px;
$contentWidth: 1400px;
$contentPadding: 8px;
$fontSize: (
h1: 4rem,
h2: 2rem,
h3: 1.5rem,
h4: 1rem,
h5: 0.875rem,
h6: 0.75rem
);
[data-bs-theme="light"] {
--siteBackground: linear-gradient(to bottom right, rgb(136, 200, 255), rgb(135, 165, 255));
--siteTextColor: #000;
--mainBackground: #001030;
--mainTextColor: #fff;
}
[data-bs-theme="dark"] {
--siteBackground: linear-gradient(to bottom right, rgb(70, 81, 110), rgb(16, 20, 60));
--siteTextColor: #000;
--mainBackground: #001030;
--mainTextColor: #fff;
}
.navbar-brand img {
max-width: 40px;
transform: rotate(340deg);
}
body {
position: relative;
min-height: 100%;
color: var(--siteTextColor);
background: var(--siteBackground);
background: {
repeat: no-repeat;
attachment: fixed;
}
font: {
family: $siteFontFamily;
size: $siteFontSize;
}
}
// main {
// height: 100%;
// display: flex;
// justify-content: center;
// &>.container {
// width: 100%;
// height: 100%;
// max-width: $contentWidth;
// padding: $contentPadding;
// background: $mainBackground;
// border-radius: $mainBorderRadius;
// color: $mainTextColor;
// }
// .flex {
// display: flex;
// flex-flow: row wrap;
// &.center {
// justify-content: center;
// }
// }
// }
@each $tag, $size in $fontSize {
#{$tag} {
font: {
family: $siteHeadingFontFamily;
size: $size;
weight: bold;
}
padding: 0;
margin: 0;
}
}

View File

@ -1,35 +0,0 @@
/* exo-2-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 400;
src: url('../fonts/exo-2-v26-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* exo-2-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Exo 2';
font-style: normal;
font-weight: 700;
src: url('../fonts/exo-2-v26-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-regular - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 400;
src: url('../fonts/overpass-v19-latin-regular.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}
/* overpass-700 - latin */
@font-face {
font-display: swap; /* Check https://developer.mozilla.org/en-US/docs/Web/CSS/@font-face/font-display for other options. */
font-family: 'Overpass';
font-style: normal;
font-weight: 700;
src: url('../fonts/overpass-v19-latin-700.woff2') format('woff2'); /* Chrome 36+, Opera 23+, Firefox 39+, Safari 12+, iOS 10+ */
}

View File

@ -1 +0,0 @@
body{position:relative;min-height:100%;color:#000;background-image:linear-gradient(to bottom, #205080, #4070a0);background-repeat:no-repeat;background-attachment:fixed;font-family:"Overpass",sans-serif;font-size:18px}main{height:100%;display:flex;justify-content:center}main>.container{width:100%;height:100%;max-width:1400px;padding:8px;background:#001030;border-radius:16px;color:#fff}main .flex{display:flex;flex-flow:row wrap}main .flex.center{justify-content:center}h1{font-family:"Exo 2",sans-serif;font-size:4rem;font-weight:bold;padding:0;margin:0}h2{font-family:"Exo 2",sans-serif;font-size:2rem;font-weight:bold;padding:0;margin:0}h3{font-family:"Exo 2",sans-serif;font-size:1.5rem;font-weight:bold;padding:0;margin:0}h4{font-family:"Exo 2",sans-serif;font-size:1rem;font-weight:bold;padding:0;margin:0}h5{font-family:"Exo 2",sans-serif;font-size:.875rem;font-weight:bold;padding:0;margin:0}h6{font-family:"Exo 2",sans-serif;font-size:.75rem;font-weight:bold;padding:0;margin:0}

View File

@ -0,0 +1,3 @@
<li class="nav-item">
<a class="nav-link {% if route == '/' %}active{% endif %}" href="/">Home</a>
</li>

5
web/templates/home.html Normal file
View File

@ -0,0 +1,5 @@
{% extends 'index.html' %}
{% block navLinks %}
{% include 'components/menu_public.html' %}
{% endblock %}

87
web/templates/index.html Normal file
View File

@ -0,0 +1,87 @@
<!DOCTYPE html>
<html data-bs-theme="dark">
<head>
<meta charset="UTF-8" />
<title>StarfallBot - {% block titleSuffix %}Unassigned{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<meta name="description" content="StarfallBot administation interface" />
<link rel="icon" href="{{ url_for('static', filename='favicon.png') }}" />
<link rel="stylesheet" type="text/css" href="{{ assets['scss'].urls()|first }}" />
{% block head %}{% endblock %}
</head>
<body>
<a class="visually-hidden-focusable" href="#content">Skip to main content</a>
<header>
<nav class="navbar navbar-expand-md">
<div class="container-fluid">
<a class="navbar-brand" href="/">
<img src="{{ url_for('static', filename='logo.png') }}" alt="Starfall" />
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse"
data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent"
aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarSupportedContent">
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
{% block navLinks %}{% endblock %}
<li class="nav-menu dropdown">
<button id="theme" class="btn btn-link nav-link dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">Theme</button>
<ul class="dropdown-menu">
<li>
<button class="dropdown-item" type="button" data-bs-theme-value="light">
Light
</button>
<button class="dropdown-item" type="button" data-bs-theme-value="dark">
Dark
</button>
<button class="dropdown-item" type="button" data-bs-theme-value="auto">
Auto
</button>
</li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
</header>
{% block beforeMain %}{% endblock %}
<main>
{% block beforeMainContainer %}{% endblock %}
<div class="container" id="content">
{% block body %}
{# If there's no body on this page, treat it as a 404. #}
<div class="d-flex flex-column justify-content-center align-items-center">
<div>
<h1 class="text-danger-emphasis m-2">404</h1>
</div>
<div class="mb-4">
<h2 class="flex-col text-danger-emphasis">That's an error.</h2>
</div>
<div class="flex-row">
<p>Want to go back to the <a href="/">start</a> and try again?</p>
</div>
</div>
</div>
{% endblock %}
</div>
{% block afterMainContainer %}{% endblock %}
</main>
{% block afterMain %}{% endblock %}
<script type="text/javascript" src="{{ url_for('static', filename='js/bootstrap.bundle.min.js') }}"></script>
<script type="text/javascript" src="{{ url_for('static', filename='js/color-switcher.js') }}"></script>
</body>
</html>