Rename the gallery to onlylegs in the files

Im sorry
This commit is contained in:
Michał Gdula 2023-04-12 16:58:13 +00:00
parent 8029fff73e
commit 2174b10879
76 changed files with 66 additions and 20 deletions

136
onlylegs/__init__.py Normal file
View file

@ -0,0 +1,136 @@
"""
Onlylegs Gallery
This is the main app file, it loads all the other files and sets up the app
"""
import os
import logging
import platformdirs
from flask_assets import Bundle
from flask_migrate import init as migrate_init
from flask_migrate import upgrade as migrate_upgrade
from flask_migrate import migrate as migrate_migrate
from flask import Flask, render_template, abort
from werkzeug.exceptions import HTTPException
from werkzeug.security import generate_password_hash
from onlylegs.extensions import db, migrate, login_manager, assets, compress, cache
from onlylegs.views import index, image, group, settings, profile
from onlylegs.models import User
from onlylegs import api
from onlylegs import auth
INSTACE_DIR = os.path.join(platformdirs.user_config_dir("onlylegs"), "instance")
MIGRATIONS_DIR = os.path.join(INSTACE_DIR, "migrations")
def create_app(): # pylint: disable=R0914
"""
Create and configure the main app
"""
app = Flask(__name__, instance_path=INSTACE_DIR)
app.config.from_pyfile("config.py")
# DATABASE
db.init_app(app)
migrate.init_app(app, db)
# If database file doesn't exist, create it
if not os.path.exists(os.path.join(INSTACE_DIR, "gallery.sqlite3")):
print("Creating database")
with app.app_context():
db.create_all()
register_user = User(
username=app.config["ADMIN_CONF"]["username"],
email=app.config["ADMIN_CONF"]["email"],
password=generate_password_hash("changeme!", method="sha256"),
)
db.session.add(register_user)
db.session.commit()
print(
"""
####################################################
# DEFAULY ADMIN USER GENERATED WITH GIVEN USERNAME #
# THE DEFAULT PASSWORD "changeme!" HAS BEEN USED, #
# PLEASE UPDATE IT IN THE SETTINGS! #
####################################################
"""
)
# Check if migrations directory exists, if not create it
with app.app_context():
if not os.path.exists(MIGRATIONS_DIR):
print("Creating migrations directory")
migrate_init(directory=MIGRATIONS_DIR)
# Check if migrations are up to date
with app.app_context():
print("Checking for schema changes...")
migrate_migrate(directory=MIGRATIONS_DIR)
migrate_upgrade(directory=MIGRATIONS_DIR)
# LOGIN MANAGER
# can also set session_protection to "strong"
# this would protect against session hijacking
login_manager.init_app(app)
login_manager.login_view = "gallery.index"
@login_manager.user_loader
def load_user(user_id):
return User.query.filter_by(alt_id=user_id).first()
@login_manager.unauthorized_handler
def unauthorized():
error = 401
msg = "You are not authorized to view this page!!!!"
return render_template("error.html", error=error, msg=msg), error
# ERROR HANDLERS
@app.errorhandler(Exception)
def error_page(err): # noqa
"""
Error handlers, if the error is not a HTTP error, return 500
"""
if not isinstance(err, HTTPException):
abort(500)
return (
render_template("error.html", error=err.code, msg=err.description),
err.code,
)
# ASSETS
assets.init_app(app)
scripts = Bundle("js/*.js", filters="jsmin", output="gen/js.js", depends="js/*.js")
styles = Bundle(
"sass/*.sass",
filters="libsass, cssmin",
output="gen/styles.css",
depends="sass/**/*.sass",
)
assets.register("scripts", scripts)
assets.register("styles", styles)
# BLUEPRINTS
app.register_blueprint(auth.blueprint)
app.register_blueprint(api.blueprint)
app.register_blueprint(index.blueprint)
app.register_blueprint(image.blueprint)
app.register_blueprint(group.blueprint)
app.register_blueprint(profile.blueprint)
app.register_blueprint(settings.blueprint)
# CACHE AND COMPRESS
cache.init_app(app)
compress.init_app(app)
# Yupee! We got there :3
print("Done!")
logging.info("Gallery started successfully!")
return app

205
onlylegs/api.py Normal file
View file

@ -0,0 +1,205 @@
"""
Onlylegs - API endpoints
"""
from uuid import uuid4
import os
import pathlib
import logging
import platformdirs
from flask import Blueprint, send_from_directory, abort, flash, request, current_app
from werkzeug.utils import secure_filename
from flask_login import login_required, current_user
from colorthief import ColorThief
from onlylegs.extensions import db
from onlylegs.models import Post, Group, GroupJunction
from onlylegs.utils import metadata as mt
from onlylegs.utils.generate_image import generate_thumbnail
blueprint = Blueprint("api", __name__, url_prefix="/api")
@blueprint.route("/file/<file_name>", methods=["GET"])
def file(file_name):
"""
Returns a file from the uploads folder
r for resolution, 400x400 or thumb for thumbnail
"""
res = request.args.get("r", default=None, type=str) # Type of file (thumb, etc)
ext = request.args.get("e", default=None, type=str) # File extension
file_name = secure_filename(file_name) # Sanitize file name
# if no args are passed, return the raw file
if not res and not ext:
if not os.path.exists(
os.path.join(current_app.config["UPLOAD_FOLDER"], file_name)
):
abort(404)
return send_from_directory(current_app.config["UPLOAD_FOLDER"], file_name)
thumb = generate_thumbnail(file_name, res, ext)
if not thumb:
abort(404)
return send_from_directory(os.path.dirname(thumb), os.path.basename(thumb))
@blueprint.route("/upload", methods=["POST"])
@login_required
def upload():
"""
Uploads an image to the server and saves it to the database
"""
form_file = request.files["file"]
form = request.form
# If no image is uploaded, return 404 error
if not form_file:
return abort(404)
# Get file extension, generate random name and set file path
img_ext = pathlib.Path(form_file.filename).suffix.replace(".", "").lower()
img_name = "GWAGWA_" + str(uuid4())
img_path = os.path.join(
current_app.config["UPLOAD_FOLDER"], img_name + "." + img_ext
)
# Check if file extension is allowed
if img_ext not in current_app.config["ALLOWED_EXTENSIONS"].keys():
logging.info("File extension not allowed: %s", img_ext)
abort(403)
# Save file
try:
form_file.save(img_path)
except OSError as err:
logging.info("Error saving file %s because of %s", img_path, err)
abort(500)
img_exif = mt.Metadata(img_path).yoink() # Get EXIF data
img_colors = ColorThief(img_path).get_palette(color_count=3) # Get color palette
# Save to database
query = Post(
author_id=current_user.id,
filename=img_name + "." + img_ext,
mimetype=img_ext,
exif=img_exif,
colours=img_colors,
description=form["description"],
alt=form["alt"],
)
db.session.add(query)
db.session.commit()
return "Gwa Gwa" # Return something so the browser doesn't show an error
@blueprint.route("/delete/<int:image_id>", methods=["POST"])
@login_required
def delete_image(image_id):
"""
Deletes an image from the server and database
"""
post = Post.query.filter_by(id=image_id).first()
# Check if image exists and if user is allowed to delete it (author)
if post is None:
abort(404)
if post.author_id != current_user.id:
abort(403)
# Delete file
try:
os.remove(os.path.join(current_app.config["UPLOAD_FOLDER"], post.filename))
except FileNotFoundError:
logging.warning(
"File not found: %s, already deleted or never existed", post.filename
)
# Delete cached files
cache_path = os.path.join(platformdirs.user_config_dir("onlylegs"), "cache")
cache_name = post.filename.rsplit(".")[0]
for cache_file in pathlib.Path(cache_path).glob(cache_name + "*"):
os.remove(cache_file)
GroupJunction.query.filter_by(post_id=image_id).delete()
db.session.delete(post)
db.session.commit()
logging.info("Removed image (%s) %s", image_id, post.filename)
flash(["Image was all in Le Head!", "1"])
return "Gwa Gwa"
@blueprint.route("/group/create", methods=["POST"])
@login_required
def create_group():
"""
Creates a group
"""
new_group = Group(
name=request.form["name"],
description=request.form["description"],
author_id=current_user.id,
)
db.session.add(new_group)
db.session.commit()
return ":3"
@blueprint.route("/group/modify", methods=["POST"])
@login_required
def modify_group():
"""
Changes the images in a group
"""
group_id = request.form["group"]
image_id = request.form["image"]
action = request.form["action"]
group = db.get_or_404(Group, group_id)
db.get_or_404(Post, image_id) # Check if image exists
if group.author_id != current_user.id:
abort(403)
if (
action == "add"
and not GroupJunction.query.filter_by(
group_id=group_id, post_id=image_id
).first()
):
db.session.add(GroupJunction(group_id=group_id, post_id=image_id))
elif request.form["action"] == "remove":
GroupJunction.query.filter_by(group_id=group_id, post_id=image_id).delete()
db.session.commit()
return ":3"
@blueprint.route("/group/delete", methods=["POST"])
def delete_group():
"""
Deletes a group
"""
group_id = request.form["group"]
group = Group.query.filter_by(id=group_id).first()
if group is None:
abort(404)
elif group.author_id != current_user.id:
abort(403)
GroupJunction.query.filter_by(group_id=group_id).delete()
db.session.delete(group)
db.session.commit()
flash(["Group yeeted!", "1"])
return ":3"

109
onlylegs/auth.py Normal file
View file

@ -0,0 +1,109 @@
"""
OnlyLegs - Authentication
User registration, login and logout and locking access to pages behind a login
"""
import re
import logging
from flask import Blueprint, flash, redirect, request, url_for, abort, jsonify
from werkzeug.security import check_password_hash, generate_password_hash
from flask_login import login_user, logout_user, login_required
from onlylegs.extensions import db
from onlylegs.models import User
blueprint = Blueprint("auth", __name__, url_prefix="/auth")
@blueprint.route("/login", methods=["POST"])
def login():
"""
Log in a registered user by adding the user id to the session
"""
error = []
username = request.form["username"].strip()
password = request.form["password"].strip()
remember = bool(request.form["remember-me"])
user = User.query.filter_by(username=username).first()
if not user or not check_password_hash(user.password, password):
logging.error("Login attempt from %s", request.remote_addr)
error.append("Username or Password is incorrect!")
if error:
abort(403)
login_user(user, remember=remember)
logging.info("User %s logged in from %s", username, request.remote_addr)
flash(["Logged in successfully!", "4"])
return "ok", 200
@blueprint.route("/register", methods=["POST"])
def register():
"""
Register a new user
"""
error = []
# Thanks Fennec for reminding me to strip out the whitespace lol
username = request.form["username"].strip()
email = request.form["email"].strip()
password = request.form["password"].strip()
password_repeat = request.form["password-repeat"].strip()
email_regex = re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b")
username_regex = re.compile(r"\b[A-Za-z0-9._-]+\b")
# Validate the form
if not username or not username_regex.match(username):
error.append("Username is invalid!")
if not email or not email_regex.match(email):
error.append("Email is invalid!")
if not password:
error.append("Password is empty!")
elif len(password) < 8:
error.append("Password is too short! Longer than 8 characters pls")
if not password_repeat:
error.append("Enter password again!")
elif password_repeat != password:
error.append("Passwords do not match!")
user_exists = User.query.filter_by(username=username).first()
if user_exists:
error.append("User already exists!")
# If there are errors, return them
if error:
print(error)
return jsonify(error), 400
register_user = User(
username=username,
email=email,
password=generate_password_hash(password, method="sha256"),
)
db.session.add(register_user)
db.session.commit()
logging.info("User %s registered", username)
return "ok", 200
@blueprint.route("/logout")
@login_required
def logout():
"""
Clear the current session, including the stored user id
"""
logout_user()
flash(["Goodbye!!!", "4"])
return redirect(url_for("gallery.index"))

36
onlylegs/config.py Normal file
View file

@ -0,0 +1,36 @@
"""
Gallery configuration file
"""
import os
import platformdirs
from dotenv import load_dotenv
from yaml import safe_load
# Set dirs
user_dir = platformdirs.user_config_dir("onlylegs")
instance_dir = os.path.join(user_dir, "instance")
# Load environment variables
print("Loading environment variables...")
load_dotenv(os.path.join(user_dir, ".env"))
# Load config from user dir
print("Loading config...")
with open(os.path.join(user_dir, "conf.yml"), encoding="utf-8", mode="r") as file:
conf = safe_load(file)
# Flask config
SECRET_KEY = os.environ.get("FLASK_SECRET")
SQLALCHEMY_DATABASE_URI = "sqlite:///gallery.sqlite3"
# Upload config
MAX_CONTENT_LENGTH = 1024 * 1024 * conf["upload"]["max-size"]
UPLOAD_FOLDER = os.path.join(user_dir, "uploads")
ALLOWED_EXTENSIONS = conf["upload"]["allowed-extensions"]
# Pass YAML config to app
ADMIN_CONF = conf["admin"]
UPLOAD_CONF = conf["upload"]
WEBSITE_CONF = conf["website"]

16
onlylegs/extensions.py Normal file
View file

@ -0,0 +1,16 @@
"""
Extensions used by the application
"""
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_assets import Environment
from flask_compress import Compress
from flask_caching import Cache
db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
assets = Environment()
compress = Compress()
cache = Cache(config={"CACHE_TYPE": "SimpleCache", "CACHE_DEFAULT_TIMEOUT": 300})

4
onlylegs/langs/gb.json Normal file
View file

@ -0,0 +1,4 @@
{
"IMAGES_UPLOADED": "%s images uploaded!",
"DONT USE THIS": "variable:format(data), jinja2 doesnt use the same method as Django does, odd"
}

105
onlylegs/models.py Normal file
View file

@ -0,0 +1,105 @@
"""
OnlyLegs - Database models and ions for SQLAlchemy
"""
from uuid import uuid4
from flask_login import UserMixin
from onlylegs.extensions import db
class GroupJunction(db.Model): # pylint: disable=too-few-public-methods, C0103
"""
Junction table for posts and groups
Joins with posts and groups
"""
__tablename__ = "group_junction"
id = db.Column(db.Integer, primary_key=True)
group_id = db.Column(db.Integer, db.ForeignKey("group.id"))
post_id = db.Column(db.Integer, db.ForeignKey("post.id"))
date_added = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(), # pylint: disable=E1102
)
class Post(db.Model): # pylint: disable=too-few-public-methods, C0103
"""
Post table
"""
__tablename__ = "post"
id = db.Column(db.Integer, primary_key=True)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
filename = db.Column(db.String, unique=True, nullable=False)
mimetype = db.Column(db.String, nullable=False)
exif = db.Column(db.PickleType, nullable=False)
colours = db.Column(db.PickleType, nullable=False)
description = db.Column(db.String, nullable=False)
alt = db.Column(db.String, nullable=False)
created_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(), # pylint: disable=E1102
)
junction = db.relationship("GroupJunction", backref="posts")
class Group(db.Model): # pylint: disable=too-few-public-methods, C0103
"""
Group table
"""
__tablename__ = "group"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String, nullable=False)
description = db.Column(db.String, nullable=False)
author_id = db.Column(db.Integer, db.ForeignKey("user.id"))
created_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(), # pylint: disable=E1102
)
junction = db.relationship("GroupJunction", backref="groups")
class User(db.Model, UserMixin): # pylint: disable=too-few-public-methods, C0103
"""
User table
"""
__tablename__ = "user"
# Gallery used information
id = db.Column(db.Integer, primary_key=True)
alt_id = db.Column(db.String, unique=True, nullable=False, default=str(uuid4()))
profile_picture = db.Column(db.String, nullable=True, default=None)
username = db.Column(db.String, unique=True, nullable=False)
email = db.Column(db.String, unique=True, nullable=False)
password = db.Column(db.String, nullable=False)
joined_at = db.Column(
db.DateTime,
nullable=False,
server_default=db.func.now(), # pylint: disable=E1102
)
posts = db.relationship("Post", backref="author")
groups = db.relationship("Group", backref="author")
def get_id(self):
return str(self.alt_id)

BIN
onlylegs/static/error.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

View file

@ -0,0 +1,7 @@
@font-face {
font-family: 'Rubik';
src: url('./Rubik.ttf') format('truetype');
font-style: normal;
font-display: block;
font-weight: 300 900;
}

45
onlylegs/static/gen/js.js Normal file
View file

@ -0,0 +1,45 @@
function imgFade(obj,time=250){obj.style.transition=`opacity ${time}ms`;obj.style.opacity=1;}
function loadOnView(){const lazyLoad=document.querySelectorAll('#lazy-load');const webpSupport=checkWebpSupport();for(let i=0;i<lazyLoad.length;i++){let image=lazyLoad[i];if(image.getBoundingClientRect().top<window.innerHeight&&image.getBoundingClientRect().bottom>0){if(!image.src&&webpSupport){image.src=image.getAttribute('data-src')+'&e=webp'}else if(!image.src){image.src=image.getAttribute('data-src')}}}}
window.onload=function(){loadOnView();let times=document.querySelectorAll('.time');for(let i=0;i<times.length;i++){const raw=times[i].innerHTML.split('.')[0];const time=raw.split(' ')[1]
const date=raw.split(' ')[0].split('-');let formatted=date[0]+'/'+date[1]+'/'+date[2]+' '+time+' UTC';let dateTime=new Date(formatted);times[i].innerHTML=dateTime.toLocaleDateString()+' '+dateTime.toLocaleTimeString();}
let topOfPage=document.querySelector('.top-of-page');if(document.body.scrollTop>300||document.documentElement.scrollTop>20){topOfPage.classList.add('show');}else{topOfPage.classList.remove('show');}
topOfPage.onclick=function(){document.body.scrollTop=0;document.documentElement.scrollTop=0;}
let infoButton=document.querySelector('.info-button');if(infoButton){if(document.body.scrollTop>300||document.documentElement.scrollTop>20){infoButton.classList.remove('show');}else{infoButton.classList.add('show');}
infoButton.onclick=function(){popUpShow('OnlyLegs','<a href="https://github.com/Fluffy-Bean/onlylegs">V23.04.10</a> '+
'using <a href="https://phosphoricons.com/">Phosphoricons</a> and Flask.'+
'<br>Made by Fluffy and others with ❤️');}}};window.onscroll=function(){loadOnView();let topOfPage=document.querySelector('.top-of-page');if(document.body.scrollTop>300||document.documentElement.scrollTop>20){topOfPage.classList.add('show');}else{topOfPage.classList.remove('show');}
let infoButton=document.querySelector('.info-button');if(infoButton){if(document.body.scrollTop>300||document.documentElement.scrollTop>20){infoButton.classList.remove('show');}else{infoButton.classList.add('show');}}};window.onresize=function(){loadOnView();};function showLogin(){cancelBtn=document.createElement('button');cancelBtn.classList.add('btn-block');cancelBtn.innerHTML='nuuuuuuuu';cancelBtn.onclick=popupDissmiss;loginBtn=document.createElement('button');loginBtn.classList.add('btn-block');loginBtn.classList.add('primary');loginBtn.innerHTML='Login';loginBtn.type='submit';loginBtn.setAttribute('form','loginForm');loginForm=document.createElement('form');loginForm.id='loginForm';loginForm.setAttribute('onsubmit','return login(event);');usernameInput=document.createElement('input');usernameInput.classList.add('input-block');usernameInput.type='text';usernameInput.placeholder='Namey';usernameInput.id='username';passwordInput=document.createElement('input');passwordInput.classList.add('input-block');passwordInput.type='password';passwordInput.placeholder='Passywassy';passwordInput.id='password';rememberMeSpan=document.createElement('span');rememberMeSpan.classList.add('input-checkbox');rememberMeInput=document.createElement('input');rememberMeInput.type='checkbox';rememberMeInput.id='remember-me';rememberMeLabel=document.createElement('label');rememberMeLabel.innerHTML='No forgetty me pls';rememberMeLabel.setAttribute('for','remember-me');rememberMeSpan.appendChild(rememberMeInput);rememberMeSpan.appendChild(rememberMeLabel);loginForm.appendChild(usernameInput);loginForm.appendChild(passwordInput);loginForm.appendChild(rememberMeSpan);popUpShow('Login!','Need an account? <span class="link" onclick="showRegister()">Register!</span>',loginForm,[cancelBtn,loginBtn]);}
function login(event){event.preventDefault();let formUsername=document.querySelector("#username").value;let formPassword=document.querySelector("#password").value;let formRememberMe=document.querySelector("#remember-me").checked;if(formUsername===""||formPassword===""){addNotification("Please fill in all fields!!!!",3);return;}
const formData=new FormData();formData.append("username",formUsername);formData.append("password",formPassword);formData.append("remember-me",formRememberMe);fetch('/auth/login',{method:'POST',body:formData}).then(response=>{if(response.ok){location.reload();}else{if(response.status===403){addNotification('None but devils play past here... Wrong information',2);}else if(response.status===500){addNotification('Server exploded, F\'s in chat',2);}else{addNotification('Error logging in, blame someone',2);}}}).catch(error=>{addNotification('Error logging in, blame someone',2);});}
function showRegister(){cancelBtn=document.createElement('button');cancelBtn.classList.add('btn-block');cancelBtn.innerHTML='nuuuuuuuu';cancelBtn.onclick=popupDissmiss;registerBtn=document.createElement('button');registerBtn.classList.add('btn-block');registerBtn.classList.add('primary');registerBtn.innerHTML='Register';registerBtn.type='submit';registerBtn.setAttribute('form','registerForm');registerForm=document.createElement('form');registerForm.id='registerForm';registerForm.setAttribute('onsubmit','return register(event);');usernameInput=document.createElement('input');usernameInput.classList.add('input-block');usernameInput.type='text';usernameInput.placeholder='Namey';usernameInput.id='username';emailInput=document.createElement('input');emailInput.classList.add('input-block');emailInput.type='text';emailInput.placeholder='E mail!';emailInput.id='email';passwordInput=document.createElement('input');passwordInput.classList.add('input-block');passwordInput.type='password';passwordInput.placeholder='Passywassy';passwordInput.id='password';passwordInputRepeat=document.createElement('input');passwordInputRepeat.classList.add('input-block');passwordInputRepeat.type='password';passwordInputRepeat.placeholder='Passywassy again!';passwordInputRepeat.id='password-repeat';registerForm.appendChild(usernameInput);registerForm.appendChild(emailInput);registerForm.appendChild(passwordInput);registerForm.appendChild(passwordInputRepeat);popUpShow('Who are you?','Already have an account? <span class="link" onclick="showLogin()">Login!</span>',registerForm,[cancelBtn,registerBtn]);}
function register(event){event.preventDefault();let formUsername=document.querySelector("#username").value;let formEmail=document.querySelector("#email").value;let formPassword=document.querySelector("#password").value;let formPasswordRepeat=document.querySelector("#password-repeat").value;if(formUsername===""||formEmail===""||formPassword===""||formPasswordRepeat===""){addNotification("Please fill in all fields!!!!",3);return;}
const formData=new FormData();formData.append("username",formUsername);formData.append("email",formEmail);formData.append("password",formPassword);formData.append("password-repeat",formPasswordRepeat);fetch('/auth/register',{method:'POST',body:formData}).then(response=>{if(response.ok){addNotification('Registered successfully! Now please login to continue',1);showLogin();}else{if(response.status===400){response.json().then(data=>{for(let i=0;i<data.length;i++){addNotification(data[i],2);}});}else if(response.status===403){addNotification('None but devils play past here... Wrong information',2);}else if(response.status===500){addNotification('Server exploded, F\'s in chat',2);}else{addNotification('Error logging in, blame someone',2);}}}).catch(error=>{addNotification('Error logging in, blame someone',2);});}
function addNotification(notificationText,notificationLevel){const notificationContainer=document.querySelector('.notifications');const successIcon='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"></path></svg>';const criticalIcon='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"></path></svg>';const warningIcon='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>';const infoIcon='<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>';const notification=document.createElement('div');notification.classList.add('sniffle__notification');notification.onclick=function(){if(notification){notification.classList.add('hide');setTimeout(function(){notificationContainer.removeChild(notification);},500);}};const iconElement=document.createElement('span');iconElement.classList.add('sniffle__notification-icon');notification.appendChild(iconElement);if(notificationLevel===1){notification.classList.add('success');iconElement.innerHTML=successIcon;}else if(notificationLevel===2){notification.classList.add('critical');iconElement.innerHTML=criticalIcon;}else if(notificationLevel===3){notification.classList.add('warning');iconElement.innerHTML=warningIcon;}else{notification.classList.add('info');iconElement.innerHTML=infoIcon;}
const description=document.createElement('span');description.classList.add('sniffle__notification-text');description.innerHTML=notificationText;notification.appendChild(description);notificationContainer.appendChild(notification);setTimeout(function(){notification.classList.add('show');},5);setTimeout(function(){if(notification){notification.classList.add('hide');setTimeout(function(){notificationContainer.removeChild(notification);},500);}},5000);}
function popUpShow(titleText,subtitleText,bodyContent=null,userActions=null){const popupSelector=document.querySelector('.pop-up');const headerSelector=document.querySelector('.pop-up-header');const actionsSelector=document.querySelector('.pop-up-controlls');headerSelector.innerHTML='';actionsSelector.innerHTML='';const titleElement=document.createElement('h2');titleElement.innerHTML=titleText;headerSelector.appendChild(titleElement);const subtitleElement=document.createElement('p');subtitleElement.innerHTML=subtitleText;headerSelector.appendChild(subtitleElement);if(bodyContent){headerSelector.appendChild(bodyContent);}
if(userActions){for(let i=0;i<userActions.length;i++){actionsSelector.appendChild(userActions[i]);}}else{actionsSelector.innerHTML='<button class="btn-block" onclick="popupDissmiss()">Close</button>';}
document.querySelector("html").style.overflow="hidden";popupSelector.style.display='block';setTimeout(function(){popupSelector.classList.add('active')},5);}
function popupDissmiss(){const popupSelector=document.querySelector('.pop-up');document.querySelector("html").style.overflow="auto";popupSelector.classList.remove('active');setTimeout(function(){popupSelector.style.display='none';},200);}
window.addEventListener("dragover",(event)=>{event.preventDefault();},false);window.addEventListener("drop",(event)=>{event.preventDefault();},false);function openUploadTab(){let uploadTab=document.querySelector(".upload-panel");document.querySelector("html").style.overflow="hidden";uploadTab.style.display="block";setTimeout(function(){uploadTab.classList.add("open");},5);}
function closeUploadTab(){let uploadTab=document.querySelector(".upload-panel");let uploadTabContainer=document.querySelector(".upload-panel .container");document.querySelector("html").style.overflow="auto";uploadTab.classList.remove("open");setTimeout(function(){uploadTab.style.display="none";uploadTabContainer.style.transform="";uploadTab.dataset.lastY=0;},250);}
function toggleUploadTab(){let uploadTab=document.querySelector(".upload-panel");if(uploadTab.classList.contains("open")){closeUploadTab();}else{openUploadTab();}}
function tabDragStart(event){event.preventDefault();let uploadTab=document.querySelector(".upload-panel .container");let offset=uploadTab.getBoundingClientRect().y;uploadTab.classList.add("dragging");document.addEventListener('touchmove',event=>{if(uploadTab.classList.contains("dragging")){if(event.touches[0].clientY-offset>=0){uploadTab.dataset.lastY=event.touches[0].clientY;}else{uploadTab.dataset.lastY=offset;}
uploadTab.style.transform=`translateY(${uploadTab.dataset.lastY-offset}px)`;}});}
function tabDragStopped(event){event.preventDefault();let uploadTab=document.querySelector(".upload-panel .container");uploadTab.classList.remove("dragging");if(uploadTab.dataset.lastY>(screen.height*0.3)){closeUploadTab();}else{uploadTab.style.transition="transform 0.25s cubic-bezier(0.76, 0, 0.17, 1)";uploadTab.style.transform="translateY(0px)";setTimeout(function(){uploadTab.style.transition="";},250);}}
function fileActivate(event){event.preventDefault()
let fileDrop=document.querySelector('.fileDrop-block');let fileDropTitle=fileDrop.querySelector('.status');fileDrop.classList.remove('error');fileDrop.classList.add('edging');fileDropTitle.innerHTML='Drop to upload!';}
function fileDefault(){let fileDrop=document.querySelector('.fileDrop-block');let fileDropTitle=fileDrop.querySelector('.status');fileDrop.classList.remove('error');fileDrop.classList.remove('edging');fileDropTitle.innerHTML='Choose or Drop file';}
function fileDropHandle(event){event.preventDefault()
let fileDrop=document.querySelector('.fileDrop-block');let fileUpload=fileDrop.querySelector('#file');fileUpload.files=event.dataTransfer.files;fileDefault();fileChanged();}
function fileChanged(){let dropBlock=document.querySelector('.fileDrop-block');let dropBlockStatus=dropBlock.querySelector('.status');let dropBlockInput=dropBlock.querySelector('#file');if(dropBlockInput.value!==""){dropBlock.classList.add('active');dropBlockStatus.innerHTML=dropBlockInput.files[0].name;}else{fileDefault();}}
function clearUpload(){let fileDrop=document.querySelector('#uploadForm');let fileUpload=fileDrop.querySelector('#file');let fileAlt=fileDrop.querySelector('#alt');let fileDescription=fileDrop.querySelector('#description');let fileTags=fileDrop.querySelector('#tags');fileUpload.value="";fileAlt.value="";fileDescription.value="";fileTags.value="";}
document.addEventListener('DOMContentLoaded',()=>{let uploadTab=document.querySelector(".upload-panel");if(!uploadTab){return;}
let uploadTabDrag=uploadTab.querySelector("#dragIndicator");let uploadForm=uploadTab.querySelector('#uploadForm');let fileDrop=uploadForm.querySelector('.fileDrop-block');let fileDropTitle=fileDrop.querySelector('.status');let fileUpload=fileDrop.querySelector('#file');let fileAlt=uploadForm.querySelector('#alt');let fileDescription=uploadForm.querySelector('#description');let fileTags=uploadForm.querySelector('#tags');clearUpload();fileDefault();uploadTabDrag.addEventListener('touchstart',tabDragStart,false);uploadTabDrag.addEventListener('touchend',tabDragStopped,false);fileDrop.addEventListener('dragover',fileActivate,false);fileDrop.addEventListener('dragenter',fileActivate,false);fileDrop.addEventListener('dragleave',fileDefault,false);fileDrop.addEventListener('drop',fileDropHandle,false);fileUpload.addEventListener('change',fileChanged,false);fileUpload.addEventListener('click',fileDefault,false);uploadForm.addEventListener('submit',(event)=>{event.preventDefault()
if(fileUpload.value===""){fileDrop.classList.add('error');fileDropTitle.innerHTML='No file selected!';return;}
let formData=new FormData();formData.append("file",fileUpload.files[0]);formData.append("alt",fileAlt.value);formData.append("description",fileDescription.value);formData.append("tags",fileTags.value);fetch('/api/upload',{method:'POST',body:formData})
.then(data=>{addNotification("Image uploaded successfully",1);}).catch(error=>{switch(response.status){case 500:addNotification("Server exploded, F's in chat",2)
break;case 400:case 404:addNotification("Error uploading. Blame yourself",2)
break;case 403:addNotification("None but devils play past here...",2)
break;case 413:addNotification("File too large!!!!!!",2);break;default:addNotification("Error uploading file, blame someone",2)
break;}});clearUpload();fileDrop.classList.remove('active');fileDropTitle.innerHTML='Choose or Drop file';});});function checkWebpSupport(){let webpSupport=false;try{webpSupport=document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp')===0;}catch(e){webpSupport=false;}
return webpSupport;}

File diff suppressed because one or more lines are too long

BIN
onlylegs/static/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,98 @@
// fade in images
function imgFade(obj, time = 250) {
obj.style.transition = `opacity ${time}ms`;
obj.style.opacity = 1;
}
// Lazy load images when they are in view
function loadOnView() {
const lazyLoad = document.querySelectorAll('#lazy-load');
const webpSupport = checkWebpSupport();
for (let i = 0; i < lazyLoad.length; i++) {
let image = lazyLoad[i];
if (image.getBoundingClientRect().top < window.innerHeight && image.getBoundingClientRect().bottom > 0) {
if (!image.src && webpSupport) {
image.src = image.getAttribute('data-src') + '&e=webp'
} else if (!image.src) {
image.src = image.getAttribute('data-src')
}
}
}
}
window.onload = function () {
loadOnView();
let times = document.querySelectorAll('.time');
for (let i = 0; i < times.length; i++) {
// Remove milliseconds
const raw = times[i].innerHTML.split('.')[0];
// Parse YYYY-MM-DD HH:MM:SS to Date object
const time = raw.split(' ')[1]
const date = raw.split(' ')[0].split('-');
// Format to YYYY/MM/DD HH:MM:SS
let formatted = date[0] + '/' + date[1] + '/' + date[2] + ' ' + time + ' UTC';
// Convert to UTC Date object
let dateTime = new Date(formatted);
// Convert to local time
times[i].innerHTML = dateTime.toLocaleDateString() + ' ' + dateTime.toLocaleTimeString();
}
// Top Of Page button
let topOfPage = document.querySelector('.top-of-page');
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
topOfPage.classList.add('show');
} else {
topOfPage.classList.remove('show');
}
topOfPage.onclick = function () {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
// Info button
let infoButton = document.querySelector('.info-button');
if (infoButton) {
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
infoButton.classList.remove('show');
} else {
infoButton.classList.add('show');
}
infoButton.onclick = function () {
popUpShow('OnlyLegs',
'<a href="https://github.com/Fluffy-Bean/onlylegs">V23.04.10</a> ' +
'using <a href="https://phosphoricons.com/">Phosphoricons</a> and Flask.' +
'<br>Made by Fluffy and others with ❤️');
}
}
};
window.onscroll = function () {
loadOnView();
// Top Of Page button
let topOfPage = document.querySelector('.top-of-page');
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
topOfPage.classList.add('show');
} else {
topOfPage.classList.remove('show');
}
// Info button
let infoButton = document.querySelector('.info-button');
if (infoButton) {
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
infoButton.classList.remove('show');
} else {
infoButton.classList.add('show');
}
}
};
window.onresize = function () {
loadOnView();
};

202
onlylegs/static/js/login.js Normal file
View file

@ -0,0 +1,202 @@
// Function to show login
function showLogin() {
// Create elements
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'nuuuuuuuu';
cancelBtn.onclick = popupDissmiss;
loginBtn = document.createElement('button');
loginBtn.classList.add('btn-block');
loginBtn.classList.add('primary');
loginBtn.innerHTML = 'Login';
loginBtn.type = 'submit';
loginBtn.setAttribute('form', 'loginForm');
// Create form
loginForm = document.createElement('form');
loginForm.id = 'loginForm';
loginForm.setAttribute('onsubmit', 'return login(event);');
usernameInput = document.createElement('input');
usernameInput.classList.add('input-block');
usernameInput.type = 'text';
usernameInput.placeholder = 'Namey';
usernameInput.id = 'username';
passwordInput = document.createElement('input');
passwordInput.classList.add('input-block');
passwordInput.type = 'password';
passwordInput.placeholder = 'Passywassy';
passwordInput.id = 'password';
// Container for remember me checkbox
rememberMeSpan = document.createElement('span');
rememberMeSpan.classList.add('input-checkbox');
rememberMeInput = document.createElement('input');
rememberMeInput.type = 'checkbox';
rememberMeInput.id = 'remember-me';
rememberMeLabel = document.createElement('label');
rememberMeLabel.innerHTML = 'No forgetty me pls';
rememberMeLabel.setAttribute('for', 'remember-me');
rememberMeSpan.appendChild(rememberMeInput);
rememberMeSpan.appendChild(rememberMeLabel);
loginForm.appendChild(usernameInput);
loginForm.appendChild(passwordInput);
loginForm.appendChild(rememberMeSpan);
popUpShow(
'Login!',
'Need an account? <span class="link" onclick="showRegister()">Register!</span>',
loginForm,
[cancelBtn, loginBtn]
);
}
// Function to login
function login(event) {
// AJAX takes control of subby form :3
event.preventDefault();
let formUsername = document.querySelector("#username").value;
let formPassword = document.querySelector("#password").value;
let formRememberMe = document.querySelector("#remember-me").checked;
if (formUsername === "" || formPassword === "") {
addNotification("Please fill in all fields!!!!", 3);
return;
}
// Make form
const formData = new FormData();
formData.append("username", formUsername);
formData.append("password", formPassword);
formData.append("remember-me", formRememberMe);
fetch('/auth/login', {
method: 'POST',
body: formData
}).then(response => {
if (response.ok) {
location.reload();
} else {
if (response.status === 403) {
addNotification('None but devils play past here... Wrong information', 2);
} else if (response.status === 500) {
addNotification('Server exploded, F\'s in chat', 2);
} else {
addNotification('Error logging in, blame someone', 2);
}
}
}).catch(error => {
addNotification('Error logging in, blame someone', 2);
});
}
// Function to show register
function showRegister() {
// Create buttons
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'nuuuuuuuu';
cancelBtn.onclick = popupDissmiss;
registerBtn = document.createElement('button');
registerBtn.classList.add('btn-block');
registerBtn.classList.add('primary');
registerBtn.innerHTML = 'Register';
registerBtn.type = 'submit';
registerBtn.setAttribute('form', 'registerForm');
// Create form
registerForm = document.createElement('form');
registerForm.id = 'registerForm';
registerForm.setAttribute('onsubmit', 'return register(event);');
usernameInput = document.createElement('input');
usernameInput.classList.add('input-block');
usernameInput.type = 'text';
usernameInput.placeholder = 'Namey';
usernameInput.id = 'username';
emailInput = document.createElement('input');
emailInput.classList.add('input-block');
emailInput.type = 'text';
emailInput.placeholder = 'E mail!';
emailInput.id = 'email';
passwordInput = document.createElement('input');
passwordInput.classList.add('input-block');
passwordInput.type = 'password';
passwordInput.placeholder = 'Passywassy';
passwordInput.id = 'password';
passwordInputRepeat = document.createElement('input');
passwordInputRepeat.classList.add('input-block');
passwordInputRepeat.type = 'password';
passwordInputRepeat.placeholder = 'Passywassy again!';
passwordInputRepeat.id = 'password-repeat';
registerForm.appendChild(usernameInput);
registerForm.appendChild(emailInput);
registerForm.appendChild(passwordInput);
registerForm.appendChild(passwordInputRepeat);
popUpShow(
'Who are you?',
'Already have an account? <span class="link" onclick="showLogin()">Login!</span>',
registerForm,
[cancelBtn, registerBtn]
);
}
// Function to register
function register(event) {
// AJAX takes control of subby form
event.preventDefault();
let formUsername = document.querySelector("#username").value;
let formEmail = document.querySelector("#email").value;
let formPassword = document.querySelector("#password").value;
let formPasswordRepeat = document.querySelector("#password-repeat").value;
if (formUsername === "" || formEmail === "" || formPassword === "" || formPasswordRepeat === "") {
addNotification("Please fill in all fields!!!!", 3);
return;
}
// Make form
const formData = new FormData();
formData.append("username", formUsername);
formData.append("email", formEmail);
formData.append("password", formPassword);
formData.append("password-repeat", formPasswordRepeat);
// Send form to server
fetch('/auth/register', {
method: 'POST',
body: formData
}).then(response => {
if (response.ok) {
addNotification('Registered successfully! Now please login to continue', 1);
showLogin();
} else {
if (response.status === 400) {
response.json().then(data => {
for (let i = 0; i < data.length; i++) {
addNotification(data[i], 2);
}
});
} else if (response.status === 403) {
addNotification('None but devils play past here... Wrong information', 2);
} else if (response.status === 500) {
addNotification('Server exploded, F\'s in chat', 2);
} else {
addNotification('Error logging in, blame someone', 2);
}
}
}).catch(error => {
addNotification('Error logging in, blame someone', 2);
});
}

View file

@ -0,0 +1,63 @@
function addNotification(notificationText, notificationLevel) {
const notificationContainer = document.querySelector('.notifications');
// Set the different icons for the different notification levels
const successIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M229.66,77.66l-128,128a8,8,0,0,1-11.32,0l-56-56a8,8,0,0,1,11.32-11.32L96,188.69,218.34,66.34a8,8,0,0,1,11.32,11.32Z"></path></svg>';
const criticalIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M236.8,188.09,149.35,36.22h0a24.76,24.76,0,0,0-42.7,0L19.2,188.09a23.51,23.51,0,0,0,0,23.72A24.35,24.35,0,0,0,40.55,224h174.9a24.35,24.35,0,0,0,21.33-12.19A23.51,23.51,0,0,0,236.8,188.09ZM222.93,203.8a8.5,8.5,0,0,1-7.48,4.2H40.55a8.5,8.5,0,0,1-7.48-4.2,7.59,7.59,0,0,1,0-7.72L120.52,44.21a8.75,8.75,0,0,1,15,0l87.45,151.87A7.59,7.59,0,0,1,222.93,203.8ZM120,144V104a8,8,0,0,1,16,0v40a8,8,0,0,1-16,0Zm20,36a12,12,0,1,1-12-12A12,12,0,0,1,140,180Z"></path></svg>';
const warningIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>';
const infoIcon = '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>';
// Create notification element
const notification = document.createElement('div');
notification.classList.add('sniffle__notification');
notification.onclick = function() {
if (notification) {
notification.classList.add('hide');
setTimeout(function() {
notificationContainer.removeChild(notification);
}, 500);
}
};
// Create icon element and append to notification
const iconElement = document.createElement('span');
iconElement.classList.add('sniffle__notification-icon');
notification.appendChild(iconElement);
// Set the icon based on the notification level, not pretty but it works :3
if (notificationLevel === 1) {
notification.classList.add('success');
iconElement.innerHTML = successIcon;
} else if (notificationLevel === 2) {
notification.classList.add('critical');
iconElement.innerHTML = criticalIcon;
} else if (notificationLevel === 3) {
notification.classList.add('warning');
iconElement.innerHTML = warningIcon;
} else {
notification.classList.add('info');
iconElement.innerHTML = infoIcon;
}
// Create text element and append to notification
const description = document.createElement('span');
description.classList.add('sniffle__notification-text');
description.innerHTML = notificationText;
notification.appendChild(description);
// Append notification to container
notificationContainer.appendChild(notification);
setTimeout(function() { notification.classList.add('show'); }, 5);
// Remove notification after 5 seconds
setTimeout(function() {
if (notification) {
notification.classList.add('hide');
setTimeout(function() {
notificationContainer.removeChild(notification);
}, 500);
}
}, 5000);
}

View file

@ -0,0 +1,46 @@
function popUpShow(titleText, subtitleText, bodyContent=null, userActions=null) {
// Get popup elements
const popupSelector = document.querySelector('.pop-up');
const headerSelector = document.querySelector('.pop-up-header');
const actionsSelector = document.querySelector('.pop-up-controlls');
// Clear popup elements
headerSelector.innerHTML = '';
actionsSelector.innerHTML = '';
// Set popup header and subtitle
const titleElement = document.createElement('h2');
titleElement.innerHTML = titleText;
headerSelector.appendChild(titleElement);
const subtitleElement = document.createElement('p');
subtitleElement.innerHTML = subtitleText;
headerSelector.appendChild(subtitleElement);
if (bodyContent) {
headerSelector.appendChild(bodyContent);
}
// Set buttons that will be displayed
if (userActions) {
// for each user action, add the element
for (let i = 0; i < userActions.length; i++) {
actionsSelector.appendChild(userActions[i]);
}
} else {
actionsSelector.innerHTML = '<button class="btn-block" onclick="popupDissmiss()">Close</button>';
}
// Stop scrolling and show popup
document.querySelector("html").style.overflow = "hidden";
popupSelector.style.display = 'block';
setTimeout(function() { popupSelector.classList.add('active') }, 5); // 2ms delay to allow for css transition >:C
}
function popupDissmiss() {
const popupSelector = document.querySelector('.pop-up');
document.querySelector("html").style.overflow = "auto";
popupSelector.classList.remove('active');
setTimeout(function() { popupSelector.style.display = 'none'; }, 200);
}

View file

@ -0,0 +1,313 @@
// Remove default events on file drop, otherwise the browser will open the file
window.addEventListener("dragover", (event) => {
event.preventDefault();
}, false);
window.addEventListener("drop", (event) => {
event.preventDefault();
}, false);
// open upload tab
function openUploadTab() {
let uploadTab = document.querySelector(".upload-panel");
// Stop scrolling and open upload tab
document.querySelector("html").style.overflow = "hidden";
uploadTab.style.display = "block";
setTimeout(function () { uploadTab.classList.add("open"); }, 5);
}
// close upload tab
function closeUploadTab() {
let uploadTab = document.querySelector(".upload-panel");
let uploadTabContainer = document.querySelector(".upload-panel .container");
// un-Stop scrolling and close upload tab
document.querySelector("html").style.overflow = "auto";
uploadTab.classList.remove("open");
setTimeout(function () {
uploadTab.style.display = "none";
uploadTabContainer.style.transform = "";
uploadTab.dataset.lastY = 0;
}, 250);
}
// toggle upload tab
function toggleUploadTab() {
let uploadTab = document.querySelector(".upload-panel");
if (uploadTab.classList.contains("open")) {
closeUploadTab();
} else {
openUploadTab();
}
}
function tabDragStart(event) {
event.preventDefault();
let uploadTab = document.querySelector(".upload-panel .container");
let offset = uploadTab.getBoundingClientRect().y;
uploadTab.classList.add("dragging");
document.addEventListener('touchmove', event => {
if (uploadTab.classList.contains("dragging")) {
if (event.touches[0].clientY - offset >= 0) {
uploadTab.dataset.lastY = event.touches[0].clientY;
} else {
uploadTab.dataset.lastY = offset;
}
uploadTab.style.transform = `translateY(${uploadTab.dataset.lastY - offset}px)`;
}
});
}
function tabDragStopped(event) {
event.preventDefault();
let uploadTab = document.querySelector(".upload-panel .container");
uploadTab.classList.remove("dragging");
if (uploadTab.dataset.lastY > (screen.height * 0.3)) {
closeUploadTab();
} else {
uploadTab.style.transition = "transform 0.25s cubic-bezier(0.76, 0, 0.17, 1)";
uploadTab.style.transform = "translateY(0px)";
setTimeout(function () { uploadTab.style.transition = ""; }, 250);
}
}
// Edging the file plunge :3
function fileActivate(event) {
event.preventDefault()
let fileDrop = document.querySelector('.fileDrop-block');
let fileDropTitle = fileDrop.querySelector('.status');
fileDrop.classList.remove('error');
fileDrop.classList.add('edging');
fileDropTitle.innerHTML = 'Drop to upload!';
}
function fileDefault() {
let fileDrop = document.querySelector('.fileDrop-block');
let fileDropTitle = fileDrop.querySelector('.status');
fileDrop.classList.remove('error');
fileDrop.classList.remove('edging');
fileDropTitle.innerHTML = 'Choose or Drop file';
}
function fileDropHandle(event) {
event.preventDefault()
let fileDrop = document.querySelector('.fileDrop-block');
let fileUpload = fileDrop.querySelector('#file');
fileUpload.files = event.dataTransfer.files;
fileDefault();
fileChanged();
}
function fileChanged() {
let dropBlock = document.querySelector('.fileDrop-block');
let dropBlockStatus = dropBlock.querySelector('.status');
let dropBlockInput = dropBlock.querySelector('#file');
if (dropBlockInput.value !== "") {
dropBlock.classList.add('active');
dropBlockStatus.innerHTML = dropBlockInput.files[0].name;
} else {
fileDefault();
}
}
function clearUpload() {
let fileDrop = document.querySelector('#uploadForm');
let fileUpload = fileDrop.querySelector('#file');
let fileAlt = fileDrop.querySelector('#alt');
let fileDescription = fileDrop.querySelector('#description');
let fileTags = fileDrop.querySelector('#tags');
fileUpload.value = "";
fileAlt.value = "";
fileDescription.value = "";
fileTags.value = "";
}
// function createJob(file) {
// jobContainer = document.createElement("div");
// jobContainer.classList.add("job");
// jobStatus = document.createElement("span");
// jobStatus.classList.add("job__status");
// jobStatus.innerHTML = "Uploading...";
// jobProgress = document.createElement("span");
// jobProgress.classList.add("progress");
// jobImg = document.createElement("img");
// jobImg.src = URL.createObjectURL(file);
// jobImgFilter = document.createElement("span");
// jobImgFilter.classList.add("img-filter");
// jobContainer.appendChild(jobStatus);
// jobContainer.appendChild(jobProgress);
// jobContainer.appendChild(jobImg);
// jobContainer.appendChild(jobImgFilter);
// return jobContainer;
// }
document.addEventListener('DOMContentLoaded', () => {
// Function to upload images
let uploadTab = document.querySelector(".upload-panel");
if (!uploadTab) { return; } // If upload tab doesn't exist, don't run this code :3
let uploadTabDrag = uploadTab.querySelector("#dragIndicator");
let uploadForm = uploadTab.querySelector('#uploadForm');
// let jobList = document.querySelector(".upload-jobs");
let fileDrop = uploadForm.querySelector('.fileDrop-block');
let fileDropTitle = fileDrop.querySelector('.status');
let fileUpload = fileDrop.querySelector('#file');
let fileAlt = uploadForm.querySelector('#alt');
let fileDescription = uploadForm.querySelector('#description');
let fileTags = uploadForm.querySelector('#tags');
clearUpload();
fileDefault();
// Tab is dragged
uploadTabDrag.addEventListener('touchstart', tabDragStart, false);
uploadTabDrag.addEventListener('touchend', tabDragStopped, false);
// Drag over/enter event
fileDrop.addEventListener('dragover', fileActivate, false);
fileDrop.addEventListener('dragenter', fileActivate, false);
// Drag out
fileDrop.addEventListener('dragleave', fileDefault, false);
// Drop file into box
fileDrop.addEventListener('drop', fileDropHandle, false);
// File upload change
fileUpload.addEventListener('change', fileChanged, false);
// File upload clicked
fileUpload.addEventListener('click', fileDefault, false);
// Submit form
uploadForm.addEventListener('submit', (event) => {
// AJAX takes control of subby form
event.preventDefault()
if (fileUpload.value === "") {
fileDrop.classList.add('error');
fileDropTitle.innerHTML = 'No file selected!';
// Stop the function
return;
}
// Make form
let formData = new FormData();
formData.append("file", fileUpload.files[0]);
formData.append("alt", fileAlt.value);
formData.append("description", fileDescription.value);
formData.append("tags", fileTags.value);
// jobItem = createJob(fileUpload.files[0]);
// jobStatus = jobItem.querySelector(".job__status");
// Upload the information
// $.ajax({
// url: '/api/upload',
// type: 'post',
// data: formData,
// contentType: false,
// processData: false,
// beforeSend: function () {
// // Add job to list
// jobList.appendChild(jobItem);
// },
// success: function (response) {
// jobItem.classList.add("success");
// jobStatus.innerHTML = "Uploaded successfully";
// if (!document.querySelector(".upload-panel").classList.contains("open")) {
// addNotification("Image uploaded successfully", 1);
// }
// },
// error: function (response) {
// jobItem.classList.add("critical");
// switch (response.status) {
// case 500:
// jobStatus.innerHTML = "Server exploded, F's in chat";
// break;
// case 400:
// case 404:
// jobStatus.innerHTML = "Error uploading. Blame yourself";
// break;
// case 403:
// jobStatus.innerHTML = "None but devils play past here...";
// break;
// case 413:
// jobStatus.innerHTML = "File too large!!!!!!";
// break;
// default:
// jobStatus.innerHTML = "Error uploading file, blame someone";
// break;
// }
// if (!document.querySelector(".upload-panel").classList.contains("open")) {
// addNotification("Error uploading file", 2);
// }
// },
// });
fetch('/api/upload', {
method: 'POST',
body: formData
})
// .then(response => response.json())
.then(data => { addNotification("Image uploaded successfully", 1); })
.catch(error => {
switch (response.status) {
case 500:
addNotification("Server exploded, F's in chat", 2)
break;
case 400:
case 404:
addNotification("Error uploading. Blame yourself", 2)
break;
case 403:
addNotification("None but devils play past here...", 2)
break;
case 413:
addNotification("File too large!!!!!!", 2);
break;
default:
addNotification("Error uploading file, blame someone", 2)
break;
}
});
clearUpload();
// Reset drop
fileDrop.classList.remove('active');
fileDropTitle.innerHTML = 'Choose or Drop file';
});
});

View file

@ -0,0 +1,10 @@
function checkWebpSupport() {
let webpSupport = false;
try {
webpSupport = document.createElement('canvas').toDataURL('image/webp').indexOf('data:image/webp') === 0;
} catch (e) {
webpSupport = false;
}
return webpSupport;
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.1 KiB

View file

@ -0,0 +1,18 @@
{
"name": "OnlyLegs",
"short_name": "OnlyLegs",
"start_url": "/",
"display": "standalone",
"background_color": "#151515",
"theme_color": "#151515",
"description": "A gallery built for fast and simple image management!",
"icons": [
{
"src": "icon.png",
"sizes": "621x621",
"type": "image/png"
}
],
"splash_pages": null
}

View file

@ -0,0 +1,11 @@
@keyframes imgLoading
0%
background-position: -468px 0
100%
background-position: 468px 0
@keyframes uploadingLoop
0%
left: -100%
100%
left: 100%

View file

@ -0,0 +1,198 @@
.banner,
.banner-small
width: 100%
position: relative
color: RGB($fg-white)
.link
padding: 0.1rem 0.3rem
text-decoration: none
font-weight: 500
background-color: RGB($fg-white)
color: RGB($fg-black)
border-radius: $rad-inner
cursor: pointer
&:hover
background-color: RGB($fg-black)
color: RGB($fg-white)
&::after
content: ''
width: $rad
height: calc(#{$rad} * 2)
position: absolute
bottom: calc(#{$rad} * -2)
left: 0
background-color: RGB($bg-bright)
border-radius: $rad 0 0 0
box-shadow: 0 calc(#{$rad} * -1) 0 0 RGB($bg-100)
.banner
height: 30rem
background-color: RGB($bg-300)
img
position: absolute
inset: 0
width: 100%
height: 100%
background-color: inherit
object-fit: cover
object-position: center center
.banner-filter
position: absolute
inset: 0
width: 100%
height: 100%
background: linear-gradient(to right, RGB($primary), transparent)
z-index: +1
.banner-content
padding: 0.5rem
width: 100%
height: auto
position: absolute
left: 0
bottom: 0
display: grid
grid-template-columns: 1fr auto
grid-template-rows: 1fr auto auto
grid-template-areas: 'info info' 'header header' 'subtitle options'
z-index: +2
.banner-header,
.banner-info,
.banner-subtitle
margin: 0
padding: 0
width: 100%
.banner-header
grid-area: header
margin: 0.5rem 0
text-align: left
font-size: 6.9rem
font-weight: 700
color: RGB($primary)
.banner-info
grid-area: info
font-size: 1rem
font-weight: 400
.banner-subtitle
grid-area: subtitle
font-size: 1rem
font-weight: 400
.pill-row
margin-top: auto
grid-area: options
.banner-small
height: 3.5rem
background-color: RGB($bg-100)
.banner-content
padding: 0 0.5rem
width: 100%
height: 100%
position: absolute
inset: 0
display: flex
flex-direction: row
justify-content: flex-start
z-index: +2
.banner-header,
.banner-info
margin: auto 0
padding: 0
width: auto
height: auto
justify-self: flex-start
.banner-header
margin-right: 0.6rem
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
text-align: left
font-weight: 700
font-size: 1.5rem
color: RGB($primary)
.banner-info
margin-right: 0.6rem
font-size: 0.9rem
font-weight: 400
.pill-row
margin-left: auto
width: auto
@media (max-width: $breakpoint)
.banner,
.banner-small
&::after
display: none
.banner
min-height: 17rem
height: auto
.banner-content
padding: 0.5rem
height: 100%
display: flex
flex-direction: column
justify-content: center
align-items: center
.banner-header
margin: 1rem 0
text-align: center
font-size: 2.5rem
.banner-info
font-size: 1.1rem
text-align: center
.banner-subtitle
display: none
.pill-row
margin-top: 0rem
.banner-small
.banner-content
.banner-info
display: none

View file

@ -0,0 +1,178 @@
@mixin btn-block($color)
color: RGB($color)
box-shadow: 0 1px 0 RGBA($black, 0.2), 0 -1px 0 RGBA($white, 0.2)
&:hover, &:focus-visible
background-color: RGBA($color, 0.1)
color: RGB($color)
box-shadow: 0 1px 0 RGBA($black, 0.2), 0 -1px 0 RGBA($color, 0.2)
.btn-block
padding: 0.4rem 0.7rem
width: auto
min-height: 2.3rem
display: flex
justify-content: center
align-items: center
gap: 0.5rem
position: relative
font-size: 1rem
font-weight: 400
text-align: center
background-color: RGBA($white, 0.1)
color: RGB($white)
border: none
border-radius: $rad-inner
box-shadow: 0 1px 0 RGBA($black, 0.2), 0 -1px 0 RGBA($white, 0.2)
outline: none
cursor: pointer
transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out, box-shadow 0.15s ease-in-out
&:hover, &:focus-visible
background-color: RGBA($white, 0.2)
box-shadow: 0 1px 0 RGBA($black, 0.3), 0 -1px 0 RGBA($white, 0.3)
&.primary
@include btn-block($primary)
&.critical
@include btn-block($critical)
&.warning
@include btn-block($warning)
&.success
@include btn-block($success)
&.info
@include btn-block($info)
&.black
@include btn-block($black)
.input-checkbox
padding: 0
display: flex
justify-content: flex-start
align-items: center
gap: 0.5rem
position: relative
label
font-size: 1rem
font-weight: 400
text-align: left
color: RGB($fg-white)
.input-block
padding: 0.4rem 0.7rem
width: auto
min-height: 2.3rem
display: flex
justify-content: flex-start
align-items: center
position: relative
font-size: 1rem
font-weight: 400
text-align: left
background-color: RGBA($white, 0.1)
color: RGB($white)
border: none
border-bottom: 3px solid RGBA($white, 0.1)
border-radius: $rad-inner
box-shadow: 0 1px 0 RGBA($black, 0.2), 0 -1px 0 RGBA($white, 0.2)
outline: none
cursor: pointer
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out
&:not(:focus):not([value=""]):not(:placeholder-shown)
border-color: RGBA($white, 0.3)
&:hover
border-color: RGBA($white, 0.3)
&:focus
border-color: RGB($primary)
&.black
@include btn-block($black)
.fileDrop-block
padding: 1rem 1.25rem
width: 100%
min-height: 2.3rem
display: flex
flex-direction: column
justify-content: center
align-items: center
gap: 0.5rem
position: relative
font-size: 1rem
font-weight: 400
text-align: center
background-color: RGBA($white, 0.1)
color: RGB($white)
border: none
border-radius: $rad-inner
box-shadow: 0 1px 0 RGBA($black, 0.2), 0 -1px 0 RGBA($white, 0.2)
outline: none
cursor: pointer
overflow: hidden
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out, box-shadow 0.15s ease-in-out
input
position: absolute
inset: 0
opacity: 0
cursor: pointer
.status
width: 100%
white-space: nowrap
text-overflow: ellipsis
text-align: center
overflow: hidden
&:hover, &:focus-visible
background-color: RGBA($white, 0.2)
color: RGB($white)
box-shadow: 0 1px 0 RGBA($black, 0.3), 0 -1px 0 RGBA($white, 0.3)
&.active
background-color: RGBA($primary, 0.2)
color: RGB($primary)
box-shadow: 0 1px 0 RGBA($black, 0.3), 0 -1px 0 RGBA($primary, 0.3)
&.edging
background-color: RGBA($white, 0.2)
color: RGB($white)
box-shadow: 0 1px 0 RGBA($black, 0.3), 0 -1px 0 RGBA($white, 0.3)
input
display: none // So it doesnt get in the way of the drop as that breaks things
&.error
background-color: RGBA($critical, 0.2)
color: RGB($critical)
box-shadow: 0 1px 0 RGBA($black, 0.3), 0 -1px 0 RGBA($critical, 0.3)

View file

@ -0,0 +1,41 @@
.info-button
margin: 0
padding: 0
width: auto
height: auto
position: fixed
bottom: 0.75rem
right: -3rem
display: flex
justify-content: center
align-items: center
background-color: RGB($bg-300)
color: RGB($fg-white)
border-radius: $rad
border: none
opacity: 0
z-index: 20
cursor: pointer
transition: all 0.2s cubic-bezier(.86, 0, .07, 1)
&:hover
color: RGB($info)
svg
margin: 0.5rem
width: 1.25rem
height: 1.25rem
&.show
right: 0.75rem
opacity: 1
@media (max-width: $breakpoint)
.info-button
bottom: 4.25rem

View file

@ -0,0 +1,94 @@
.pill-row
margin: 0
padding: 0
width: 100%
height: auto
display: flex
justify-content: center
align-items: center
gap: 0.5rem
> div
margin: 0
padding: 0
display: flex
background-color: RGB($bg-200)
border-radius: $rad
box-shadow: 0 1px 0 RGB($bg-100), 0 -1px 0 RGB($bg-300)
.pill-text
margin: 0
padding: 0.5rem 1rem
width: auto
height: 2.5rem
display: flex
justify-content: center
align-items: center
position: relative
text-align: center
font-size: 1rem
font-weight: 400
background-color: RGB($bg-200)
color: RGB($fg-white)
border-radius: $rad
.pill-item
margin: 0
padding: 0.5rem
width: 2.5rem
height: 2.5rem
display: flex
justify-content: center
align-items: center
position: relative
border: none
background-color: transparent
color: RGB($fg-white)
svg
width: 1.25rem
height: 1.25rem
&:hover
cursor: pointer
color: RGB($primary)
.pill__critical
color: RGB($critical)
span
background: RGB($critical)
color: RGB($fg-white)
svg
color: RGB($critical)
&:hover
color: RGB($fg-white)
.pill__info
color: RGB($info)
span
color: RGB($info)
&:hover
color: RGB($fg-white)
@media (max-width: $breakpoint)
.tool-tip
display: none

View file

@ -0,0 +1,41 @@
.top-of-page
margin: 0
padding: 0
width: auto
height: auto
position: fixed
bottom: 0.75rem
right: -3rem
display: flex
justify-content: center
align-items: center
background-color: RGB($bg-300)
color: RGB($fg-white)
border-radius: $rad
border: none
opacity: 0
z-index: 20
cursor: pointer
transition: all 0.2s cubic-bezier(.86, 0, .07, 1)
&:hover
color: RGB($primary)
svg
margin: 0.5rem
width: 1.25rem
height: 1.25rem
&.show
right: 0.75rem
opacity: 1
@media (max-width: $breakpoint)
.top-of-page
bottom: 4.25rem

View file

@ -0,0 +1,241 @@
.gallery-grid
margin: 0
padding: 0.35rem
width: 100%
display: grid
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
.gallery-item
margin: 0.35rem
padding: 0
height: auto
position: relative
border-radius: $rad-inner
box-shadow: 0 0.15rem 0.4rem 0.1rem RGBA($bg-100, 0.4)
box-sizing: border-box
overflow: hidden
transition: box-shadow 0.2s cubic-bezier(.79, .14, .15, .86)
.image-filter
margin: 0
padding: 0.5rem
width: 100%
min-height: 30%
height: auto
position: absolute
left: 0
bottom: 0
display: flex
flex-direction: column
justify-content: flex-end
background-image: linear-gradient(to top, rgba($bg-100, 0.69), transparent)
opacity: 0 // hide
z-index: +4
transition: opacity 0.2s cubic-bezier(.79, .14, .15, .86)
.image-title,
.image-subtitle
margin: 0
padding: 0
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
color: RGB($fg-white)
text-shadow: 0px 0px 2px RGB($fg-black)
.image-title
font-size: 0.9rem
font-weight: 700
.image-subtitle
font-size: 0.8rem
font-weight: 400
img
width: 100%
height: 100%
position: absolute
inset: 0
object-fit: cover
object-position: center
background-color: RGB($bg-bright)
filter: blur(0.5rem)
opacity: 0
transition: all 0.2s cubic-bezier(.79, .14, .15, .86)
&.loaded
filter: blur(0)
opacity: 1
&:after
content: ""
display: block
padding-bottom: 100%
&:hover
box-shadow: 0 0.2rem 0.4rem 0.1rem RGBA($bg-100, 0.6)
.image-filter
opacity: 1
.group-item
margin: 0.35rem
padding: 0
height: auto
position: relative
border-radius: $rad-inner
box-sizing: border-box
overflow: hidden
.image-filter
margin: 0
padding: 0.5rem
width: 100%
min-height: 30%
height: auto
position: absolute
left: 0
bottom: 0
display: flex
flex-direction: column
justify-content: flex-end
background-image: linear-gradient(to top, rgba($bg-100, 0.8), transparent)
z-index: +4
.image-title,
.image-subtitle
margin: 0
padding: 0
white-space: nowrap
text-overflow: ellipsis
overflow: hidden
color: RGB($fg-white)
text-shadow: 0px 0px 2px RGB($fg-black)
.image-title
font-size: 0.9rem
font-weight: 700
.image-subtitle
font-size: 0.8rem
font-weight: 400
.images
margin: 0
padding: 0
width: 100%
height: 100%
position: absolute
inset: 0
display: block
img
width: 100%
height: 100%
position: absolute
top: 0
left: 0
object-fit: cover
object-position: center
background-color: RGB($bg-bright)
border-radius: $rad-inner
box-shadow: 0 0 0.4rem 0.25rem RGBA($bg-100, 0.1)
filter: blur(0.5rem)
opacity: 0
transition: all 0.2s cubic-bezier(.79, .14, .15, .86)
&.loaded
filter: blur(0)
opacity: 1
&.size-1
.data-1
transform: scale(0.8) rotate(3deg)
&.size-2
.data-1
transform: scale(0.7) rotate(-3deg) translate(10%, 10%)
z-index: +2
.data-2
transform: scale(0.7) rotate(3deg) translate(-10%, -10%)
z-index: +1
&.size-3
.data-1
transform: scale(0.6) rotate(3deg) translate(-25%, 25%)
z-index: +3
.data-2
transform: scale(0.6) rotate(-5deg) translate(25%, 10%)
z-index: +2
.data-3
transform: scale(0.6) rotate(-1deg) translate(-15%, -23%)
z-index: +1
&:after
content: ""
display: block
padding-bottom: 100%
&:hover
.images
&.size-1
.data-1
transform: scale(0.9) rotate(0deg)
&.size-2
.data-1
transform: scale(0.75) rotate(-1deg) translate(12%, 14%)
z-index: +2
.data-2
transform: scale(0.75) rotate(1deg) translate(-12%, -10%)
z-index: +1
&.size-3
.data-1
transform: scale(0.65) rotate(1deg) translate(-24%, 24%)
z-index: +3
.data-2
transform: scale(0.65) rotate(-2deg) translate(24%, 10%)
z-index: +2
.data-3
transform: scale(0.65) rotate(0deg) translate(-15%, -25%)
z-index: +1
@media (max-width: 800px)
.gallery-grid
grid-template-columns: auto auto auto

View file

@ -0,0 +1,42 @@
.background
width: 100%
height: 100vh
position: fixed
top: 0
left: 0
background-color: RGB($bg-300)
background-image: linear-gradient(to right, RGB($bg-400) 15%, RGB($bg-200) 35%, RGB($bg-400) 50%)
background-size: 1000px 640px
animation: imgLoading 1.8s linear infinite forwards
user-select: none
overflow: hidden
z-index: 1
img
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background-color: RGB($fg-white)
filter: blur(1rem) saturate(1.2)
transform: scale(1.1)
object-fit: cover
object-position: center center
span
position: absolute
top: 0
left: 0
width: 100%
height: 100%
z-index: +1

View file

@ -0,0 +1,21 @@
.image-container
margin: auto
padding: 0.5rem
width: 100%
height: 100%
display: flex
overflow: hidden
img
margin: auto
padding: 0
width: auto
height: auto
max-width: 100%
max-height: 100%
object-fit: contain
object-position: center

View file

@ -0,0 +1,215 @@
.info-container
width: 27rem
height: 100vh
position: absolute
top: 0
left: 0
display: flex
flex-direction: column
gap: 0
background-color: RGB($bg-200)
overflow-y: auto
z-index: +4
transition: left 0.3s cubic-bezier(0.76, 0, 0.17, 1)
&.collapsed
left: -27rem
.info-tab
width: 100%
display: flex
flex-direction: column
position: relative
background-color: RGB($bg-200)
border-radius: $rad
transition: max-height 0.3s cubic-bezier(.79, .14, .15, .86)
&.collapsed
height: 2.5rem
.collapse-indicator
transform: rotate(90deg)
.info-table
height: 0
padding: 0
.collapse-indicator
margin: 0
padding: 0
width: 1.25rem
height: 1.25rem
position: absolute
top: 0.6rem
right: 0.6rem
background-color: transparent
color: RGB($primary)
border: none
z-index: +2
transition: transform 0.15s cubic-bezier(.79, .14, .15, .86)
cursor: pointer
.info-header
margin: 0
padding: 0.5rem
width: 100%
height: 2.5rem
display: flex
justify-content: start
align-items: center
gap: 0.5rem
position: sticky
top: 0
z-index: +1
background-color: RGB($bg-200)
svg
margin: 0
padding: 0
width: 1.25rem
height: 1.25rem
fill: RGB($primary)
h2
margin: 0
padding: 0
font-size: 1.1rem
font-weight: 500
color: RGB($primary)
text-overflow: ellipsis
overflow: hidden
.info-table
margin: 0
padding: 0.5rem
display: flex
flex-direction: column
gap: 1rem
color: RGB($fg-white)
overflow-x: hidden
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 400
text-overflow: ellipsis
overflow: hidden
.link
margin: 0
padding: 0
color: RGB($primary)
cursor: pointer
text-decoration: none
&:hover
text-decoration: underline
table
margin: 0
padding: 0
width: 100%
overflow-x: hidden
border-collapse: collapse
tr
white-space: nowrap
td
padding-bottom: 0.5rem
max-width: 0
font-size: 1rem
font-weight: 400
vertical-align: top
td:first-child
padding-right: 0.5rem
width: 50%
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
td:last-child
width: 50%
white-space: normal
word-break: break-word
tr:last-of-type td
padding-bottom: 0
.img-colours
width: 100%
display: flex
gap: 0.5rem
span
margin: 0
padding: 0
width: 1.5rem
height: 1.5rem
display: flex
justify-content: center
align-items: center
border-radius: $rad-inner
// border: 1px solid RGB($white)
.img-groups
width: 100%
display: flex
flex-wrap: wrap
gap: 0.5rem
@media (max-width: 1100px)
.info-container
width: 100%
height: 100%
position: relative
display: flex
flex-direction: column
gap: 0.5rem
&.collapsed
left: unset

View file

@ -0,0 +1,81 @@
@import 'background'
@import 'info-tab'
@import 'image'
.image-grid
padding: 0
width: 100%
height: 100vh
position: relative
display: flex
flex-direction: column
gap: 0.5rem
z-index: 3
.image-block
margin: 0 0 0 27rem
padding: 0
width: calc(100% - 27rem)
height: 100vh
position: relative
display: flex
flex-direction: column
gap: 0
z-index: 3
transition: margin 0.3s cubic-bezier(0.76, 0, 0.17, 1), width 0.3s cubic-bezier(0.76, 0, 0.17, 1)
.pill-row
margin-bottom: 0.5rem
&.collapsed
.image-block
margin: 0
width: 100%
@media (max-width: 1100px)
.image-grid
padding: 0.5rem
height: auto
.image-block
margin: 0
width: 100%
height: auto
gap: 0.5rem
transition: margin 0s, width 0s
.image-container
margin: 0 auto
padding: 0
max-height: 69vh
img
max-height: 69vh
.pill-row
margin-bottom: 0
#fullscreenImage
display: none
.info-container
background: transparent
.info-header
border-radius: $rad $rad 0 0
.info-tab.collapsed .info-header
border-radius: $rad

View file

@ -0,0 +1,170 @@
.navigation
margin: 0
padding: 0
width: 3.5rem
height: 100%
height: 100dvh
display: flex
flex-direction: column
justify-content: space-between
position: fixed
top: 0
left: 0
background-color: RGB($bg-100)
color: RGB($fg-white)
z-index: 69
.logo
margin: 0
padding: 0
width: 3.5rem
height: 3.5rem
min-height: 3.5rem
display: flex
flex-direction: row
align-items: center
.navigation-spacer
height: 100%
.navigation-item
margin: 0
padding: 0
width: 3.5rem
height: 3.5rem
min-height: 3.5rem
position: relative
display: flex
flex-direction: row
justify-content: center
align-items: center
background-color: transparent
border: none
text-decoration: none
> svg
margin: 0
padding: 0.5rem
width: 2.5rem
height: 2.5rem
border-radius: $rad-inner
color: RGB($fg-white)
transition: color 0.2s ease-out, transform 0.2s ease-out
.tool-tip
margin: 0
padding: 0.4rem 0.7rem
display: block
position: absolute
top: 50%
left: 3rem
transform: translateY(-50%)
font-size: 0.9rem
font-weight: 500
background-color: RGB($bg-100)
color: RGB($fg-white)
opacity: 0
border-radius: $rad-inner
transition: opacity 0.2s cubic-bezier(.76,0,.17,1), left 0.2s cubic-bezier(.76,0,.17,1)
pointer-events: none
> svg
margin: 0
font-size: 1rem
width: 0.75rem
height: 0.75rem
display: block
position: absolute
top: 50%
left: -0.45rem
transform: translateY(-50%)
color: RGB($bg-100)
&:hover
> svg
background: RGBA($fg-white, 0.1)
span
opacity: 1
left: 3.9rem
&.selected
> svg
color: RGB($primary)
&::before
content: ''
display: block
position: absolute
top: 3px
left: 0
width: 3px
height: calc(100% - 6px)
background-color: RGB($primary)
border-radius: $rad-inner
@media (max-width: $breakpoint)
.navigation
width: 100vw
height: 3.5rem
flex-direction: row
justify-content: space-around
position: fixed
top: unset
bottom: 0
left: 0
> span
display: none
.logo
display: none
.navigation-item
margin: 0.25rem
padding: 0
width: 3rem
height: 3rem
min-height: 3rem
.tool-tip
display: none
&.selected::before
top: unset
bottom: 0
left: 0
width: 100%
height: 3px

View file

@ -0,0 +1,145 @@
@keyframes notificationTimeout
0%
left: -100%
height: 3px
90%
left: 0%
height: 3px
95%
left: 0%
height: 0
100%
left: 0%
height: 0
@mixin notification($color)
color: RGB($color)
&::after
background-color: RGB($color)
.notifications
margin: 0
padding: 0
width: 450px
height: auto
position: fixed
top: 0.3rem
right: 0.3rem
display: flex
flex-direction: column
z-index: 621
.sniffle__notification
margin: 0 0 0.3rem 0
padding: 0
width: 450px
height: auto
max-height: 100px
display: flex
flex-direction: row
position: relative
background-color: RGB($bg-300)
border-radius: $rad-inner
color: RGB($fg-white)
opacity: 0
transform: scale(0.8)
box-sizing: border-box
overflow: hidden
transition: all 0.25s ease-in-out, opacity 0.2s ease-in-out, transform 0.2s cubic-bezier(.68,-0.55,.27,1.55)
&::after
content: ""
width: 100%
height: 3px
position: absolute
bottom: 0px
left: 0px
background-color: RGB($fg-white)
z-index: +2
animation: notificationTimeout 5.1s linear
&.success
@include notification($success)
&.warning
@include notification($warning)
&.critical
@include notification($critical)
&.info
@include notification($info)
&.show
opacity: 1
transform: scale(1)
&.hide
margin: 0
max-height: 0
opacity: 0
transform: translateX(100%)
transition: all 0.4s ease-in-out, max-height 0.2s ease-in-out
.sniffle__notification-icon
margin: 0
padding: 1rem
width: auto
height: auto
display: flex
justify-content: center
align-items: center
background-color: RGB($bg-200)
svg
width: 1.25rem
height: 1.25rem
.sniffle__notification-text
margin: 0
padding: 1rem
width: auto
height: auto
display: flex
flex-direction: column
justify-self: center
align-self: center
font-size: 1rem
font-weight: 600
line-height: 1
text-align: left
@media (max-width: $breakpoint)
.notifications
width: calc(100vw - 0.6rem)
height: auto
.sniffle__notification
width: 100%
&.hide
opacity: 0
transform: translateY(-5rem)
.sniffle__notification-time
width: 100%

View file

@ -0,0 +1,165 @@
.pop-up
width: 100%
height: 100vh
position: fixed
inset: 0
display: none
background-color: $bg-transparent
opacity: 0
z-index: 101
transition: opacity 0.2s ease
.pop-up__click-off
width: 100%
height: 100vh
position: absolute
top: 0
left: 0
z-index: +1
.pop-up-wrapper
margin: 0
padding: 0
width: 621px
height: auto
position: absolute
bottom: 50%
left: 50%
transform: translate(-50%, 50%) scale(0.8)
display: flex
flex-direction: column
background-color: RGB($bg-200)
border-radius: $rad
overflow: hidden
z-index: +2
transition: transform 0.2s $animation-smooth
.pop-up-header
margin: 0
padding: 1rem
width: 100%
height: auto
display: flex
flex-direction: column
gap: 0.5rem
overflow-y: auto
overflow-x: hidden
text-size-adjust: auto
text-overflow: ellipsis
h2, h3
margin: 0
width: 100%
position: sticky
top: 0
font-size: 1.5rem
font-weight: 700
text-align: left
color: RGB($fg-white)
p
margin: 0
width: 100%
font-size: 1rem
font-weight: 400
text-align: left
color: RGB($fg-white)
svg
width: 1rem
height: 1rem
display: inline-block
vertical-align: middle
a, .link
color: RGB($primary)
cursor: pointer
text-decoration: none
&:hover
text-decoration: underline
img
margin: auto
padding: 0
width: auto
height: auto
max-width: 100%
max-height: 40vh
border-radius: $rad-inner
form
margin: 0
padding: 0
width: 100%
height: auto
display: flex
flex-direction: column
gap: 0.5rem
justify-content: center
.pop-up-controlls
margin: 0
padding: 0.25rem
width: 100%
height: auto
display: flex
flex-direction: row
justify-content: flex-end
gap: 0.25rem
background-color: RGB($bg-100)
&.active
opacity: 1
.pop-up-wrapper
transform: translate(-50%, 50%) scale(1)
@media (max-width: $breakpoint)
.pop-up
.pop-up-wrapper
width: calc(100% - 0.75rem)
max-height: 95vh
.pop-up-content
max-height: 100%
img
max-height: 50vh
.pop-up-controlls button
width: 100%
&.active
opacity: 1

View file

@ -0,0 +1,27 @@
.tag-icon
margin: 0
padding: 0.25rem 0.5rem
display: flex
align-items: center
justify-content: center
gap: 0.25rem
font-size: 0.9rem
font-weight: 500
text-decoration: none
border-radius: $rad-inner
border: none
background-color: RGBA($primary, 0.1)
color: RGB($primary)
cursor: pointer
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out
svg
width: 1.15rem
height: 1.15rem
&:hover
background-color: RGBA($primary, 0.3)

View file

@ -0,0 +1,224 @@
.upload-panel
position: fixed
left: 3.5rem
bottom: 0
display: none
width: calc(100% - 3.5rem)
height: 100vh
background-color: transparent
color: RGB($fg-white)
overflow: hidden
z-index: 68
transition: background-color 0.25s cubic-bezier(0.76, 0, 0.17, 1)
h3
margin: 0
padding: 0
font-size: 1.5rem
font-weight: 700
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 400
form
margin: 0
padding: 0
width: 100%
display: flex
flex-direction: column
align-items: center
gap: 0.5rem
input, button
width: 100%
.click-off
position: absolute
top: 0
left: 0
width: 100%
height: 100%
z-index: +1
.container
padding: 1rem
position: absolute
bottom: 0
left: -27rem
width: 27rem
height: 100%
display: flex
flex-direction: column
gap: 1rem
background-color: RGB($bg-200)
z-index: +2
transition: left 0.25s cubic-bezier(0.76, 0, 0.17, 1), bottom 0.25s cubic-bezier(0.76, 0, 0.17, 1)
#dragIndicator
display: none
position: absolute
top: 0
left: 0
width: 100%
height: 5rem
z-index: +1
&::after
content: ''
width: 8rem
height: 3px
position: absolute
top: 0.5rem
left: 50%
transform: translate(-50%, -50%)
background-color: RGB($bg-400)
border-radius: $rad-inner
.upload-jobs
display: flex
flex-direction: column
gap: 0.5rem
border-radius: $rad
overflow-y: auto
.job
width: 100%
height: 5rem
min-height: 5rem
position: relative
display: flex
align-items: center
gap: 0.5rem
background-color: RGB($bg-200)
border-radius: $rad
overflow: hidden
img
position: absolute
top: 0
left: 0
width: 100%
height: 5rem
object-fit: cover
.img-filter
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background-image: linear-gradient(to right, RGB($bg-100), transparent)
.job__status
margin: 0
padding: 0
position: absolute
top: 0.5rem
left: 0.5rem
font-size: 1rem
font-weight: 600
color: RGB($fg-white)
z-index: +3
transition: color 0.25s cubic-bezier(0.76, 0, 0.17, 1)
.progress
width: 100%
height: $rad-inner
position: absolute
bottom: 0
left: -100%
background-color: RGB($primary)
animation: uploadingLoop 1s cubic-bezier(0.76, 0, 0.17, 1) infinite
z-index: +5
transition: left 1s cubic-bezier(0.76, 0, 0.17, 1)
&.critical
.job__status, .progress
color: RGB($critical)
&.success
.job__status
color: RGB($success)
.progress
height: 0
animation: none
&.warning
.job__status, .progress
color: RGB($warning)
&.critical, &.success, &.warning
.progress
height: 0
&.open
background-color: $bg-transparent
.container
left: 0
@media (max-width: $breakpoint)
.upload-panel
width: 100%
height: calc(100vh - 3.5rem)
height: calc(100dvh - 3.5rem)
left: 0
bottom: 3.5rem
.container
width: 100%
height: 95%
left: 0
bottom: -100vh
border-radius: $rad $rad 0 0
#dragIndicator
display: block
&.open
.container
left: 0
bottom: 0

View file

@ -0,0 +1,137 @@
// Default theme for OnlyLegs by FluffyBean
// Mockup link: https://www.figma.com/file/IMZT5kZr3sAngrSHSGu5di/OnlyLegs?node-id=0%3A1
@import "variables"
@import "animations"
@import "components/notification"
@import "components/pop-up"
@import "components/upload-panel"
@import "components/tags"
@import "components/navigation"
@import "components/banner"
@import "components/gallery"
@import "components/buttons/top-of-page"
@import "components/buttons/info-button"
@import "components/buttons/pill"
@import "components/buttons/block"
@import "components/image-view/view"
// Reset
*
box-sizing: border-box
font-family: $font
scrollbar-color: RGB($primary) transparent
::-webkit-scrollbar
width: 0.5rem
::-webkit-scrollbar-track
background: RGB($bg-200)
::-webkit-scrollbar-thumb
background: RGB($primary)
::-webkit-scrollbar-thumb:hover
background: RGB($fg-white)
html, body
margin: 0
padding: 0
min-height: 100vh
max-width: 100vw
background-color: RGB($fg-white)
scroll-behavior: smooth
overflow-x: hidden
.wrapper
margin: 0
padding: 0 0 0 3.5rem
min-height: 100vh
display: flex
flex-direction: column
background-color: RGB($bg-bright)
color: RGB($bg-100)
.big-text
height: 20rem
display: flex
flex-direction: column
justify-content: center
align-items: center
color: RGB($bg-100)
h1
margin: 0 2rem
font-size: 4rem
font-weight: 900
text-align: center
p
margin: 0 2rem
max-width: 40rem
font-size: 1rem
font-weight: 400
text-align: center
.error-page
width: 100%
height: 100vh
display: flex
flex-direction: column
justify-content: center
align-items: center
background-color: RGB($bg-bright)
h1
margin: 0 2rem
font-size: 6.9rem
font-weight: 900
text-align: center
color: $primary
p
margin: 0 2rem
max-width: 40rem
font-size: 1.25rem
font-weight: 400
text-align: center
color: $fg-black
@media (max-width: $breakpoint)
.wrapper
padding: 0 0 3.5rem 0
.big-text
height: calc(75vh - 3.5rem)
h1
font-size: 3.5rem
.error-page
height: calc(100vh - 3.5rem)
h1
font-size: 4.5rem
p
max-width: 100%
font-size: 1rem

View file

@ -0,0 +1,76 @@
$bg-transparent: rgba(var(--bg-dim), 0.8)
$bg-dim: var(--bg-dim)
$bg-bright: var(--bg-bright)
$bg-100: var(--bg-100)
$bg-200: var(--bg-200)
$bg-300: var(--bg-300)
$bg-400: var(--bg-400)
$bg-500: var(--bg-500)
$bg-600: var(--bg-600)
$fg-dim: var(--fg-dim)
$fg-white: var(--fg-white)
$fg-black: var(--fg-black)
$black: var(--black)
$white: var(--white)
$red: var(--red)
$orange: var(--orange)
$yellow: var(--yellow)
$green: var(--green)
$blue: var(--blue)
$purple: var(--purple)
$primary: var(--primary)
$warning: var(--warning)
$critical: var(--critical)
$success: var(--success)
$info: var(--info)
$rad: var(--rad)
$rad-inner: var(--rad-inner)
$animation-smooth: var(--animation-smooth)
$animation-bounce: var(--animation-bounce)
$font: 'Rubik', sans-serif
$breakpoint: 800px
\:root
--bg-dim: 16, 16, 16
--bg-bright: 232, 227, 227
--bg-100: 21, 21, 21
--bg-200: #{red(adjust-color(rgb(21, 21, 21), $lightness: 2%)), green(adjust-color(rgb(21, 21, 21), $lightness: 2%)), blue(adjust-color(rgb(21, 21, 21), $lightness: 2%))}
--bg-300: #{red(adjust-color(rgb(21, 21, 21), $lightness: 4%)), green(adjust-color(rgb(21, 21, 21), $lightness: 4%)), blue(adjust-color(rgb(21, 21, 21), $lightness: 4%))}
--bg-400: #{red(adjust-color(rgb(21, 21, 21), $lightness: 6%)), green(adjust-color(rgb(21, 21, 21), $lightness: 6%)), blue(adjust-color(rgb(21, 21, 21), $lightness: 6%))}
--bg-500: #{red(adjust-color(rgb(21, 21, 21), $lightness: 8%)), green(adjust-color(rgb(21, 21, 21), $lightness: 8%)), blue(adjust-color(rgb(21, 21, 21), $lightness: 8%))}
--bg-600: #{red(adjust-color(rgb(21, 21, 21), $lightness: 10%)), green(adjust-color(rgb(21, 21, 21), $lightness: 10%)), blue(adjust-color(rgb(21, 21, 21), $lightness: 10%))}
--fg-dim: 102, 102, 102
--fg-white: 232, 227, 227
--fg-black: 16, 16, 16
--black: 21, 21, 21
--white: 232, 227, 227
--red: 182, 100, 103
--orange: 217, 140, 95
--yellow: 217, 188, 140
--green: 140, 151, 125
--blue: 141, 163, 185
--purple: 169, 136, 176
--primary: var(--green) // 183, 169, 151
--warning: var(--orange)
--critical: var(--red)
--success: var(--green)
--info: var(--blue)
--rad: 6px
--rad-inner: calc(var(--rad) / 2)
--animation-smooth: cubic-bezier(0.76, 0, 0.17, 1)
--animation-bounce: cubic-bezier(.68,-0.55,.27,1.55)
--breakpoint: 800px

View file

@ -0,0 +1,7 @@
{% extends 'layout.html' %}
{% block content %}
<span class="error-page">
<h1>{{error}}</h1>
<p>{{msg}}</p>
</span>
{% endblock %}

View file

@ -0,0 +1,296 @@
{% extends 'layout.html' %}
{% block nav_groups %}selected{% endblock %}
{% block head %}
{% if images %}
<meta name="theme-color" content="rgb({{ images.0.colours.0.0 }}{{ images.0.colours.0.1 }}{{ images.0.colours.0.2 }})"/>
{% endif %}
<script type="text/javascript">
function groupShare() {
try {
navigator.clipboard.writeText(window.location.href)
addNotification("Copied link!", 4);
} catch (err) {
addNotification("Failed to copy link! Are you on HTTP?", 2);
}
}
{% if current_user.id == group.author.id %}
function groupDelete() {
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'AAAAAAAAAA';
cancelBtn.onclick = popupDissmiss;
deleteBtn = document.createElement('button');
deleteBtn.classList.add('btn-block');
deleteBtn.classList.add('critical');
deleteBtn.innerHTML = 'No ragrats!';
deleteBtn.onclick = deleteConfirm;
popUpShow('Yeet!',
'Are you surrrre? This action is irreversible and very final.' +
' This wont delete the images, but it will remove them from this group.',
null,
[cancelBtn, deleteBtn]);
}
function deleteConfirm(event) {
// AJAX takes control of subby form :3
event.preventDefault();
let formID = {{ group.id }};
if (!formID) {
addNotification("Dont tamper with the JavaScript pls!", 3);
return;
}
// Make form
const formData = new FormData();
formData.append("group", formID);
fetch('{{ url_for('api.delete_group') }}', {
method: 'POST',
body: formData
}).then(response => {
if (response.status === 200) {
// Redirect to groups page
window.location.href = '{{ url_for('group.groups') }}';
} else {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here... Bad information', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
}).catch(error => {
addNotification('Error yeeting group!', 2);
});
}
function groupEdit() {
// Create elements
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'go baaaaack';
cancelBtn.onclick = popupDissmiss;
submitBtn = document.createElement('button');
submitBtn.classList.add('btn-block');
submitBtn.classList.add('primary');
submitBtn.innerHTML = 'Saveeee';
submitBtn.type = 'submit';
submitBtn.setAttribute('form', 'editForm');
// Create form
editForm = document.createElement('form');
editForm.id = 'editForm';
editForm.setAttribute('onsubmit', 'return edit(event);');
groupInput = document.createElement('input');
groupInput.classList.add('input-block');
groupInput.type = 'text';
groupInput.placeholder = 'Group ID';
groupInput.value = {{ group.id }};
groupInput.id = 'group';
imageInput = document.createElement('input');
imageInput.classList.add('input-block');
imageInput.type = 'text';
imageInput.placeholder = 'Image ID';
imageInput.id = 'image';
actionInput = document.createElement('input');
actionInput.classList.add('input-block');
actionInput.type = 'text';
actionInput.placeholder = 'add/remove';
actionInput.value = 'add';
actionInput.id = 'action';
editForm.appendChild(groupInput);
editForm.appendChild(imageInput);
editForm.appendChild(actionInput);
popUpShow(
'Nothing stays the same',
'Add, remove, or change, the power is in your hands...',
editForm,
[cancelBtn, submitBtn]
);
}
function edit(event) {
// AJAX takes control of subby form :3
event.preventDefault();
let formGroup = document.querySelector("#group").value;
let formImage = document.querySelector("#image").value;
let formAction = document.querySelector("#action").value;
if (!formGroup || !formImage || !formAction) {
addNotification("All values must be set!", 3);
return;
}
// Make form
const formData = new FormData();
formData.append("group", formGroup);
formData.append("image", formImage);
formData.append("action", formAction);
fetch('{{ url_for('api.modify_group') }}', {
method: 'POST',
body: formData
}).then(response => {
if (response.status === 200) {
addNotification('Group edited!!!', 1);
popupDissmiss();
} else {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here... Bad information', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
}).catch(error => {
addNotification('Error!!!!! Panic!!!!', 2);
});
}
{% endif %}
</script>
<style>
{% if images %}
.banner::after {
box-shadow: 0 calc(var(--rad) * -1) 0 0 rgb({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }});
}
.banner-content p {
color: {{ text_colour }} !important;
}
.banner-content h1 {
color: {{ text_colour }} !important;
}
.banner-content .link {
background-color: {{ text_colour }} !important;
color: rgb({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }}) !important;
}
.banner-content .link:hover {
background-color: rgb({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }}) !important;
color: {{ text_colour }} !important;
}
.banner-filter {
background: linear-gradient(90deg, rgb({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }}),
rgba({{ images.0.colours.1.0 }}, {{ images.0.colours.1.1 }}, {{ images.0.colours.1.2 }}, 0.3)) !important;
}
@media (max-width: 800px) {
.banner-filter {
background: linear-gradient(180deg, rgba({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }}, 1),
rgba({{ images.0.colours.1.0 }}, {{ images.0.colours.1.1 }}, {{ images.0.colours.1.2 }}, 0.5)) !important;
}
}
.navigation {
background-color: rgb({{ images.0.colours.0.0 }}, {{ images.0.colours.0.1 }}, {{ images.0.colours.0.2 }}) !important;
}
.navigation-item > svg {
fill: {{ text_colour }} !important;
color: {{ text_colour }} !important;
}
.navigation-item.selected::before {
background-color: {{ text_colour }} !important;
}
{% endif %}
</style>
{% endblock %}
{% block content %}
{% if images %}
<div class="banner">
<img src="{{ url_for('api.file', file_name=images.0.filename ) }}?r=prev" onload="imgFade(this)" style="opacity:0;" alt="{% if images.0.alt %}{{ images.0.alt }}{% else %}Group Banner{% endif %}"/>
<span class="banner-filter"></span>
<div class="banner-content">
<p class="banner-info"><a href="{{ url_for('profile.profile', id=group.author.id) }}" class="link">By {{ group.author.username }}</a></p>
<h1 class="banner-header">{{ group.name }}</h1>
<p class="banner-subtitle">{{ images|length }} Images · {{ group.description }}</p>
<div class="pill-row">
<div>
<button class="pill-item" onclick="groupShare()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path></svg>
</button>
</div>
{% if current_user.id == group.author.id %}
<div>
<button class="pill-item pill__critical" onclick="groupDelete()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
<button class="pill-item pill__critical" onclick="groupEdit()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"></path></svg>
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% else %}
<div class="banner-small">
<div class="banner-content">
<h1 class="banner-header">{{ group.name }}</h1>
<p class="banner-info">By {{ group.author.username }}</p>
<div class="pill-row">
<div>
<button class="pill-item" onclick="groupShare()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path></svg>
</button>
</div>
{% if current_user.id == group.author.id %}
<div>
<button class="pill-item pill__critical" onclick="groupDelete()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
<button class="pill-item pill__critical" onclick="groupEdit()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"></path></svg>
</button>
</div>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if images %}
<div class="gallery-grid">
{% for image in images %}
<a id="image-{{ image.id }}" class="gallery-item" href="{{ url_for('group.group_post', group_id=group.id, image_id=image.id) }}" style="background-color: rgb({{ image.colours.0.0 }}, {{ image.colours.0.1 }}, {{ image.colours.0.2 }})">
<div class="image-filter">
<p class="image-subtitle"></p>
<p class="image-title"><span class="time">{{ image.created_at }}</span></p>
</div>
<img alt="{% if image.alt %}{{ image.alt }}{% else %}Image Thumbnail{% endif %}" data-src="{{ url_for('api.file', file_name=image.filename) }}?r=thumb" onload="this.classList.add('loaded');" id="lazy-load"/>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>*crickets chirping*</h1>
{% if current_user.is_authenticated %}
<p>Add some images to the group!</p>
{% else %}
<p>Login to start managing this image group!</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,249 @@
{% extends 'layout.html' %}
{% block page_index %}
{% if return_page %}?page={{ return_page }}{% endif %}{% endblock %}
{% block head %}
<meta property="og:image" content="{{ url_for('api.file', file_name=image.filename) }}"/>
<meta name="theme-color" content="rgb({{ image.colours.0.0 }}{{ image.colours.0.1 }}{{ image.colours.0.2 }})"/>
<script type="text/javascript">
function imageShare() {
try {
navigator.clipboard.writeText(window.location.href)
addNotification("Copied link!", 4);
} catch (err) {
addNotification("Failed to copy link! Are you on HTTP?", 2);
}
}
function fullscreen() {
let info = document.querySelector('.info-container');
let wrapper = document.querySelector('.image-grid');
if (info.classList.contains('collapsed')) {
info.classList.remove('collapsed');
wrapper.classList.remove('collapsed');
} else {
info.classList.add('collapsed');
wrapper.classList.add('collapsed');
}
}
{% if current_user.id == image.author.id %}
function imageDelete() {
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'nuuuuuuuu';
cancelBtn.onclick = popupDissmiss;
deleteBtn = document.createElement('button');
deleteBtn.classList.add('btn-block');
deleteBtn.classList.add('critical');
deleteBtn.innerHTML = 'Dewww eeeet!';
deleteBtn.onclick = deleteConfirm;
popUpShow('DESTRUCTION!!!!!!',
'Do you want to delete this image along with all of its data??? ' +
'This action is irreversible!',
null,
[cancelBtn, deleteBtn]);
}
function deleteConfirm() {
popupDissmiss();
fetch('{{ url_for('api.delete_image', image_id=image['id']) }}', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
action: 'delete'
})
}).then(function(response) {
if (response.ok) {
window.location.href = '/';
} else {
addNotification(`Image *clings*`, 2);
}
});
}
function imageEdit() {
addNotification("Not an option, oops!", 3);
}
{% endif %}
</script>
<style>
.background span {
background-image: linear-gradient(to top, rgba({{ image.colours.0.0 }}, {{ image.colours.0.1 }}, {{ image.colours.0.2 }}, 0.8),
rgba({{ image.colours.1.0 }}, {{ image.colours.1.1 }}, {{ image.colours.1.2 }}, 0.2));
}
</style>
{% endblock %}
{% block content %}
<div class="background">
<img src="{{ url_for('api.file', file_name=image.filename) }}?r=prev" alt="{{ image.alt }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
</div>
<div class="image-grid">
<div class="image-block">
<div class="image-container">
<img
src="{{ url_for('api.file', file_name=image.filename) }}?r=prev"
alt="{{ image.alt }}"
onload="imgFade(this)"
style="opacity: 0;"
onerror="this.src='{{ url_for('static', filename='error.png')}}'"
{% if "File" in image.exif %}
width="{{ image.exif.File.Width.raw }}"
height="{{ image.exif.File.Height.raw }}"
{% endif %}
/>
</div>
<div class="pill-row">
{% if next_url %}
<div>
<a class="pill-item" href="{{ next_url }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"></path></svg>
</a>
</div>
{% endif %}
<div>
<button class="pill-item" onclick="fullscreen()" id="fullscreenImage">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>
</button>
<button class="pill-item" onclick="imageShare()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,112v96a16,16,0,0,1-16,16H56a16,16,0,0,1-16-16V112A16,16,0,0,1,56,96H80a8,8,0,0,1,0,16H56v96H200V112H176a8,8,0,0,1,0-16h24A16,16,0,0,1,216,112ZM93.66,69.66,120,43.31V136a8,8,0,0,0,16,0V43.31l26.34,26.35a8,8,0,0,0,11.32-11.32l-40-40a8,8,0,0,0-11.32,0l-40,40A8,8,0,0,0,93.66,69.66Z"></path></svg>
</button>
<a class="pill-item" href="/api/file/{{ image.filename }}" download onclick="addNotification('Download started!', 4)">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM200,216H56V40h88V88a8,8,0,0,0,8,8h48V216Zm-42.34-61.66a8,8,0,0,1,0,11.32l-24,24a8,8,0,0,1-11.32,0l-24-24a8,8,0,0,1,11.32-11.32L120,164.69V120a8,8,0,0,1,16,0v44.69l10.34-10.35A8,8,0,0,1,157.66,154.34Z"></path></svg>
</a>
</div>
{% if current_user.id == image.author.id %}
<div>
<button class="pill-item pill__critical" onclick="imageDelete()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,48H176V40a24,24,0,0,0-24-24H104A24,24,0,0,0,80,40v8H40a8,8,0,0,0,0,16h8V208a16,16,0,0,0,16,16H192a16,16,0,0,0,16-16V64h8a8,8,0,0,0,0-16ZM96,40a8,8,0,0,1,8-8h48a8,8,0,0,1,8,8v8H96Zm96,168H64V64H192ZM112,104v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Zm48,0v64a8,8,0,0,1-16,0V104a8,8,0,0,1,16,0Z"></path></svg>
</button>
<button class="pill-item pill__critical" onclick="imageEdit()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM92.69,208H48V163.31l88-88L180.69,120ZM192,108.68,147.31,64l24-24L216,84.68Z"></path></svg>
</button>
</div>
{% endif %}
{% if prev_url %}
<div>
<a class="pill-item" href="{{ prev_url }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"></path></svg>
</a>
</div>
{% endif %}
</div>
</div>
<div class="info-container">
<div class="info-tab">
<div class="info-header">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"></path></svg>
<h2>Info</h2>
<button class="collapse-indicator">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -8 24 24" fill="currentColor">
<path d="M7.071 5.314l4.95-4.95a1 1 0 1 1 1.414 1.414L7.778 7.435a1 1 0 0 1-1.414 0L.707 1.778A1 1 0 1 1 2.121.364l4.95 4.95z"></path>
</svg>
</button>
</div>
<div class="info-table">
<table>
<tr>
<td>Author</td>
<td><a href="{{ url_for('profile.profile', id=image.author.id) }}" class="link">{{ image.author.username }}</a></td>
</tr>
<tr>
<td>Upload date</td>
<td><span class="time">{{ image.created_at }}</span></td>
</tr>
{% if image.description %}
<tr>
<td>Description</td>
<td>{{ image.description }}</td>
</tr>
{% endif %}
</table>
<div class="img-colours">
{% for col in image.colours %}
<span style="background-color: rgb({{col.0}}, {{col.1}}, {{col.2}})"></span>
{% endfor %}
</div>
{% if image.groups %}
<div class="img-groups">
{% for group in image.groups %}
<a href="{{ url_for('group.group', group_id=group.id) }}" class="tag-icon">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"></path></svg>
{{ group['name'] }}
</a>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% for tag in image.exif %}
<div class="info-tab">
<div class="info-header">
{% if tag == 'Photographer' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M160,40a32,32,0,1,0-32,32A32,32,0,0,0,160,40ZM128,56a16,16,0,1,1,16-16A16,16,0,0,1,128,56Zm90.34,78.05L173.17,82.83a32,32,0,0,0-24-10.83H106.83a32,32,0,0,0-24,10.83L37.66,134.05a20,20,0,0,0,28.13,28.43l16.3-13.08L65.55,212.28A20,20,0,0,0,102,228.8l26-44.87,26,44.87a20,20,0,0,0,36.41-16.52L173.91,149.4l16.3,13.08a20,20,0,0,0,28.13-28.43Zm-11.51,16.77a4,4,0,0,1-5.66,0c-.21-.2-.42-.4-.65-.58L165,121.76A8,8,0,0,0,152.26,130L175.14,217a7.72,7.72,0,0,0,.48,1.35,4,4,0,1,1-7.25,3.38,6.25,6.25,0,0,0-.33-.63L134.92,164a8,8,0,0,0-13.84,0L88,221.05a6.25,6.25,0,0,0-.33.63,4,4,0,0,1-2.26,2.07,4,4,0,0,1-5-5.45,7.72,7.72,0,0,0,.48-1.35L103.74,130A8,8,0,0,0,91,121.76L55.48,150.24c-.23.18-.44.38-.65.58a4,4,0,1,1-5.66-5.65c.12-.12.23-.24.34-.37L94.83,93.41a16,16,0,0,1,12-5.41h42.34a16,16,0,0,1,12,5.41l45.32,51.39c.11.13.22.25.34.37A4,4,0,0,1,206.83,150.82Z"></path></svg>
<h2>Photographer</h2>
{% elif tag == 'Camera' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,56H180.28L166.65,35.56A8,8,0,0,0,160,32H96a8,8,0,0,0-6.65,3.56L75.71,56H48A24,24,0,0,0,24,80V192a24,24,0,0,0,24,24H208a24,24,0,0,0,24-24V80A24,24,0,0,0,208,56Zm8,136a8,8,0,0,1-8,8H48a8,8,0,0,1-8-8V80a8,8,0,0,1,8-8H80a8,8,0,0,0,6.66-3.56L100.28,48h55.43l13.63,20.44A8,8,0,0,0,176,72h32a8,8,0,0,1,8,8ZM128,88a44,44,0,1,0,44,44A44.05,44.05,0,0,0,128,88Zm0,72a28,28,0,1,1,28-28A28,28,0,0,1,128,160Z"></path></svg>
<h2>Camera</h2>
{% elif tag == 'Software' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M24,96v72a8,8,0,0,0,8,8h80a8,8,0,0,1,0,16H96v16h16a8,8,0,0,1,0,16H64a8,8,0,0,1,0-16H80V192H32A24,24,0,0,1,8,168V96A24,24,0,0,1,32,72h80a8,8,0,0,1,0,16H32A8,8,0,0,0,24,96ZM208,64H176a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm0,32H176a8,8,0,0,0,0,16h32a8,8,0,0,0,0-16Zm40-48V208a16,16,0,0,1-16,16H152a16,16,0,0,1-16-16V48a16,16,0,0,1,16-16h80A16,16,0,0,1,248,48ZM232,208V48H152V208h80Zm-40-40a12,12,0,1,0,12,12A12,12,0,0,0,192,168Z"></path></svg>
<h2>Software</h2>
{% elif tag == 'File' %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M110.66,147.56a8,8,0,0,0-13.32,0L76.49,178.85l-9.76-15.18a8,8,0,0,0-13.46,0l-36,56A8,8,0,0,0,24,232H152a8,8,0,0,0,6.66-12.44ZM38.65,216,60,182.79l9.63,15a8,8,0,0,0,13.39.11l21-31.47L137.05,216Zm175-133.66-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v96a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48V216h-8a8,8,0,0,0,0,16h8a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160Z"></path></svg>
<h2>File</h2>
{% else %}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M110.66,147.56a8,8,0,0,0-13.32,0L76.49,178.85l-9.76-15.18a8,8,0,0,0-13.46,0l-36,56A8,8,0,0,0,24,232H152a8,8,0,0,0,6.66-12.44ZM38.65,216,60,182.79l9.63,15a8,8,0,0,0,13.39.11l21-31.47L137.05,216Zm175-133.66-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40v96a8,8,0,0,0,16,0V40h88V88a8,8,0,0,0,8,8h48V216h-8a8,8,0,0,0,0,16h8a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160Z"></path></svg>
<h2>{{ tag }}</h2>
{% endif %}
<button class="collapse-indicator">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -8 24 24" fill="currentColor">
<path d="M7.071 5.314l4.95-4.95a1 1 0 1 1 1.414 1.414L7.778 7.435a1 1 0 0 1-1.414 0L.707 1.778A1 1 0 1 1 2.121.364l4.95 4.95z"></path>
</svg>
</button>
</div>
<div class="info-table">
<table>
{% for subtag in image.exif[tag] %}
<tr>
<td>{{ subtag }}</td>
{% if image.exif[tag][subtag]['formatted'] %}
{% if image.exif[tag][subtag]['type'] == 'date' %}
<td><span class="time">{{ image.exif[tag][subtag]['formatted'] }}</span></td>
{% else %}
<td>{{ image.exif[tag][subtag]['formatted'] }}</td>
{% endif %}
{% elif image.exif[tag][subtag]['raw'] %}
<td>{{ image.exif[tag][subtag]['raw'] }}</td>
{% else %}
<td class="empty-table">Oops, an error</td>
{% endif %}
</tr>
{% endfor %}
</table>
</div>
</div>
{% endfor %}
</div>
</div>
{% endblock %}
{% block script %}
<script type="text/javascript">
let infoTab = document.querySelectorAll('.info-tab');
for (let i = 0; i < infoTab.length; i++) {
infoTab[i].querySelector('.collapse-indicator').addEventListener('click', function() {
infoTab[i].classList.toggle('collapsed');
});
}
</script>
{% endblock %}

View file

@ -0,0 +1,65 @@
{% extends 'layout.html' %}
{% block nav_home %}selected{% endblock %}
{% block content %}
<div class="banner-small">
<div class="banner-content">
<h1 class="banner-header">{{ config.WEBSITE_CONF.name }}</h1>
{% if total_images == 0 %}
<p class="banner-info">0 images D:</p>
{% elif total_images == 69 %}
<p class="banner-info">{{ total_images }} images, nice</p>
{% else %}
<p class="banner-info">{{ total_images }} images</p>
{% endif %}
{% if pages > 1 %}
<div class="pill-row">
<div>
{% if pages > 4 %}
<a class="pill-item" href="{{ url_for('gallery.index') }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M232,128a8,8,0,0,1-8,8H91.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L91.31,120H224A8,8,0,0,1,232,128ZM40,32a8,8,0,0,0-8,8V216a8,8,0,0,0,16,0V40A8,8,0,0,0,40,32Z"></path></svg>
</a>
{% endif %}
<a class="pill-item" href="{% if (page - 1) > 1 %} {{ url_for('gallery.index', page=page-1) }} {% else %} {{ url_for('gallery.index') }} {% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"></path></svg>
</a>
</div>
<span class="pill-text">
{{ page }} / {{ pages }}
</span>
<div>
<a class="pill-item" href="{% if (page + 1) < pages %} {{ url_for('gallery.index', page=page+1) }} {% else %} {{ url_for('gallery.index', page=pages) }} {% endif %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"></path></svg>
</a>
{% if pages > 4 %}
<a class="pill-item" href="{{ url_for('gallery.index', page=pages) }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M189.66,122.34a8,8,0,0,1,0,11.32l-72,72a8,8,0,0,1-11.32-11.32L164.69,136H32a8,8,0,0,1,0-16H164.69L106.34,61.66a8,8,0,0,1,11.32-11.32ZM216,32a8,8,0,0,0-8,8V216a8,8,0,0,0,16,0V40A8,8,0,0,0,216,32Z"></path></svg>
</a>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
{% if images %}
<div class="gallery-grid">
{% for image in images %}
<a id="image-{{ image.id }}" class="gallery-item" href="{{ url_for('image.image', image_id=image.id) }}" style="background-color: rgb({{ image.colours.0.0 }}, {{ image.colours.0.1 }}, {{ image.colours.0.2 }})">
<div class="image-filter">
<p class="image-subtitle"></p>
<p class="image-title"><span class="time">{{ image.created_at }}</span></p>
</div>
<img fetchpriority="low" alt="{% if image.alt %}{{ image.alt }}{% else %}Image Thumbnail{% endif %}" data-src="{{ url_for('api.file', file_name=image.filename) }}?r=thumb" onload="this.classList.add('loaded');" id="lazy-load"/>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>*crickets chirping*</h1>
<p>There are no images here yet, upload some!</p>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,167 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ config.WEBSITE_CONF.name }}</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="description" content="{{ config.WEBSITE_CONF.motto }}"/>
<meta name="author" content="{{ config.WEBSITE_CONF.author }}"/>
<meta property="og:title" content="{{ config.WEBSITE_CONF.name }}"/>
<meta property="og:description" content="{{ config.WEBSITE_CONF.motto }}"/>
<meta property="og:type" content="website"/>
<meta name="twitter:title" content="{{ config.WEBSITE_CONF.name }}"/>
<meta name="twitter:description" content="{{ config.WEBSITE_CONF.motto }}"/>
<meta name="twitter:card" content="summary_large_image">
<link rel="manifest" href="static/manifest.json"/>
<link
href="{{url_for('static', filename='logo-black.svg')}}"
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: light)"/>
<link
href="{{url_for('static', filename='logo-white.svg')}}"
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"/>
<link
rel="prefetch"
href="{{url_for('static', filename='fonts/font.css')}}"
type="stylesheet"/>
{% assets "scripts" %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% assets "styles" %}
<link rel="stylesheet" href="{{ ASSET_URL }}" type="text/css" defer>
{% endassets %}
{% block head %}{% endblock %}
</head>
<body>
<div class="notifications"></div>
<button class="top-of-page" aria-label="Jump to top of page">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M184,216a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,216Zm45.66-101.66-96-96a8,8,0,0,0-11.32,0l-96,96A8,8,0,0,0,32,128H72v24a8,8,0,0,0,8,8h96a8,8,0,0,0,8-8V128h40a8,8,0,0,0,5.66-13.66ZM176,176H80a8,8,0,0,0,0,16h96a8,8,0,0,0,0-16Z"></path></svg>
</button>
{% if request.path == "/" %}
<button class="info-button" aria-label="Show info on gallery">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M140,180a12,12,0,1,1-12-12A12,12,0,0,1,140,180ZM128,72c-22.06,0-40,16.15-40,36v4a8,8,0,0,0,16,0v-4c0-11,10.77-20,24-20s24,9,24,20-10.77,20-24,20a8,8,0,0,0-8,8v8a8,8,0,0,0,16,0v-.72c18.24-3.35,32-17.9,32-35.28C168,88.15,150.06,72,128,72Zm104,56A104,104,0,1,1,128,24,104.11,104.11,0,0,1,232,128Zm-16,0a88,88,0,1,0-88,88A88.1,88.1,0,0,0,216,128Z"></path></svg>
</button>
{% endif %}
<div class="pop-up">
<span class="pop-up__click-off" onclick="popupDissmiss()"></span>
<div class="pop-up-wrapper">
<div class="pop-up-header"></div>
<div class="pop-up-controlls"></div>
</div>
</div>
<div class="wrapper">
<div class="navigation">
<!--<img src="{{url_for('static', filename='icon.png')}}" alt="Logo" class="logo" onload="this.style.opacity=1;" style="opacity:0">-->
<a href="{{ url_for('gallery.index') }}{% block page_index %}{% endblock %}" class="navigation-item {% block nav_home %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,32H80A16,16,0,0,0,64,48V64H48A16,16,0,0,0,32,80V208a16,16,0,0,0,16,16H176a16,16,0,0,0,16-16V192h16a16,16,0,0,0,16-16V48A16,16,0,0,0,208,32ZM80,48H208v69.38l-16.7-16.7a16,16,0,0,0-22.62,0L93.37,176H80Zm96,160H48V80H64v96a16,16,0,0,0,16,16h96ZM104,88a16,16,0,1,1,16,16A16,16,0,0,1,104,88Z"></path></svg>
<span class="tool-tip">
Home
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</a>
<a href="{{ url_for('group.groups') }}" class="navigation-item {% block nav_groups %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M245,110.64A16,16,0,0,0,232,104H216V88a16,16,0,0,0-16-16H130.67L102.94,51.2a16.14,16.14,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V208h0a8,8,0,0,0,8,8H211.1a8,8,0,0,0,7.59-5.47l28.49-85.47A16.05,16.05,0,0,0,245,110.64ZM93.34,64l27.73,20.8a16.12,16.12,0,0,0,9.6,3.2H200v16H146.43a16,16,0,0,0-8.88,2.69l-20,13.31H69.42a15.94,15.94,0,0,0-14.86,10.06L40,166.46V64Z"></path></svg>
<span class="tool-tip">
Groups
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</a>
{% if current_user.is_authenticated %}
<button class="navigation-item {% block nav_upload %}{% endblock %}" onclick="toggleUploadTab()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M74.34,77.66a8,8,0,0,1,0-11.32l48-48a8,8,0,0,1,11.32,0l48,48a8,8,0,0,1-11.32,11.32L136,43.31V128a8,8,0,0,1-16,0V43.31L85.66,77.66A8,8,0,0,1,74.34,77.66ZM240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16h68a4,4,0,0,1,4,4v3.46c0,13.45,11,24.79,24.46,24.54A24,24,0,0,0,152,128v-4a4,4,0,0,1,4-4h68A16,16,0,0,1,240,136Zm-40,32a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"></path></svg>
<span class="tool-tip">
Upload
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</button>
{% endif %}
<span class="navigation-spacer"></span>
{% if current_user.is_authenticated %}
<a href="{{ url_for('profile.profile') }}" class="navigation-item {% block nav_profile %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M231.73,221.94A8,8,0,0,1,224,232H160A8,8,0,0,1,152.27,222a40,40,0,0,1,17.11-23.33,32,32,0,1,1,45.24,0A40,40,0,0,1,231.73,221.94ZM216,72H130.67L102.93,51.2a16.12,16.12,0,0,0-9.6-3.2H40A16,16,0,0,0,24,64V200a16,16,0,0,0,16,16h80a8,8,0,0,0,0-16H40V64H93.33l27.74,20.8a16.12,16.12,0,0,0,9.6,3.2H216v32a8,8,0,0,0,16,0V88A16,16,0,0,0,216,72Z"></path></svg>
<span class="tool-tip">
Profile
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</a>
<a href="{{ url_for('settings.general') }}" class="navigation-item {% block nav_settings %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,130.16q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.6,107.6,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.29,107.29,0,0,0-26.25-10.86,8,8,0,0,0-7.06,1.48L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.6,107.6,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z"></path></svg>
<span class="tool-tip">
Settings
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</a>
{% else %}
<button class="navigation-item {% block nav_login %}{% endblock %}" onclick="showLogin()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M141.66,133.66l-40,40A8,8,0,0,1,88,168V136H24a8,8,0,0,1,0-16H88V88a8,8,0,0,1,13.66-5.66l40,40A8,8,0,0,1,141.66,133.66ZM192,32H136a8,8,0,0,0,0,16h56V208H136a8,8,0,0,0,0,16h56a16,16,0,0,0,16-16V48A16,16,0,0,0,192,32Z"></path></svg>
<span class="tool-tip">
Login
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M168,48V208a8,8,0,0,1-13.66,5.66l-80-80a8,8,0,0,1,0-11.32l80-80A8,8,0,0,1,168,48Z"></path></svg>
</span>
</button>
{% endif %}
</div>
{% if current_user.is_authenticated %}
<div class="upload-panel">
<span class="click-off" onclick="closeUploadTab()"></span>
<div class="container">
<span id="dragIndicator"></span>
<h3>Upload stuffs</h3>
<p>May the world see your stuff 👀</p>
<form id="uploadForm">
<button class="fileDrop-block" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H80a8,8,0,0,1,0,16H32v64H224V136H176a8,8,0,0,1,0-16h48A16,16,0,0,1,240,136ZM85.66,77.66,120,43.31V128a8,8,0,0,0,16,0V43.31l34.34,34.35a8,8,0,0,0,11.32-11.32l-48-48a8,8,0,0,0-11.32,0l-48,48A8,8,0,0,0,85.66,77.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"></path></svg>
<span class="status">Choose or Drop file</span>
<input type="file" id="file" tab-index="-1"/>
</button>
<input class="input-block" type="text" placeholder="alt" id="alt"/>
<input class="input-block" type="text" placeholder="description" id="description"/>
<input class="input-block" type="text" placeholder="tags" id="tags"/>
<button class="btn-block primary" type="submit">Upload</button>
</form>
<div class="upload-jobs"></div>
</div>
</div>
{% endif %}
<div class="content">
{% block content %}
{% endblock %}
</div>
</div>
<script type="text/javascript">
// Show notifications on page load
{% for message in get_flashed_messages() %}
addNotification('{{ message[0] }}', {{ message[1] }});
{% endfor %}
</script>
{% block script %}{% endblock %}
</body>
</html>

View file

@ -0,0 +1,149 @@
{% extends 'layout.html' %}
{% block nav_groups %}selected{% endblock %}
{% block head %}
{% if images %}
<meta name="theme-color" content="rgb({{ images.0.colours.0.0 }}{{ images.0.colours.0.1 }}{{ images.0.colours.0.2 }})"/>
{% endif %}
{% if current_user.is_authenticated %}
<script type="text/javascript">
function showCreate() {
// Create elements
cancelBtn = document.createElement('button');
cancelBtn.classList.add('btn-block');
cancelBtn.innerHTML = 'nuuuuuuuu';
cancelBtn.onclick = popupDissmiss;
submitBtn = document.createElement('button');
submitBtn.classList.add('btn-block');
submitBtn.classList.add('primary');
submitBtn.innerHTML = 'Submit!!';
submitBtn.type = 'submit';
submitBtn.setAttribute('form', 'createForm');
// Create form
createForm = document.createElement('form');
createForm.id = 'createForm';
createForm.setAttribute('onsubmit', 'return create(event);');
titleInput = document.createElement('input');
titleInput.classList.add('input-block');
titleInput.type = 'text';
titleInput.placeholder = 'Group namey';
titleInput.id = 'name';
descriptionInput = document.createElement('input');
descriptionInput.classList.add('input-block');
descriptionInput.type = 'text';
descriptionInput.placeholder = 'What it about????';
descriptionInput.id = 'description';
createForm.appendChild(titleInput);
createForm.appendChild(descriptionInput);
popUpShow(
'New stuff!',
'Image groups are a simple way to "group" images together, are you ready?',
createForm,
[cancelBtn, submitBtn]
);
}
function create(event) {
// AJAX takes control of subby form :3
event.preventDefault();
let formName = document.querySelector("#name").value;
let formDescription = document.querySelector("#description").value;
if (!formName) {
addNotification("Group name must be set!", 3);
return;
}
// Make form
const formData = new FormData();
formData.append("name", formName);
formData.append("description", formDescription);
fetch('{{ url_for('api.create_group') }}', {
method: 'POST',
body: formData
}).then(response => {
if (response.status === 200) {
addNotification('Group created!', 1);
popupDissmiss();
} else {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here... Bad information', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
}).catch(error => {
addNotification('Error making group! :c', 2);
});
}
</script>
{% endif %}
{% endblock %}
{% block content %}
<div class="banner-small">
<div class="banner-content">
<h1 class="banner-header">{{ config.WEBSITE_CONF.name }}</h1>
{% if groups|length == 0 %}
<p class="banner-info">No groups!!!!</p>
{% elif groups|length == 69 %}
<p class="banner-info">{{ groups|length }} groups, uwu</p>
{% else %}
<p class="banner-info">{{ groups|length }} groups</p>
{% endif %}
{% if current_user.is_authenticated %}
<div class="pill-row">
<div>
<button class="pill-item" onclick="showCreate()">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M224,128a8,8,0,0,1-8,8H136v80a8,8,0,0,1-16,0V136H40a8,8,0,0,1,0-16h80V40a8,8,0,0,1,16,0v80h80A8,8,0,0,1,224,128Z"></path></svg>
</button>
</div>
</div>
{% endif %}
</div>
</div>
{% if groups %}
<div class="gallery-grid">
{% for group in groups %}
<a id="group-{{ group.id }}" class="group-item" href="{{ url_for('group.group', group_id=group.id) }}" {% if group.images|length > 0 %} style="background-color: rgba({{ group.images.0.colours.0.0 }}, {{ group.images.0.colours.0.1 }}, {{ group.images.0.colours.0.2 }}, 0.4);" {% endif %}>
<div class="image-filter">
<p class="image-subtitle">By {{ group.author.username }}</p>
<p class="image-title">{{ group.name }}</p>
</div>
<div class="images size-{{ group.images|length }}">
{% if group.images|length > 0 %}
{% for image in group.images %}
<img data-src="{{ url_for('api.file', file_name=image.filename) }}?r=thumb" onload="this.classList.add('loaded');" id="lazy-load" class="data-{{ loop.index }}" {% if image.alt %}{{ image.alt }}{% else %}Image Thumbnail{% endif %}/>
{% endfor %}
{% else %}
<img src="{{ url_for('static', filename='error.png') }}" class="loaded" alt="Error thumbnail"/>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>*crickets chirping*</h1>
{% if current_user.is_authenticated %}
<p>You can get started by creating a new image group!</p>
{% else %}
<p>Login to start seeing anything here!</p>
{% endif %}
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'layout.html' %}
{% block nav_profile %}selected{% endblock %}
{% block content %}
<div class="banner-small">
<div class="banner-content">
<h1 class="banner-header">{{ user.username }}</h1>
<p class="banner-info">Member since <span class="time">{{ user.joined_at }}</span></p>
</div>
</div>
{% if images %}
<div class="gallery-grid">
{% for image in images %}
<a id="image-{{ image.id }}" class="gallery-item" href="{{ url_for('image.image', image_id=image.id) }}" style="background-color: rgb({{ image.colours.0.0 }}, {{ image.colours.0.1 }}, {{ image.colours.0.2 }})">
<div class="image-filter">
<p class="image-subtitle"></p>
<p class="image-title"><span class="time">{{ image.created_at }}</span></p>
</div>
<img fetchpriority="low" alt="{{ image.alt }}" data-src="{{ url_for('api.file', file_name=image.filename) }}?r=thumb" onload="this.classList.add('loaded');" id="lazy-load"/>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>*crickets chirping*</h1>
<p>There are no images here yet, upload some!</p>
</div>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,12 @@
{% extends 'settings/settings_layout.html' %}
{% block settings_account %}settings-nav__item-selected{% endblock %}
{% block settings_content %}
<h2>Account</h2>
<p>Is session fresh?</p>
{% if fresh %}
<p>Yes</p>
{% else %}
<p>No</p>
{% endif %}
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends 'settings/settings_layout.html' %}
{% block settings_general %}settings-nav__item-selected{% endblock %}
{% block settings_content %}
<h2>General</h2>
{% endblock %}

View file

@ -0,0 +1,30 @@
{% extends 'settings/settings_layout.html' %}
{% block settings_logs %}settings-nav__item-selected{% endblock %}
{% block settings_content %}
<h2>Logs</h2>
<div class="settings-list" id="logs">
<div class="log" style="display:flex;flex-direction:row;gap:0.5rem;"></div>
</div>
{% endblock %}
{% block script %}
<script>
const output = document.getElementById('logs');
setInterval(function() {
$.ajax({
url: '{{ url_for('api.logfile') }}',
type: 'GET',
dataType: "json",
success: function(response) {
// for each item in response, log to console
response.forEach(function(item) {
console.log(item);
});
}
});
}, 1000); // 10 seconds
</script>
{% endblock %}

View file

@ -0,0 +1,6 @@
{% extends 'settings/settings_layout.html' %}
{% block settings_server %}settings-nav__item-selected{% endblock %}
{% block settings_content %}
<h2>Server</h2>
{% endblock %}

View file

@ -0,0 +1,29 @@
{% extends 'layout.html' %}
{% block nav_settings %}selected{% endblock %}
{% block content %}
<div class="banner-small">
<div class="banner-content">
<h1 class="banner-header">Settings</h1>
<p class="banner-info">{% block banner_subtitle%}{% endblock %}</p>
<div class="pill-row">
<div>
<a class="pill-item pill__critical" href="{{ url_for( 'auth.logout' ) }}">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M112,216a8,8,0,0,1-8,8H48a16,16,0,0,1-16-16V48A16,16,0,0,1,48,32h56a8,8,0,0,1,0,16H48V208h56A8,8,0,0,1,112,216Zm109.66-93.66-40-40a8,8,0,0,0-11.32,11.32L196.69,120H104a8,8,0,0,0,0,16h92.69l-26.35,26.34a8,8,0,0,0,11.32,11.32l40-40A8,8,0,0,0,221.66,122.34Z"></path></svg>
</a>
</div>
</div>
</div>
</div>
<div class="settings-nav">
<a href="{{ url_for('settings.general') }}" class="settings-nav__item {% block settings_general %}{% endblock %}">General</a>
<a href="{{ url_for('settings.server') }}" class="settings-nav__item {% block settings_server %}{% endblock %}">Server</a>
<a href="{{ url_for('settings.account') }}" class="settings-nav__item {% block settings_account %}{% endblock %}">Account</a>
<a href="{{ url_for('settings.logs') }}" class="settings-nav__item {% block settings_logs %}{% endblock %}">Logs</a>
</div>
<div class="settings-content">
{% block settings_content %}{% endblock %}
</div>
{% endblock %}

View file

View file

@ -0,0 +1,25 @@
"""
Calculate the contrast between two colors
"""
def contrast(background, light, dark, threshold=0.179):
"""
background: tuple of (r, g, b) values
light: color to use if the background is light
dark: color to use if the background is dark
threshold: the threshold to use for determining lightness, the default is w3 recommended
"""
red = background[0]
green = background[1]
blue = background[2]
# Calculate contrast
uicolors = [red / 255, green / 255, blue / 255]
cont = [
col / 12.92 if col <= 0.03928 else ((col + 0.055) / 1.055) ** 2.4
for col in uicolors
]
lightness = (0.2126 * cont[0]) + (0.7152 * cont[1]) + (0.0722 * cont[2])
return light if lightness > threshold else dark

View file

@ -0,0 +1,81 @@
"""
Tools for generating images and thumbnails
"""
import os
import platformdirs
from PIL import Image, ImageOps
from werkzeug.utils import secure_filename
CACHE_PATH = os.path.join(platformdirs.user_config_dir("onlylegs"), "cache")
UPLOAD_PATH = os.path.join(platformdirs.user_config_dir("onlylegs"), "uploads")
def generate_thumbnail(file_name, resolution, ext=None):
"""
Image thumbnail generator
Uses PIL to generate a thumbnail of the image and saves it to the cache directory
Name is the filename
resolution: 400x400 or thumb, or any other resolution
ext is the file extension of the image
"""
# Make image cache directory if it doesn't exist
if not os.path.exists(CACHE_PATH):
os.makedirs(CACHE_PATH)
# no sussy business
file_name, file_ext = secure_filename(file_name).rsplit(".")
if not ext:
ext = file_ext.strip(".")
# PIL doesnt like jpg so we convert it to jpeg
if ext.lower() == "jpg":
ext = "jpeg"
# Set resolution based on preset resolutions
if resolution in ["prev", "preview"]:
res_x, res_y = (1920, 1080)
elif resolution in ["thumb", "thumbnail"]:
res_x, res_y = (400, 400)
elif resolution in ["icon", "favicon"]:
res_x, res_y = (10, 10)
else:
return None
# If image has been already generated, return it from the cache
if os.path.exists(os.path.join(CACHE_PATH, f"{file_name}_{res_x}x{res_y}.{ext}")):
return os.path.join(CACHE_PATH, f"{file_name}_{res_x}x{res_y}.{ext}")
# Check if image exists in the uploads directory
if not os.path.exists(os.path.join(UPLOAD_PATH, f"{file_name}.{file_ext}")):
return None
# Open image and rotate it based on EXIF data and get ICC profile so colors are correct
image = Image.open(os.path.join(UPLOAD_PATH, f"{file_name}.{file_ext}"))
image_icc = image.info.get("icc_profile")
img_x, img_y = image.size
# Resize image to fit the resolution
image = ImageOps.exif_transpose(image)
image.thumbnail((min(img_x, int(res_x)), min(img_y, int(res_y))), Image.ANTIALIAS)
# Save image to cache directory
try:
image.save(
os.path.join(CACHE_PATH, f"{file_name}_{res_x}x{res_y}.{ext}"),
icc_profile=image_icc,
)
except OSError:
# This usually happens when saving a JPEG with an ICC profile,
# so we convert to RGB and try again
image = image.convert("RGB")
image.save(
os.path.join(CACHE_PATH, f"{file_name}_{res_x}x{res_y}.{ext}"),
icc_profile=image_icc,
)
# No need to keep the image in memory, learned the hard way
image.close()
return os.path.join(CACHE_PATH, f"{file_name}_{res_x}x{res_y}.{ext}")

View file

@ -0,0 +1,101 @@
"""
OnlyLegs - Metadata Parser
Parse metadata from images if available
otherwise get some basic information from the file
"""
import os
from PIL import Image
from PIL.ExifTags import TAGS
from .helpers import *
from .mapping import *
class Metadata:
"""
Metadata parser
"""
def __init__(self, file_path):
"""
Initialize the metadata parser
"""
self.file_path = file_path
img_exif = {}
try:
file = Image.open(file_path)
tags = file._getexif()
img_exif = {}
for tag, value in TAGS.items():
if tag in tags:
img_exif[value] = tags[tag]
img_exif["FileName"] = os.path.basename(file_path)
img_exif["FileSize"] = os.path.getsize(file_path)
img_exif["FileFormat"] = img_exif["FileName"].split(".")[-1]
img_exif["FileWidth"], img_exif["FileHeight"] = file.size
file.close()
except TypeError:
img_exif["FileName"] = os.path.basename(file_path)
img_exif["FileSize"] = os.path.getsize(file_path)
img_exif["FileFormat"] = img_exif["FileName"].split(".")[-1]
img_exif["FileWidth"], img_exif["FileHeight"] = file.size
self.encoded = img_exif
def yoink(self):
"""
Yoinks the metadata from the image
"""
if not os.path.isfile(self.file_path):
return None
return self.format_data(self.encoded)
@staticmethod
def format_data(encoded_exif):
"""
Formats the data into a dictionary
"""
exif = {
"Photographer": {},
"Camera": {},
"Software": {},
"File": {},
}
# Thanks chatGPT xP
# the helper function works, so not sure why it triggers pylint
for key, value in encoded_exif.items():
for mapping_name, mapping_val in EXIF_MAPPING:
if key in mapping_val:
if len(mapping_val[key]) == 2:
exif[mapping_name][mapping_val[key][0]] = {
"raw": value,
"formatted": (
getattr(
helpers, # pylint: disable=E0602
mapping_val[key][1],
)(value)
),
}
else:
exif[mapping_name][mapping_val[key][0]] = {
"raw": value,
}
continue
# Remove empty keys
if not exif["Photographer"]:
del exif["Photographer"]
if not exif["Camera"]:
del exif["Camera"]
if not exif["Software"]:
del exif["Software"]
if not exif["File"]:
del exif["File"]
return exif

View file

@ -0,0 +1,421 @@
"""
OnlyLegs - Metadata Parser
Metadata formatting helpers
"""
from datetime import datetime
def human_size(value):
"""
Formats the size of a file in a human readable format
"""
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
if abs(value) < 1024.0:
return f"{value:3.1f}{unit}B"
value /= 1024.0
return f"{value:.1f}YiB"
def date_format(value):
"""
Formats the date into a standard format
"""
return str(datetime.strptime(value, "%Y:%m:%d %H:%M:%S"))
def fnumber(value):
"""
Formats the f-number into a standard format
"""
return "ƒ/" + str(value)
def iso(value):
"""
Formats the ISO into a standard format
"""
return "ISO " + str(value)
def shutter(value):
"""
Formats the shutter speed into a standard format
"""
return str(value) + " s"
def focal_length(value):
"""
Formats the focal length into a standard format
"""
try:
calculated = value[0] / value[1]
except TypeError:
calculated = value
return str(calculated) + " mm"
def exposure(value):
"""
Formats the exposure value into a standard format
"""
return str(value) + " EV"
def color_space(value):
"""
Maps the value of the color space to a human readable format
"""
value_map = {0: "Reserved", 1: "sRGB", 65535: "Uncalibrated"}
try:
return value_map[int(value)]
except KeyError:
return None
def flash(value):
"""
Maps the value of the flash to a human readable format
"""
value_map = {
0: "Did not fire",
1: "Fired",
5: "Strobe return light not detected",
7: "Strobe return light detected",
9: "Fired, compulsory",
13: "Fired, compulsory, return light not detected",
15: "Fired, compulsory, return light detected",
16: "Did not fire, compulsory",
24: "Did not fire, auto mode",
25: "Fired, auto mode",
29: "Fired, auto mode, return light not detected",
31: "Fired, auto mode, return light detected",
32: "No function",
65: "Fired, red-eye reduction mode",
69: "Fired, red-eye reduction mode, return light not detected",
71: "Fired, red-eye reduction mode, return light detected",
73: "Fired, compulsory, red-eye reduction mode",
77: "Fired, compulsory, red-eye reduction mode, return light not detected",
79: "Fired, compulsory, red-eye reduction mode, return light detected",
89: "Fired, auto mode, red-eye reduction mode",
93: "Fired, auto mode, return light not detected, red-eye reduction mode",
95: "Fired, auto mode, return light detected, red-eye reduction mode",
}
try:
return value_map[int(value)]
except KeyError:
return None
def exposure_program(value):
"""
Maps the value of the exposure program to a human readable format
"""
value_map = {
0: "Not defined",
1: "Manual",
2: "Normal program",
3: "Aperture priority",
4: "Shutter priority",
5: "Creative program",
6: "Action program",
7: "Portrait mode",
8: "Landscape mode",
}
try:
return value_map[int(value)]
except KeyError:
return None
def metering_mode(value):
"""
Maps the value of the metering mode to a human readable format
"""
value_map = {
0: "Unknown",
1: "Average",
2: "Center-Weighted Average",
3: "Spot",
4: "Multi-Spot",
5: "Pattern",
6: "Partial",
255: "Other",
}
try:
return value_map[int(value)]
except KeyError:
return None
def resolution_unit(value):
"""
Maps the value of the resolution unit to a human readable format
"""
value_map = {1: "No absolute unit of measurement", 2: "Inch", 3: "Centimeter"}
try:
return value_map[int(value)]
except KeyError:
return None
def light_source(value):
"""
Maps the value of the light source to a human readable format
"""
value_map = {
0: "Unknown",
1: "Daylight",
2: "Fluorescent",
3: "Tungsten (incandescent light)",
4: "Flash",
9: "Fine weather",
10: "Cloudy weather",
11: "Shade",
12: "Daylight fluorescent (D 5700 - 7100K)",
13: "Day white fluorescent (N 4600 - 5400K)",
14: "Cool white fluorescent (W 3900 - 4500K)",
15: "White fluorescent (WW 3200 - 3700K)",
17: "Standard light A",
18: "Standard light B",
19: "Standard light C",
20: "D55",
21: "D65",
22: "D75",
23: "D50",
24: "ISO studio tungsten",
255: "Other light source",
}
try:
return value_map[int(value)]
except KeyError:
return None
def scene_capture_type(value):
"""
Maps the value of the scene capture type to a human readable format
"""
value_map = {
0: "Standard",
1: "Landscape",
2: "Portrait",
3: "Night scene",
}
try:
return value_map[int(value)]
except KeyError:
return None
def white_balance(value):
"""
Maps the value of the white balance to a human readable format
"""
value_map = {
0: "Auto",
1: "Manual",
}
try:
return value_map[int(value)]
except KeyError:
return None
def exposure_mode(value):
"""
Maps the value of the exposure mode to a human readable format
"""
value_map = {
0: "Auto",
1: "Manual",
2: "Auto bracket",
}
try:
return value_map[int(value)]
except KeyError:
return None
def sensitivity_type(value):
"""
Maps the value of the sensitivity type to a human readable format
"""
value_map = {
0: "Unknown",
1: "Standard Output Sensitivity",
2: "Recommended Exposure Index",
3: "ISO Speed",
4: "Standard Output Sensitivity and Recommended Exposure Index",
5: "Standard Output Sensitivity and ISO Speed",
6: "Recommended Exposure Index and ISO Speed",
7: "Standard Output Sensitivity, Recommended Exposure Index and ISO Speed",
}
try:
return value_map[int(value)]
except KeyError:
return None
def lens_specification(value):
"""
Maps the value of the lens specification to a human readable format
"""
try:
return str(value[0] / value[1]) + "mm - " + str(value[2] / value[3]) + "mm"
except TypeError:
return None
def compression_type(value):
"""
Maps the value of the compression type to a human readable format
"""
value_map = {
1: "Uncompressed",
2: "CCITT 1D",
3: "T4/Group 3 Fax",
4: "T6/Group 4 Fax",
5: "LZW",
6: "JPEG (old-style)",
7: "JPEG",
8: "Adobe Deflate",
9: "JBIG B&W",
10: "JBIG Color",
99: "JPEG",
262: "Kodak 262",
32766: "Next",
32767: "Sony ARW Compressed",
32769: "Packed RAW",
32770: "Samsung SRW Compressed",
32771: "CCIRLEW",
32772: "Samsung SRW Compressed 2",
32773: "PackBits",
32809: "Thunderscan",
32867: "Kodak KDC Compressed",
32895: "IT8CTPAD",
32896: "IT8LW",
32897: "IT8MP",
32898: "IT8BL",
32908: "PixarFilm",
32909: "PixarLog",
32946: "Deflate",
32947: "DCS",
33003: "Aperio JPEG 2000 YCbCr",
33005: "Aperio JPEG 2000 RGB",
34661: "JBIG",
34676: "SGILog",
34677: "SGILog24",
34712: "JPEG 2000",
34713: "Nikon NEF Compressed",
34715: "JBIG2 TIFF FX",
34718: "(MDI) Binary Level Codec",
34719: "(MDI) Progressive Transform Codec",
34720: "(MDI) Vector",
34887: "ESRI Lerc",
34892: "Lossy JPEG",
34925: "LZMA2",
34926: "Zstd",
34927: "WebP",
34933: "PNG",
34934: "JPEG XR",
65000: "Kodak DCR Compressed",
65535: "Pentax PEF Compressed",
}
try:
return value_map[int(value)]
except KeyError:
return None
def orientation(value):
"""
Maps the value of the orientation to a human readable format
"""
value_map = {
0: "Undefined",
1: "Horizontal (normal)",
2: "Mirror horizontal",
3: "Rotate 180",
4: "Mirror vertical",
5: "Mirror horizontal and rotate 270 CW",
6: "Rotate 90 CW",
7: "Mirror horizontal and rotate 90 CW",
8: "Rotate 270 CW",
}
try:
return value_map[int(value)]
except KeyError:
return None
def components_configuration(value):
"""
Maps the value of the components configuration to a human readable format
"""
value_map = {
0: "",
1: "Y",
2: "Cb",
3: "Cr",
4: "R",
5: "G",
6: "B",
}
try:
return "".join([value_map[int(x)] for x in value])
except KeyError:
return None
def rating(value):
"""
Maps the value of the rating to a human readable format
"""
return str(value) + " stars"
def rating_percent(value):
"""
Maps the value of the rating to a human readable format
"""
return str(value) + "%"
def pixel_dimension(value):
"""
Maps the value of the pixel dimension to a human readable format
"""
return str(value) + " px"
def title(value):
"""
Maps the value of the title to a human readable format
"""
return str(value.title())
def subject_distance(value):
"""
Maps the value of the subject distance to a human readable format
"""
return str(value) + " m"
def subject_distance_range(value):
"""
Maps the value of the subject distance range to a human readable format
"""
value_map = {
0: "Unknown",
1: "Macro",
2: "Close view",
3: "Distant view",
}
try:
return value_map[int(value)]
except KeyError:
return None

View file

@ -0,0 +1,71 @@
"""
OnlyLegs - Metatada Parser
Mapping for metadata
"""
PHOTOGRAHER_MAPPING = {
"Artist": ["Artist"],
"UserComment": ["Comment"],
"ImageDescription": ["Description"],
"Copyright": ["Copyright"],
}
CAMERA_MAPPING = {
"Model": ["Model", "title"],
"Make": ["Manifacturer", "title"],
"BodySerialNumber": ["Camera Type"],
"LensMake": ["Lens Make", "title"],
"LensModel": ["Lens Model", "title"],
"LensSpecification": ["Lens Specification", "lens_specification"],
"ComponentsConfiguration": ["Components Configuration", "components_configuration"],
"DateTime": ["Date and Time", "date_format"],
"DateTimeOriginal": ["Date and Time (Original)", "date_format"],
"DateTimeDigitized": ["Date and Time (Digitized)", "date_format"],
"OffsetTime": ["Time Offset"],
"OffsetTimeOriginal": ["Time Offset (Original)"],
"OffsetTimeDigitized": ["Time Offset (Digitized)"],
"FNumber": ["FNumber", "fnumber"],
"FocalLength": ["Focal Length", "focal_length"],
"FocalLengthIn35mmFilm": ["Focal Length in 35mm format", "focal_length"],
"MaxApertureValue": ["Max Aperture", "fnumber"],
"ApertureValue": ["Aperture", "fnumber"],
"ShutterSpeedValue": ["Shutter Speed", "shutter"],
"ISOSpeedRatings": ["ISO Speed Ratings", "iso"],
"ISOSpeed": ["ISO Speed", "iso"],
"SensitivityType": ["Sensitivity Type", "sensitivity_type"],
"ExposureBiasValue": ["Exposure Bias", "exposure"],
"ExposureTime": ["Exposure Time", "shutter"],
"ExposureMode": ["Exposure Mode", "exposure_mode"],
"ExposureProgram": ["Exposure Program", "exposure_program"],
"WhiteBalance": ["White Balance", "white_balance"],
"Flash": ["Flash", "flash"],
"MeteringMode": ["Metering Mode", "metering_mode"],
"LightSource": ["Light Source", "light_source"],
"SceneCaptureType": ["Scene Capture Type", "scene_capture_type"],
"SubjectDistance": ["Subject Distance", "subject_distance"],
"SubjectDistanceRange": ["Subject Distance Range", "subject_distance_range"],
}
SOFTWARE_MAPPING = {
"Software": ["Software"],
"ColorSpace": ["Colour Space", "color_space"],
"Compression": ["Compression", "compression_type"],
}
FILE_MAPPING = {
"FileName": ["Name"],
"FileSize": ["Size", "human_size"],
"FileFormat": ["Format"],
"FileWidth": ["Width", "pixel_dimension"],
"FileHeight": ["Height", "pixel_dimension"],
"Orientation": ["Orientation", "orientation"],
"XResolution": ["X-resolution"],
"YResolution": ["Y-resolution"],
"ResolutionUnit": ["Resolution Units", "resolution_unit"],
"Rating": ["Rating", "rating"],
"RatingPercent": ["Rating Percent", "rating_percent"],
}
EXIF_MAPPING = [
("Photographer", PHOTOGRAHER_MAPPING),
("Camera", CAMERA_MAPPING),
("Software", SOFTWARE_MAPPING),
("File", FILE_MAPPING),
]

View file

@ -0,0 +1 @@
# :3

132
onlylegs/views/group.py Normal file
View file

@ -0,0 +1,132 @@
"""
Onlylegs - Image Groups
Why groups? Because I don't like calling these albums
sounds more limiting that it actually is in this gallery
"""
from flask import Blueprint, render_template, url_for
from onlylegs.models import Post, User, GroupJunction, Group
from onlylegs.extensions import db
from onlylegs.utils import contrast
blueprint = Blueprint("group", __name__, url_prefix="/group")
@blueprint.route("/", methods=["GET"])
def groups():
"""
Group overview, shows all image groups
"""
groups = Group.query.all()
# For each group, get the 3 most recent images
for group in groups:
group.author_username = (
User.query.with_entities(User.username)
.filter(User.id == group.author_id)
.first()[0]
)
# Get the 3 most recent images
images = (
GroupJunction.query.with_entities(GroupJunction.post_id)
.filter(GroupJunction.group_id == group.id)
.order_by(GroupJunction.date_added.desc())
.limit(3)
)
# For each image, get the image data and add it to the group item
group.images = []
for image in images:
group.images.append(
Post.query.with_entities(Post.filename, Post.alt, Post.colours, Post.id)
.filter(Post.id == image[0])
.first()
)
return render_template("list.html", groups=groups)
@blueprint.route("/<int:group_id>")
def group(group_id):
"""
Group view, shows all images in a group
"""
# Get the group, if it doesn't exist, 404
group = db.get_or_404(Group, group_id, description="Group not found! D:")
# Get all images in the group from the junction table
junction = (
GroupJunction.query.with_entities(GroupJunction.post_id)
.filter(GroupJunction.group_id == group_id)
.order_by(GroupJunction.date_added.desc())
.all()
)
# Get the image data for each image in the group
images = []
for image in junction:
images.append(Post.query.filter(Post.id == image[0]).first())
# Check contrast for the first image in the group for the banner
text_colour = "rgb(var(--fg-black))"
if images:
text_colour = contrast.contrast(
images[0].colours[0], "rgb(var(--fg-black))", "rgb(var(--fg-white))"
)
return render_template(
"group.html", group=group, images=images, text_colour=text_colour
)
@blueprint.route("/<int:group_id>/<int:image_id>")
def group_post(group_id, image_id):
"""
Image view, shows the image and its metadata from a specific group
"""
# Get the image, if it doesn't exist, 404
image = db.get_or_404(Post, image_id, description="Image not found :<")
# Get all groups the image is in
groups = (
GroupJunction.query.with_entities(GroupJunction.group_id)
.filter(GroupJunction.post_id == image_id)
.all()
)
# Get the group data for each group the image is in
image.groups = []
for group in groups:
image.groups.append(
Group.query.with_entities(Group.id, Group.name)
.filter(Group.id == group[0])
.first()
)
# Get the next and previous images in the group
next_url = (
GroupJunction.query.with_entities(GroupJunction.post_id)
.filter(GroupJunction.group_id == group_id)
.filter(GroupJunction.post_id > image_id)
.order_by(GroupJunction.date_added.asc())
.first()
)
prev_url = (
GroupJunction.query.with_entities(GroupJunction.post_id)
.filter(GroupJunction.group_id == group_id)
.filter(GroupJunction.post_id < image_id)
.order_by(GroupJunction.date_added.desc())
.first()
)
# If there is a next or previous image, get the URL for it
if next_url:
next_url = url_for("group.group_post", group_id=group_id, image_id=next_url[0])
if prev_url:
prev_url = url_for("group.group_post", group_id=group_id, image_id=prev_url[0])
return render_template(
"image.html", image=image, next_url=next_url, prev_url=prev_url
)

81
onlylegs/views/image.py Normal file
View file

@ -0,0 +1,81 @@
"""
Onlylegs - Image View
"""
from math import ceil
from flask import Blueprint, render_template, url_for, current_app
from onlylegs.models import Post, GroupJunction, Group
from onlylegs.extensions import db
blueprint = Blueprint("image", __name__, url_prefix="/image")
@blueprint.route("/<int:image_id>")
def image(image_id):
"""
Image view, shows the image and its metadata
"""
# Get the image, if it doesn't exist, 404
image = db.get_or_404(Post, image_id, description="Image not found :<")
# Get all groups the image is in
groups = (
GroupJunction.query.with_entities(GroupJunction.group_id)
.filter(GroupJunction.post_id == image_id)
.all()
)
# Get the group data for each group the image is in
image.groups = []
for group in groups:
image.groups.append(
Group.query.with_entities(Group.id, Group.name)
.filter(Group.id == group[0])
.first()
)
# Get the next and previous images
# Check if there is a group ID set
next_url = (
Post.query.with_entities(Post.id)
.filter(Post.id > image_id)
.order_by(Post.id.asc())
.first()
)
prev_url = (
Post.query.with_entities(Post.id)
.filter(Post.id < image_id)
.order_by(Post.id.desc())
.first()
)
# If there is a next or previous image, get the url
if next_url:
next_url = url_for("image.image", image_id=next_url[0])
if prev_url:
prev_url = url_for("image.image", image_id=prev_url[0])
# Yoink all the images in the database
total_images = Post.query.with_entities(Post.id).order_by(Post.id.desc()).all()
limit = current_app.config["UPLOAD_CONF"]["max-load"]
# If the number of items is less than the limit, no point of calculating the page
if len(total_images) <= limit:
return_page = None
else:
# How many pages should there be
for i in range(ceil(len(total_images) / limit)):
# Slice the list of IDs into chunks of the limit
for j in total_images[i * limit : (i + 1) * limit]:
# Is our image in this chunk?
if not image_id > j[-1]:
return_page = i + 1
break
return render_template(
"image.html",
image=image,
next_url=next_url,
prev_url=prev_url,
return_page=return_page,
)

52
onlylegs/views/index.py Normal file
View file

@ -0,0 +1,52 @@
"""
Onlylegs Gallery - Index view
"""
from math import ceil
from flask import Blueprint, render_template, request, current_app
from werkzeug.exceptions import abort
from onlylegs.models import Post
blueprint = Blueprint("gallery", __name__)
@blueprint.route("/")
def index():
"""
Home page of the website, shows the feed of the latest images
"""
# meme
if request.args.get("coffee") == "please":
abort(418)
# pagination, defaults to page 1 if no page is specified
page = request.args.get("page", default=1, type=int)
limit = current_app.config["UPLOAD_CONF"]["max-load"]
# get the total number of images in the database
# calculate the total number of pages, and make sure the page number is valid
total_images = Post.query.with_entities(Post.id).count()
pages = ceil(max(total_images, limit) / limit)
if page > pages:
abort(
404,
"You have reached the far and beyond, "
+ "but you will not find your answers here.",
)
# get the images for the current page
images = (
Post.query.with_entities(
Post.filename, Post.alt, Post.colours, Post.created_at, Post.id
)
.order_by(Post.id.desc())
.offset((page - 1) * limit)
.limit(limit)
.all()
)
return render_template(
"index.html", images=images, total_images=total_images, pages=pages, page=page
)

36
onlylegs/views/profile.py Normal file
View file

@ -0,0 +1,36 @@
"""
Onlylegs Gallery - Profile view
"""
from flask import Blueprint, render_template, request
from werkzeug.exceptions import abort
from flask_login import current_user
from onlylegs.models import Post, User
blueprint = Blueprint("profile", __name__, url_prefix="/profile")
@blueprint.route("/")
def profile():
"""
Profile overview, shows all profiles on the onlylegs gallery
"""
user_id = request.args.get("id", default=None, type=int)
# If there is no userID set, check if the user is logged in and display their profile
if not user_id:
if current_user.is_authenticated:
user_id = current_user.id
else:
abort(404, "You must be logged in to view your own profile!")
# Get the user's data
user = User.query.filter(User.id == user_id).first()
if not user:
abort(404, "User not found :c")
images = Post.query.filter(Post.author_id == user_id).all()
return render_template("profile.html", user=user, images=images)

View file

@ -0,0 +1,43 @@
"""
OnlyLegs - Settings page
"""
from flask import Blueprint, render_template
from flask_login import login_required
blueprint = Blueprint("settings", __name__, url_prefix="/settings")
@blueprint.route("/")
@login_required
def general():
"""
General settings page
"""
return render_template("settings/general.html")
@blueprint.route("/server")
@login_required
def server():
"""
Server settings page
"""
return render_template("settings/server.html")
@blueprint.route("/account")
@login_required
def account():
"""
Account settings page
"""
return render_template("settings/account.html")
@blueprint.route("/logs")
@login_required
def logs():
"""
Logs settings page
"""
return render_template("settings/logs.html")