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