Compare commits

...

39 Commits

Author SHA1 Message Date
a607318eff Added URL fragment support for tabs 2026-06-12 05:44:43 +01:00
99131cea6f Addeded to "I'm a" text 2026-06-12 05:44:09 +01:00
615716d72e Fixed styling error in newest "I'm a" text 2026-06-10 07:55:58 +01:00
dd5d400282 Appended to "I'm a" text 2026-06-10 07:53:44 +01:00
3ff43e9e1b Added project variables to example environment 2026-06-10 07:52:38 +01:00
82347e4b78 Added connection check to projects blueprint 2026-06-10 07:52:04 +01:00
322c09f10a Changed booper.js layout 2026-04-19 13:10:05 +01:00
c0f6765a06 Implemented first version of UI for #4 2026-03-11 14:45:50 +00:00
88999dafd5 Updated typehints 2026-03-07 18:47:39 +00:00
38aae30892 Fixed bug with 24h counter failing on new db 2026-03-07 18:45:57 +00:00
ccfa2bd48c Implemented JS for #4 2026-03-07 18:21:20 +00:00
b07f3781fa Renamed backend.py to app.py 2026-03-07 17:48:58 +00:00
9f8b5d3e59 Removed debug lines 2026-03-07 17:48:25 +00:00
21c78ccce5 Implemented backend for #4 2026-03-07 17:45:42 +00:00
fa94974239 Implemented db to backend code 2026-03-07 17:27:10 +00:00
26c25122cc Added database 2026-03-06 18:53:23 +00:00
1e1a54217a Implemented first version of back-end for #1 2026-02-28 13:13:19 +00:00
6f372c44e1 Fixed bug introduced in aa1ceb7739 2026-02-28 13:12:28 +00:00
49352cd0b2 Added .env loading 2026-02-23 13:49:09 +00:00
aa1ceb7739 Moved blueprints to /blueprints/ 2026-02-23 13:35:24 +00:00
52db1c3d9b Added static backend 2026-02-23 13:33:21 +00:00
a162ba6841 Added Python .gitignore 2026-02-18 19:45:32 +00:00
ec97367b78 Moved website data to /www/ 2026-02-18 19:38:46 +00:00
39e1090b91 Added drinking tab 2026-02-18 19:38:00 +00:00
1f1f2f6a9e Made the mobile version look better... somehow... 2026-02-18 19:35:27 +00:00
39967a9ba1 Added elipsis support to I'm a text 2026-02-18 19:35:12 +00:00
1e4ae4b3d3 Added basic bio 2026-02-18 19:30:19 +00:00
e95ae69070 Modified "I'm a" text 2026-02-08 14:56:16 +00:00
c56f26cb87 Implemented first version of pictures tab 2026-02-08 14:55:35 +00:00
6819538d51 Fixed CSS typo 2026-02-08 01:56:42 +00:00
61aa22e05b Made tab transition time more dynamic 2026-02-08 01:41:09 +00:00
7d7e1774c5 Appended to "I'm a" text 2026-02-08 01:33:08 +00:00
e0060bd1fe Added tabs 2026-02-08 01:32:24 +00:00
b188e5143b Adjusted "I'm a" text functionality 2026-02-07 23:43:08 +00:00
b5e60dce27 Added content to footer 2026-02-07 23:41:39 +00:00
0e7d253f89 Added colour variables 2026-02-07 23:40:17 +00:00
83d66aa86a Added "I'm a" text 2026-02-07 20:43:15 +00:00
78fa0a4fc9 Added "hello" text 2026-02-07 20:11:01 +00:00
3047d577c2 Added pick random function 2026-02-07 20:10:20 +00:00
21 changed files with 1260 additions and 76 deletions

3
.env.sample Normal file
View File

@ -0,0 +1,3 @@
# Project tab specific
GITEA_URL=""
GITEA_TOKEN=""

179
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View File

@ -1,23 +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>
</main>
<footer>
<!-- Left side -->
<div>
</div>
<!-- Right side -->
<div>
</div>
</footer>
</body>
</html>

View File

@ -1,2 +0,0 @@
window.addEventListener('load', (e) => {
});

4
requirements.txt Normal file
View File

@ -0,0 +1,4 @@
python-magic
requests
dotenv
Flask

View File

@ -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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

View 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
View 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
View 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
View 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
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

193
www/styles.css Normal file
View 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;
}