Compare commits
37 Commits
78fa0a4fc9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a607318eff
|
|||
|
99131cea6f
|
|||
|
615716d72e
|
|||
|
dd5d400282
|
|||
|
3ff43e9e1b
|
|||
|
82347e4b78
|
|||
|
322c09f10a
|
|||
|
c0f6765a06
|
|||
|
88999dafd5
|
|||
|
38aae30892
|
|||
|
ccfa2bd48c
|
|||
|
b07f3781fa
|
|||
|
9f8b5d3e59
|
|||
|
21c78ccce5
|
|||
|
fa94974239
|
|||
|
26c25122cc
|
|||
|
1e1a54217a
|
|||
|
6f372c44e1
|
|||
|
49352cd0b2
|
|||
|
aa1ceb7739
|
|||
|
52db1c3d9b
|
|||
|
a162ba6841
|
|||
|
ec97367b78
|
|||
|
39e1090b91
|
|||
|
1f1f2f6a9e
|
|||
|
39967a9ba1
|
|||
|
1e4ae4b3d3
|
|||
|
e95ae69070
|
|||
|
c56f26cb87
|
|||
|
6819538d51
|
|||
|
61aa22e05b
|
|||
|
7d7e1774c5
|
|||
|
e0060bd1fe
|
|||
|
b188e5143b
|
|||
|
b5e60dce27
|
|||
|
0e7d253f89
|
|||
|
83d66aa86a
|
3
.env.sample
Normal file
3
.env.sample
Normal file
@ -0,0 +1,3 @@
|
||||
# Project tab specific
|
||||
GITEA_URL=""
|
||||
GITEA_TOKEN=""
|
||||
179
.gitignore
vendored
Normal file
179
.gitignore
vendored
Normal file
@ -0,0 +1,179 @@
|
||||
# ---> Python
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# UV
|
||||
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
#uv.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
|
||||
.pdm.toml
|
||||
.pdm-python
|
||||
.pdm-build/
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
|
||||
# Ruff stuff:
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
|
||||
|
||||
/website.db
|
||||
44
app.py
Normal file
44
app.py
Normal file
@ -0,0 +1,44 @@
|
||||
"""
|
||||
Imports all required blueprints and initalises the app.
|
||||
Runs in debug mode if ran directly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import atexit
|
||||
|
||||
import dotenv
|
||||
import flask
|
||||
|
||||
import db
|
||||
|
||||
from blueprints.projects import projectsBP
|
||||
from blueprints.booper import booperBP
|
||||
from blueprints.static import staticBP
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
dotenv.load_dotenv()
|
||||
|
||||
app = flask.Flask(__name__)
|
||||
db.init()
|
||||
app.register_blueprint(projectsBP)
|
||||
app.register_blueprint(booperBP)
|
||||
app.register_blueprint(staticBP)
|
||||
|
||||
def onClose():
|
||||
"""
|
||||
Safely close all blueprints and then the database.
|
||||
"""
|
||||
|
||||
for bp in app.blueprints.values():
|
||||
# If there is an onClose function to call
|
||||
if callable(getattr(bp, "onClose", None)):
|
||||
bp.onClose()
|
||||
|
||||
db.GLOBAL.close()
|
||||
|
||||
atexit.register(onClose)
|
||||
|
||||
if __name__ == '__main__':
|
||||
print('Running in debug mode')
|
||||
app.run(debug=True)
|
||||
238
blueprints/booper.py
Normal file
238
blueprints/booper.py
Normal file
@ -0,0 +1,238 @@
|
||||
"""
|
||||
This blueprint deals with everything under /api/boop/.
|
||||
|
||||
Used environment variables:
|
||||
BOOPER_TARGETS: A list of comma (`,`) separated names for valid booper targets.
|
||||
These names are stripped to only contain alphanumeric characters.
|
||||
BOOPER_PRECISION: An integer (default 60) to define how precise the booper is.
|
||||
Shorter values are more precise, but take up more space in the database.
|
||||
"""
|
||||
|
||||
from threading import Thread
|
||||
import logging
|
||||
import string
|
||||
import time
|
||||
import os
|
||||
|
||||
import flask
|
||||
|
||||
import db
|
||||
|
||||
_log = logging.getLogger('blueprints.booper')
|
||||
|
||||
def stripName(name: str) -> str:
|
||||
"""
|
||||
Strips a name to only include ascii characters & digits.
|
||||
"""
|
||||
|
||||
return ''.join([c for c in name if c in string.ascii_letters+string.digits])
|
||||
|
||||
class Blueprint(flask.Blueprint):
|
||||
"""
|
||||
Holds all relavent data for this blueprint to function as well as interacting
|
||||
directly with the database to store and load data live.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._currentBoops = {} # Holds all boops in the current precision
|
||||
self._totalBoops = {} # Holds full boop totals
|
||||
self._boops24h = {} # Holds all boops that happened in the past 24h
|
||||
# The format of this is as follows:
|
||||
# {'name': [{'ts': ts, 'count': count}, {...}], ...}
|
||||
self.precision = int(os.getenv('BOOPER_PRECISION', '60'))
|
||||
self._currentTS = 0
|
||||
self._runWorker = True
|
||||
self._workerThread = None
|
||||
|
||||
# Load and filter names from `BOOPER_TARGETS` environment variable.
|
||||
for name in os.getenv('BOOPER_TARGETS', '').split(','):
|
||||
if name == '':
|
||||
continue # Ignore empty names
|
||||
name = stripName(name)
|
||||
self._currentBoops.update({name: 0})
|
||||
self._totalBoops.update({name: 0})
|
||||
|
||||
# Throw a warning if no names are detected
|
||||
if not self._currentBoops:
|
||||
_log.warning('No names are configured. This blueprint will not function.')
|
||||
_log.warning('Please configure BOOPER_TARGETS in your .env file.')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _worker(self):
|
||||
"""
|
||||
This function gets ran as a thread and it handles all time splitting capabilities.
|
||||
"""
|
||||
|
||||
while self._runWorker:
|
||||
# Check if we've past the precision threshold
|
||||
if time.time() - self._currentTS >= self.precision:
|
||||
# Save everything to the database
|
||||
self._save()
|
||||
# Reset variables
|
||||
for name in self._currentBoops:
|
||||
self._currentBoops[name] = 0
|
||||
self._currentTS = round(time.time())
|
||||
# Purge old data from 24h
|
||||
self._boops24h = {name: [data for data in self._boops24h[name]
|
||||
if time.time() - data['ts'] < 86400]
|
||||
for name in self._boops24h}
|
||||
time.sleep(1)
|
||||
|
||||
def _save(self):
|
||||
"""
|
||||
Saves everything to the database.
|
||||
"""
|
||||
|
||||
for name, count in self._currentBoops.items():
|
||||
# Check if this start time exists already
|
||||
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND startTime=?',
|
||||
(name, self._currentTS)).fetchone() is None:
|
||||
# If not, create a new row
|
||||
db.GLOBAL.execute('INSERT INTO booper (name, startTime, count) VALUES (?, ?, ?)',
|
||||
(name, self._currentTS, count))
|
||||
else:
|
||||
# If it does, edit the existing row
|
||||
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND startTime=?',
|
||||
(count, name, self._currentTS))
|
||||
|
||||
# Save the new totals
|
||||
db.GLOBAL.execute('UPDATE booper SET count=? WHERE name=? AND total=1',
|
||||
(self._totalBoops[name], name))
|
||||
|
||||
# Commit everything
|
||||
db.GLOBAL.commit()
|
||||
|
||||
def boop(self, name: str):
|
||||
"""
|
||||
Boops `name`.
|
||||
"""
|
||||
|
||||
self._currentBoops[name] += 1
|
||||
self._totalBoops[name] += 1
|
||||
self._boops24h[name][-1]['count'] += 1
|
||||
|
||||
# Override the `register` function from `flask.Blueprint`
|
||||
# so we can initialise the database at an appropriate time.
|
||||
def register(self, *args, **kwargs):
|
||||
super().register(*args, **kwargs)
|
||||
|
||||
# Initialise the database
|
||||
db.GLOBAL.createTable('booper')
|
||||
db.GLOBAL.createColumn('booper', 'name STRING')
|
||||
db.GLOBAL.createColumn('booper', 'total BOOLEAN DEFAULT false')
|
||||
db.GLOBAL.createColumn('booper', 'startTime INTEGER')
|
||||
db.GLOBAL.createColumn('booper', 'count INTEGER DEFAULT 0')
|
||||
|
||||
for name in self._currentBoops:
|
||||
# Get the latest data for this name
|
||||
latestData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=?'+
|
||||
'ORDER BY id DESC LIMIT 1', (name,)).fetchone()
|
||||
# If there is data,
|
||||
if latestData is not None:
|
||||
# Set the current time to the latest time known
|
||||
startTime = latestData['startTime']
|
||||
if startTime is not None and startTime > self._currentTS:
|
||||
self._currentTS = startTime
|
||||
# Update the current boop counter
|
||||
self._currentBoops[name] = latestData['count']
|
||||
# Otherwise,
|
||||
else:
|
||||
# Set the current timestamp to now
|
||||
self._currentTS = round(time.time())
|
||||
|
||||
# Check for total row
|
||||
if db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1',
|
||||
(name,)).fetchone() is None:
|
||||
# If none is found, create one
|
||||
_log.debug('Creating total counter for "%s"...', name)
|
||||
db.GLOBAL.addRow('booper',
|
||||
{'name': name, 'startTime': round(time.time()), 'total': True})
|
||||
self._totalBoops[name] = 0
|
||||
else:
|
||||
# If one is found, load the data and set the totals
|
||||
totalData = db.GLOBAL.execute('SELECT * FROM booper WHERE name=? AND total=1 ',
|
||||
(name,)).fetchone()
|
||||
self._totalBoops[name] = totalData['count']
|
||||
|
||||
# Initialise 24h counts with registered names
|
||||
self._boops24h = {name: [] for name in self.registeredNames}
|
||||
|
||||
# Load all boops from the past 24h
|
||||
boops24h = db.GLOBAL.execute('SELECT * FROM booper WHERE total=0 AND startTime>=?',
|
||||
(time.time()-86400,)).fetchall()
|
||||
for row in boops24h:
|
||||
name = row['name']
|
||||
count = row['count']
|
||||
ts = row['startTime']
|
||||
|
||||
self._boops24h[name].append({'ts': ts, 'count': count})
|
||||
|
||||
# Fix empty recents
|
||||
for name in self._boops24h:
|
||||
if self._boops24h[name] == []:
|
||||
self._boops24h[name].append({'ts': round(time.time()), 'count': 0})
|
||||
|
||||
self._workerThread = Thread(target=self._worker)
|
||||
self._workerThread.start()
|
||||
|
||||
def getTotalBoops(self, name: str) -> int:
|
||||
"""
|
||||
Returns the total number of boops the user has recieved.
|
||||
"""
|
||||
|
||||
if name not in self.registeredNames:
|
||||
raise ValueError(f'Name "{name}" is not registered.')
|
||||
|
||||
return self._totalBoops[name]
|
||||
|
||||
def get24hBoops(self, name) -> int:
|
||||
"""
|
||||
Calculates how many boops `name` has received in the past 24h.
|
||||
"""
|
||||
|
||||
if name not in self.registeredNames:
|
||||
raise ValueError(f'Name "{name}" is not registered.')
|
||||
|
||||
return sum(data['count'] for data in self._boops24h[name])
|
||||
|
||||
def onClose(self):
|
||||
"""
|
||||
Safely saves and closes the booper.
|
||||
Automatically ran by the backend.
|
||||
"""
|
||||
|
||||
_log.info('Stopping worker...')
|
||||
self._runWorker = False
|
||||
self._workerThread.join()
|
||||
_log.info('Saving to database...')
|
||||
self._save()
|
||||
_log.info('Booper closed successfully.')
|
||||
|
||||
@property
|
||||
def registeredNames(self) -> list[str]:
|
||||
"""
|
||||
Returns a list of all registered names.
|
||||
"""
|
||||
|
||||
return list(self._currentBoops.keys())
|
||||
|
||||
booperBP = Blueprint('booperAPI', __name__)
|
||||
|
||||
@booperBP.route('/api/boop/<name>', methods=['GET', 'POST'], strict_slashes=False)
|
||||
def boops(name: str):
|
||||
"""
|
||||
Handles both GETting and POSTing boops through the boop API endpoint.
|
||||
"""
|
||||
|
||||
name = stripName(name)
|
||||
|
||||
if name not in booperBP.registeredNames:
|
||||
return {'success': False, 'message': f'Name "{name}" is not configured as a target.'}, 404
|
||||
|
||||
if flask.request.method == 'POST':
|
||||
booperBP.boop(name)
|
||||
|
||||
return {'success': True,
|
||||
'total': booperBP.getTotalBoops(name),
|
||||
'24h': booperBP.get24hBoops(name)}
|
||||
131
blueprints/projects.py
Normal file
131
blueprints/projects.py
Normal file
@ -0,0 +1,131 @@
|
||||
"""
|
||||
This blueprint deals with everything under /api/projects/.
|
||||
|
||||
Used environment variables:
|
||||
GITEA_URL: The URL (either intenral or external) to the Gitea instance to pull data from.
|
||||
GITEA_TOKEN: The API token for the crawler user. Only requires repository.read.
|
||||
"""
|
||||
|
||||
from urllib.parse import urlencode, urlparse, urlunparse, ParseResult
|
||||
from io import BytesIO
|
||||
import logging
|
||||
import string
|
||||
import os
|
||||
|
||||
import requests
|
||||
import flask
|
||||
import magic
|
||||
|
||||
log = logging.getLogger('blueprints.projects')
|
||||
|
||||
class Blueprint(flask.Blueprint):
|
||||
"""
|
||||
Holds the required data for this blueprint to work.
|
||||
Automatically gets the UID for the logged in user,
|
||||
and gives access to a simple constructURL() function
|
||||
to make API calls without specifying the token each time.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
self._baseURL = urlparse(os.getenv('GITEA_URL'))
|
||||
self._apiKey = os.getenv('GITEA_TOKEN')
|
||||
self.giteaUID = None
|
||||
|
||||
try:
|
||||
r = requests.get(self.constructURL('/api/v1/user'), timeout=5)
|
||||
|
||||
if r.ok:
|
||||
self.giteaUID = r.json().get('id')
|
||||
else:
|
||||
log.error('Cannot get Gitea user ID. This blueprint will not work.')
|
||||
except requests.exceptions.ConnectionError:
|
||||
log.error('Failed to connect to Gitea server. This blueprint will not work.')
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def constructURL(self, apiPath: str, query: dict=None) -> str:
|
||||
"""
|
||||
Construcs an API url from the path and queries.
|
||||
"""
|
||||
|
||||
if query is None:
|
||||
query = {}
|
||||
|
||||
query.update({'token': self._apiKey})
|
||||
|
||||
newURL = ParseResult(scheme=self._baseURL.scheme, netloc=self._baseURL.netloc,
|
||||
path=apiPath, params='', query=urlencode(query), fragment='')
|
||||
return urlunparse(newURL)
|
||||
|
||||
projectsBP = Blueprint('projectsAPI', __name__)
|
||||
|
||||
def generateStrippedProjectData(data: dict) -> dict:
|
||||
"""
|
||||
Generates the stripped down project data that the front-end expects.
|
||||
"""
|
||||
|
||||
# ----- Languages -----
|
||||
# Get the languages path and request it
|
||||
path = urlparse(data['languages_url']).path
|
||||
r = requests.get(projectsBP.constructURL(path), timeout=5)
|
||||
languages = r.json()
|
||||
# Convert all languages from bytes to percentages
|
||||
languages = {lang: size / sum(languages.values()) for lang, size in languages.items()}
|
||||
|
||||
# ----- Image -----
|
||||
if not data.get('avatar_url', '') == '':
|
||||
# Get the avatar URL
|
||||
avatarUrl = urlparse(data['avatar_url'])
|
||||
# Parse the URL to get just the hash
|
||||
imgHash = avatarUrl.path.split('/')[-1:][0]
|
||||
# Create an API path for that image
|
||||
imgPath = f'/api/projects/img/{imgHash}'
|
||||
else:
|
||||
imgPath = None
|
||||
|
||||
# ----- Git URL -----
|
||||
if not data.get('private'):
|
||||
gitUrl = data.get('html_url')
|
||||
else:
|
||||
gitUrl = None
|
||||
|
||||
newData = {}
|
||||
newData.update({'name': data.get('name')})
|
||||
newData.update({'desc': data.get('description')})
|
||||
newData.update({'issues': data.get('open_issues_count')})
|
||||
newData.update({'langs': languages})
|
||||
newData.update({'gitUrl': gitUrl})
|
||||
newData.update({'imgPath': imgPath})
|
||||
return newData
|
||||
|
||||
@projectsBP.route('/api/projects', strict_slashes=False)
|
||||
def projectsList():
|
||||
"""
|
||||
Returns a filtered list of Gitea projects.
|
||||
"""
|
||||
|
||||
# Request for all Gitea projects that the crawler account is a contributor in
|
||||
r = requests.get(projectsBP.constructURL('/api/v1/repos/search',
|
||||
{'exclusive': False, 'uid': projectsBP.giteaUID}), timeout=5)
|
||||
if r.ok:
|
||||
# Filter down every repo with generateStrippedProjectData()
|
||||
projects = [generateStrippedProjectData(data) for data in r.json().get('data')]
|
||||
return projects
|
||||
else:
|
||||
flask.abort(500)
|
||||
|
||||
@projectsBP.route('/api/projects/img/<imgHash>', strict_slashes=False)
|
||||
def img(imgHash: str):
|
||||
"""
|
||||
Forwards images from Gitea's /repo-avatars.
|
||||
"""
|
||||
|
||||
# Filter the hash
|
||||
imgHash = ''.join([c for c in imgHash if c in string.ascii_lowercase+string.digits])
|
||||
# Make the request the Gitea
|
||||
r = requests.get(projectsBP.constructURL(f'/repo-avatars/{imgHash}'), timeout=5)
|
||||
# Get the MIME type
|
||||
mime = magic.from_buffer(BytesIO(r.content).read(128), mime=True)
|
||||
# Send the image
|
||||
content = BytesIO(r.content)
|
||||
return flask.send_file(content, mime)
|
||||
26
blueprints/static.py
Normal file
26
blueprints/static.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""
|
||||
Holds the blueprint for serving the static folder.
|
||||
THIS WILL ONLY BE FUNCTIONAL IN DEBUG MODE, as Nginx should serve the static folder in production.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import flask
|
||||
|
||||
staticBP = flask.Blueprint('staticPage', __name__, static_folder='../www/')
|
||||
|
||||
@staticBP.route('/', defaults={'path': 'index.html'})
|
||||
@staticBP.route('/<path:path>')
|
||||
def staticPage(path):
|
||||
"""
|
||||
Returns anything requested in the static folder.
|
||||
"""
|
||||
|
||||
if not flask.current_app.debug:
|
||||
# Return forbidden code if trying to access static files in production mode.
|
||||
flask.abort(403)
|
||||
|
||||
if os.path.isfile(os.path.join(staticBP.static_folder, path, 'index.html')):
|
||||
path = os.path.join(path, 'index.html')
|
||||
|
||||
return flask.send_from_directory(staticBP.static_folder, path)
|
||||
134
db.py
Normal file
134
db.py
Normal file
@ -0,0 +1,134 @@
|
||||
"""
|
||||
|
||||
Used environment variables:
|
||||
DB_PATH: The location of the SQLite file. `website.db` is always appended to the end.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import sqlite3
|
||||
import logging
|
||||
import atexit
|
||||
import os
|
||||
|
||||
# pylint: disable=locally-disabled, global-statement
|
||||
|
||||
class DB:
|
||||
"""
|
||||
Basically just a thread-safe version of sqlite3.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._log = logging.getLogger('DB')
|
||||
self._dbFile = os.path.join(os.getenv('DB_PATH', './'), 'website.db')
|
||||
self._conn = sqlite3.connect(self._dbFile, check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
self._connLock = threading.Lock()
|
||||
|
||||
atexit.register(self.close)
|
||||
self._log.info('Database ready.')
|
||||
|
||||
def execute(self, command: str, args: list[any]=None) -> sqlite3.Cursor:
|
||||
"""
|
||||
Executes a SQL command directly.
|
||||
"""
|
||||
|
||||
args = [] if args is None else args
|
||||
|
||||
with self._connLock:
|
||||
return self._conn.execute(command, args)
|
||||
|
||||
def commit(self):
|
||||
"""
|
||||
Commits any uncommited changes.
|
||||
"""
|
||||
|
||||
with self._connLock:
|
||||
self._conn.commit()
|
||||
|
||||
def createTable(self, tableName: str):
|
||||
"""
|
||||
Creates a table in the database with a default ID column.
|
||||
|
||||
**WARNING: `tableName` is assumed to be safe. DO NOT PASS USER INPUT TO THIS VARIABLE.**
|
||||
|
||||
Args:
|
||||
tableName (str): The name of the table to create.
|
||||
"""
|
||||
|
||||
self.execute(f'CREATE TABLE IF NOT EXISTS {tableName}'+
|
||||
'(id INTEGER PRIMARY KEY AUTOINCREMENT)')
|
||||
|
||||
def createColumn(self, tableName: str, columnDef: str):
|
||||
"""
|
||||
Creates a column in the specified table if it doesn't already exist.
|
||||
|
||||
**WARNING: This function call is intended to be INTERNAL ONLY.**
|
||||
**NO USER INPUT SHOULD EVER BE PASSED TO THIS FUNCTION.**
|
||||
|
||||
Args:
|
||||
tableName (str): The name of the table to alter.
|
||||
columnDef (str): The definition of the column. For example 'name STRING DEFAULT NULL'.
|
||||
"""
|
||||
|
||||
columnName = columnDef.split(' ')[0]
|
||||
res = self.execute(f'SELECT 1 FROM pragma_table_info(\'{tableName}\')'+
|
||||
f'WHERE name=\'{columnName}\'')
|
||||
columnExempt = res.fetchone() is None
|
||||
if columnExempt:
|
||||
self.execute(f'ALTER TABLE {tableName} ADD COLUMN {columnDef}')
|
||||
self.commit()
|
||||
|
||||
def addRow(self, table: str, data: dict):
|
||||
"""
|
||||
Adds a row into the database.
|
||||
|
||||
**WARNING: The arguments `table` and all keys of `data` are assumed to be internal.**
|
||||
**ONLY THE VALUES OF `data` CAN BE USER INPUT. ANYTHING ELSE IS UNSAFE.**
|
||||
|
||||
Args:
|
||||
table (str): The table to insert into.
|
||||
data (dict): The keys and values to insert.
|
||||
**The keys are internal only and should NEVER contain user input.**
|
||||
"""
|
||||
|
||||
self.execute(f'INSERT INTO {table} ({",".join(data.keys())}) '+
|
||||
f'VALUES ({",".join("?"*len(data))})', list(data.values()))
|
||||
self.commit()
|
||||
|
||||
def updateColumns(self, table: str, column: str, value: any,
|
||||
selectorColumn: str, selectorValue: any):
|
||||
"""
|
||||
Allows for altering cells based on the slelector.
|
||||
"""
|
||||
|
||||
self.execute(f'ALTER TABLE {table} SET ?=? WHERE ?=?',
|
||||
(column, value, selectorColumn, selectorValue))
|
||||
self.commit()
|
||||
|
||||
def getRow(self, table: str, whereKey: str, whereVal: any) -> dict:
|
||||
"""
|
||||
Gets a row from the database where `whereKey` == `whereVal`.
|
||||
"""
|
||||
|
||||
res = self.execute(f'SELECT * FROM {table} WHERE {whereKey}=?', (whereVal,))
|
||||
return res.fetchone()
|
||||
|
||||
def close(self):
|
||||
"""
|
||||
Safely closes the database connection.
|
||||
"""
|
||||
|
||||
self._log.info('Shutting down database...')
|
||||
self._conn.close()
|
||||
|
||||
GLOBAL: DB = None
|
||||
|
||||
def init(*args, **kwargs) -> DB:
|
||||
"""
|
||||
Initialises the global database.
|
||||
"""
|
||||
|
||||
global GLOBAL
|
||||
|
||||
GLOBAL = DB(*args, **kwargs)
|
||||
return GLOBAL
|
||||
24
index.html
24
index.html
@ -1,24 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/main.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header><div>
|
||||
</div></header>
|
||||
<main>
|
||||
<h1 id="helloText"><noscript>Hewwo!</noscript></h1>
|
||||
</main>
|
||||
<footer>
|
||||
<!-- Left side -->
|
||||
<div>
|
||||
</div>
|
||||
<!-- Right side -->
|
||||
<div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
11
main.js
11
main.js
@ -1,11 +0,0 @@
|
||||
const Maths = Math; // Yes, I'm that patriotic.
|
||||
const hellos = ['Hewwo!', 'Hello!', 'Awoo!'];
|
||||
|
||||
function pickRandom(array) {
|
||||
return array[Maths.floor(Maths.random() * array.length)];
|
||||
}
|
||||
|
||||
window.addEventListener('load', (e) => {
|
||||
const helloText = document.getElementById('helloText');
|
||||
helloText.innerText = pickRandom(hellos);
|
||||
});
|
||||
4
requirements.txt
Normal file
4
requirements.txt
Normal file
@ -0,0 +1,4 @@
|
||||
python-magic
|
||||
requests
|
||||
dotenv
|
||||
Flask
|
||||
51
styles.css
51
styles.css
@ -1,51 +0,0 @@
|
||||
:root {
|
||||
--header-size: 100px;
|
||||
}
|
||||
|
||||
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #FFFFFF;
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
}
|
||||
|
||||
|
||||
|
||||
header main footer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
/* Required to set the width and height of the header, as `fixed` elements can't have their size set. */
|
||||
header > div {
|
||||
width: 100vw;
|
||||
height: var(--header-size);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
margin-top: var(--header-size);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
footer > div {
|
||||
width: 50%;
|
||||
}
|
||||
59
www/booper.js
Normal file
59
www/booper.js
Normal file
@ -0,0 +1,59 @@
|
||||
var booper = {
|
||||
overlayEl: undefined,
|
||||
overlayName: undefined,
|
||||
overlayTotal: undefined,
|
||||
overlay24h: undefined,
|
||||
targets: undefined,
|
||||
};
|
||||
|
||||
let currentTimeout = 0;
|
||||
|
||||
let onImageTouch = function(target, relX, relY, pageX, pageY) {
|
||||
for (const [name, box] of Object.entries(target.locators)) {
|
||||
const [x1, y1, x2, y2] = box;
|
||||
if (relX >= x1 && relX <= x2 && relY >= y1 && relY <= y2) {
|
||||
console.log(`[booper.js] Booped ${name}!`);
|
||||
fetch(`/api/boop/${name}`, {method: 'POST'}).then((r) => {
|
||||
if (r.ok) {
|
||||
r.json().then((json) => {
|
||||
clearTimeout(currentTimeout);
|
||||
booper.overlayName.innerText = name[0].toUpperCase()+name.slice(1);
|
||||
booper.overlayTotal.innerText = json['total'];
|
||||
booper.overlay24h.innerText = json['24h'];
|
||||
booper.overlayEl.style.left = `${pageX}px`;
|
||||
booper.overlayEl.style.top = `${pageY}px`;
|
||||
booper.overlayEl.style.opacity = 1;
|
||||
booper.currentTimeout = setTimeout(() => {
|
||||
booper.overlayEl.style.opacity = 0;
|
||||
}, 2000);
|
||||
console.debug(json);
|
||||
});
|
||||
} else {
|
||||
console.error(`[booper.js] Error whilst booping:`);
|
||||
console.error(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', (e) => {
|
||||
booper.overlayEl = document.getElementById('booperOverlay');
|
||||
booper.overlayName = document.getElementById('booperName');
|
||||
booper.overlayTotal = document.getElementById('booperTotal');
|
||||
booper.overlay24h = document.getElementById('booper24h');
|
||||
booper.targets = document.getElementsByClassName('booperTarget');
|
||||
|
||||
for (let i = 0; i < booper.targets.length; i++) {
|
||||
const target = booper.targets[i];
|
||||
const locators = JSON.parse(target.getAttribute('data-booper'));
|
||||
target.locators = locators;
|
||||
target.addEventListener('click', (e) => {
|
||||
let x = e.offsetX / e.originalTarget.clientWidth;
|
||||
let y = e.offsetY / e.originalTarget.clientHeight;
|
||||
onImageTouch(target, x, y, e.pageX, e.pageY);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[booper.js] Initialised ${booper.targets.length} boop target(s)!`);
|
||||
});
|
||||
BIN
www/fag.png
Normal file
BIN
www/fag.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
1
www/icons/glass-water.svg
Normal file
1
www/icons/glass-water.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-glass-water-icon lucide-glass-water"><path d="M5.116 4.104A1 1 0 0 1 6.11 3h11.78a1 1 0 0 1 .994 1.105L17.19 20.21A2 2 0 0 1 15.2 22H8.8a2 2 0 0 1-2-1.79z"/><path d="M6 12a5 5 0 0 1 6 0 5 5 0 0 0 6 0"/></svg>
|
||||
|
After Width: | Height: | Size: 410 B |
1
www/icons/house.svg
Normal file
1
www/icons/house.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-house-icon lucide-house"><path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-6a2 2 0 0 1 2.582 0l7 6A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/></svg>
|
||||
|
After Width: | Height: | Size: 401 B |
1
www/icons/image.svg
Normal file
1
www/icons/image.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image-icon lucide-image"><rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/></svg>
|
||||
|
After Width: | Height: | Size: 371 B |
1
www/icons/spanner.svg
Normal file
1
www/icons/spanner.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-wrench-icon lucide-wrench"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.106-3.105c.32-.322.863-.22.983.218a6 6 0 0 1-8.259 7.057l-7.91 7.91a1 1 0 0 1-2.999-3l7.91-7.91a6 6 0 0 1 7.057-8.259c.438.12.54.662.219.984z"/></svg>
|
||||
|
After Width: | Height: | Size: 440 B |
71
www/index.html
Normal file
71
www/index.html
Normal file
@ -0,0 +1,71 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<!--
|
||||
Hello fellow nerd!
|
||||
-->
|
||||
|
||||
<head>
|
||||
<title></title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
<script src="/main.js"></script>
|
||||
<script src="/booper.js"></script>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header><div>
|
||||
<button id="button-home" onclick="switchTab(this);"><img src="/icons/house.svg"> Home</button>
|
||||
<button id="button-pics" onclick="switchTab(this);"><img src="/icons/image.svg"> Pictures</button>
|
||||
<button id="button-projects" onclick="switchTab(this);"><img src="/icons/spanner.svg"> Cool things I've done</button>
|
||||
<button id="button-drinking" onclick="switchTab(this);"><img src="/icons/glass-water.svg"> Drinking</button>
|
||||
</div></header>
|
||||
|
||||
<main>
|
||||
<article id="tab-home" default>
|
||||
<h1 id="helloText"><noscript>Hewwo!</noscript></h1>
|
||||
<h2><span id="imaText"><noscript>I'm a <span class="textHighlight">dumbass</span></noscript></span>.</h2>
|
||||
<div class="mainContent">
|
||||
<img class="booperTarget" data-booper='{"brosef": [0.4752,0.3438,0.5773,0.3907]}' src="/fag.png">
|
||||
<div>
|
||||
<h3>Hi there! I'm <b>Brosef</b>, as you can see by the <i>very fancy</i> changing text about this, I'm interested in a lot of things!</h3>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article id="tab-pics">
|
||||
|
||||
</article>
|
||||
|
||||
<article id="tab-projects">
|
||||
|
||||
</article>
|
||||
|
||||
<article id="tab-drinking">
|
||||
<h1>Take a look at this alcoholic!</h1>
|
||||
</article>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<!-- Left side -->
|
||||
<div>
|
||||
<p>Icons from <a href="https://lucide.dev/">Lucide</a>.</p>
|
||||
</div>
|
||||
<!-- Right side -->
|
||||
<div>
|
||||
<div style="height: 50px;" class="imgRightText">
|
||||
<img src="/pfp.png">
|
||||
<p>Brosef</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div id="booperOverlay">
|
||||
<div>
|
||||
<h4>Booped <span id="booperName"></span>!</h4>
|
||||
<p>Total boops: <span id="booperTotal"></span></p>
|
||||
<p>Boops in 24h: <span id="booper24h"></span></p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
174
www/main.js
Normal file
174
www/main.js
Normal file
@ -0,0 +1,174 @@
|
||||
const Maths = Math; // Yes, I'm that patriotic.
|
||||
const hellos = ['Hewwo!', 'Hello!', 'Awoo!'];
|
||||
|
||||
const imaTypingSpeed = 75;
|
||||
const imaDeletingSpeed = 50;
|
||||
const imaElipsisDelay = 750;
|
||||
const imaGapTime = 1500;
|
||||
const imaTexts = shuffle([
|
||||
'I\'m a $dumbass$.dog',
|
||||
'I\'m a $network engineer',
|
||||
'I\'m an $Arch Linux user',
|
||||
'I\'m a $programmer',
|
||||
'I\'m a $furry...$ duh',
|
||||
'I\'m a $massive nerd',
|
||||
'I\'m a $Home Assistant addict',
|
||||
'I\'m a $House M.D. enjoyer',
|
||||
'I\'m a $DJ',
|
||||
'I\'m an $ice skater',
|
||||
'I\'m a $masochist',
|
||||
'I\'m a $professional alcoholic',
|
||||
'I\'m a $Vim addict',
|
||||
'I\'m an $accidental UK shock collar provider',
|
||||
'I play $Beat Saber',
|
||||
'I play $VRChat',
|
||||
'I\'m a $Blender user'
|
||||
]);
|
||||
|
||||
var currentTab = null;
|
||||
|
||||
function shuffle(array) {
|
||||
return array
|
||||
.map(value => ({ value, sort: Maths.random() }))
|
||||
.sort((a, b) => a.sort - b.sort)
|
||||
.map(({ value }) => value);
|
||||
}
|
||||
|
||||
function pickRandom(array) {
|
||||
return array[Maths.floor(Maths.random() * array.length)];
|
||||
}
|
||||
|
||||
function switchTab(tabID, playAnimation=true) {
|
||||
// Get tab ID from button
|
||||
if (typeof(tabID) == 'object') {
|
||||
tabID = tabID.id.split('button-')[1];
|
||||
}
|
||||
|
||||
if (currentTab == tabID) { return; }
|
||||
|
||||
console.log(`Switching from ${currentTab} to ${tabID}...`);
|
||||
currentTab = tabID;
|
||||
location.hash = `#${tabID}`;
|
||||
|
||||
let tabTransitionTime = Number(getComputedStyle(document.body).getPropertyValue('--tab-transition-time').slice(0, -2));
|
||||
let tabs = document.getElementsByTagName('main')[0].children;
|
||||
let tab = document.getElementById(`tab-${tabID}`);
|
||||
let buttons = document.getElementsByTagName('header')[0].getElementsByTagName('button');
|
||||
let button = document.getElementById(`button-${tabID}`)
|
||||
|
||||
// Error checking
|
||||
if (tab == undefined) {
|
||||
console.error(`Error switching tab: Tab ID "${tabID}" not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hide all tabs
|
||||
for (let i = 0; i < tabs.length; i++) {
|
||||
if (tabs[i] == tab) { continue; }
|
||||
tabs[i].classList.add('hiddenTab');
|
||||
if (playAnimation) {
|
||||
setTimeout(() => { tabs[i].style.display = 'none'; }, tabTransitionTime);
|
||||
} else {
|
||||
tabs[i].style.display = 'none';
|
||||
}
|
||||
|
||||
if (button != null) {
|
||||
button.classList.remove('buttonHighlight');
|
||||
}
|
||||
}
|
||||
|
||||
// Un-highlight all buttons
|
||||
for (let i = 0; i < buttons.length; i++) {
|
||||
buttons[i].classList.remove('buttonHighlight');
|
||||
}
|
||||
|
||||
// Show selected tab
|
||||
if (playAnimation) {
|
||||
setTimeout(() => { tab.style.display = null; }, tabTransitionTime);
|
||||
} else {
|
||||
tab.style.display = null;
|
||||
}
|
||||
// Required for animation to play
|
||||
let intervalID = setInterval(() => {
|
||||
if (tab.checkVisibility()) {
|
||||
tab.classList.remove('hiddenTab');
|
||||
clearInterval(intervalID);
|
||||
}
|
||||
}, 10);
|
||||
|
||||
// Highlight button
|
||||
button.classList.add('buttonHighlight');
|
||||
}
|
||||
|
||||
window.addEventListener('hashchange', (e) => {
|
||||
switchTab(location.hash.slice(1));
|
||||
});
|
||||
|
||||
window.addEventListener('load', (e) => {
|
||||
switchTab('home', false);
|
||||
|
||||
// ----- Hellos section -----
|
||||
const helloEl = document.getElementById('helloText');
|
||||
helloEl.innerText = pickRandom(hellos);
|
||||
|
||||
// ----- I'm a text section -----
|
||||
const imaEl = document.getElementById('imaText');
|
||||
let currentEl = document.createElement('span');
|
||||
imaEl.appendChild(currentEl);
|
||||
let imaSelection = 0;
|
||||
let imaCharIdx = 0;
|
||||
|
||||
const typeFunc = () => {
|
||||
let controlChar = imaTexts[imaSelection][imaCharIdx] == '$';
|
||||
if (controlChar) {
|
||||
let highlight = !currentEl.classList.contains('textHighlight');
|
||||
currentEl = document.createElement('span');
|
||||
if (highlight) {
|
||||
currentEl.classList.add('textHighlight');
|
||||
}
|
||||
imaEl.appendChild(currentEl);
|
||||
} else {
|
||||
currentEl.innerText += imaTexts[imaSelection][imaCharIdx];
|
||||
}
|
||||
imaCharIdx++;
|
||||
|
||||
let nextTime = imaTypingSpeed;
|
||||
|
||||
if (imaTexts[imaSelection].slice(imaCharIdx, imaCharIdx+3) == '...') {
|
||||
nextTime = imaElipsisDelay;
|
||||
}
|
||||
|
||||
nextTime *= !controlChar;
|
||||
|
||||
if (imaCharIdx >= imaTexts[imaSelection].length) {
|
||||
setTimeout(deleteFunc, imaGapTime);
|
||||
} else {
|
||||
setTimeout(typeFunc, nextTime);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteFunc = () => {
|
||||
// This is honestly quite disgusting, but it does work...
|
||||
let textAttrib = imaEl.lastChild.nodeName == '#text' ? 'data' : 'innerText';
|
||||
imaEl.lastChild.innerText = imaEl.lastChild.innerText.slice(0, -1);
|
||||
if (imaEl.lastChild.innerText == '') {
|
||||
imaEl.lastChild.remove();
|
||||
}
|
||||
|
||||
if (imaEl.innerText == '') {
|
||||
currentEl = imaEl;
|
||||
imaCharIdx = 0;
|
||||
imaSelection += 1;
|
||||
if (imaSelection >= imaTexts.length) {
|
||||
imaSelection = 0;
|
||||
}
|
||||
currentEl = document.createElement('span');
|
||||
imaEl.appendChild(currentEl);
|
||||
setTimeout(typeFunc, imaTypingSpeed);
|
||||
} else {
|
||||
setTimeout(deleteFunc, imaDeletingSpeed);
|
||||
}
|
||||
};
|
||||
|
||||
typeFunc();
|
||||
});
|
||||
BIN
www/pfp.png
Normal file
BIN
www/pfp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 74 KiB |
193
www/styles.css
Normal file
193
www/styles.css
Normal file
@ -0,0 +1,193 @@
|
||||
:root {
|
||||
--header-colour: #00000031;
|
||||
--main-colour: #393e41;
|
||||
--footer-colour: #00000031;
|
||||
--text-colour: #f6f7eb;
|
||||
--text-accent-colour: #e94f37;
|
||||
--button-border-colour: var(--text-accent-colour);
|
||||
--button-border-highlight-colour: #f7b3a9;
|
||||
|
||||
--header-size: 50px;
|
||||
--tab-transition-time: 250ms; /* Must be in ms for JS parsing */
|
||||
}
|
||||
|
||||
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-colour);
|
||||
font-family: sans-serif;
|
||||
display: flex;
|
||||
flex-flow: column;
|
||||
background-color: var(--main-colour);
|
||||
}
|
||||
|
||||
|
||||
|
||||
header, main, footer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
background-color: var(--header-colour);
|
||||
}
|
||||
|
||||
/* Required to set the width and height of the header, as `fixed` elements can't have their size set. */
|
||||
header > div {
|
||||
width: 100vw;
|
||||
height: var(--header-size);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
header > div > * {
|
||||
height: var(--header-size);
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1 1 auto;
|
||||
margin-top: var(--header-size);
|
||||
text-align: center;
|
||||
background-color: var(--main-colour);
|
||||
}
|
||||
|
||||
main > article {
|
||||
display: unset;
|
||||
transition: filter var(--tab-transition-time), opacity var(--tab-transition-time);
|
||||
}
|
||||
|
||||
.hiddenTab {
|
||||
filter: blur(10px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
footer {
|
||||
flex: 0 1 auto;
|
||||
display: flex;
|
||||
padding: 20px 0 20px 0;
|
||||
background-color: var(--footer-colour);
|
||||
}
|
||||
|
||||
footer > div {
|
||||
width: 50%;
|
||||
height: fit-content;
|
||||
margin: auto 0 auto 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
footer > div > * {
|
||||
margin: 0 auto 0 auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.mainContent {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin: 10px;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mainContent > img {
|
||||
max-height: 75vh;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mainContent > div {
|
||||
flex: 1 1 auto;
|
||||
min-width: 25em;
|
||||
width: min-content;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#tab-pics {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
#tab-pics > * {
|
||||
margin: 10px;
|
||||
flex: 1 1 auto;
|
||||
max-width: 50vw;
|
||||
max-height: 50vh;
|
||||
object-fit: scale-down;
|
||||
}
|
||||
|
||||
|
||||
|
||||
button {
|
||||
display: inline-flex;
|
||||
padding: 5px;
|
||||
border: none;
|
||||
border-bottom: 2px solid var(--text-accent-colour);
|
||||
background-color: #212425;
|
||||
color: var(--text-colour);
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: border-bottom-color 0.5s;
|
||||
}
|
||||
|
||||
button > img {
|
||||
filter: invert();
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
button:hover, .buttonHighlight {
|
||||
border-bottom-color: var(--button-border-highlight-colour);
|
||||
}
|
||||
|
||||
|
||||
|
||||
.textHighlight {
|
||||
color: var(--text-accent-colour);
|
||||
}
|
||||
|
||||
/* Used to make text appear to the right of an image */
|
||||
.imgRightText {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.imgRightText > img {
|
||||
height: 100%;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.imgRightText > p {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
|
||||
|
||||
#booperOverlay {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0%;
|
||||
transition: opacity 0.5s;
|
||||
}
|
||||
|
||||
#booperOverlay > div {
|
||||
background-color: #393e41A0;
|
||||
position: absolute;
|
||||
width: max-content;
|
||||
bottom: 0px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
#booperOverlay > div > * {
|
||||
margin: 0.5em 1em;
|
||||
}
|
||||
Reference in New Issue
Block a user