Merge pull request from Fluffy-Bean/unstable

Unstable
This commit is contained in:
Michał 2023-03-20 18:01:28 +00:00 committed by GitHub
commit 9cf73a79fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
71 changed files with 3397 additions and 2448 deletions

9
.gitignore vendored
View file

@ -1,9 +1,6 @@
# Remove all development files
gallery/user/logs/*
gallery/user/uploads/*
gallery/user/conf.yml
gallery/user/conf.json
gallery/static/theme/*
gallery/static/theme
gallery/static/.webassets-cache
gallery/static/gen
.idea
.vscode

View file

@ -1,6 +1,6 @@
MIT License
Copyright (c) 2022 Michal
Copyright (c) 2023 Michal
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View file

@ -22,28 +22,54 @@
</div>
## Features
### Currently implemented
- Easy uploading and managing of a gallery of images
- Multi user support, helping you manage a whole group of photographers
- Custom CSS support
- [x] Easy uploading and managing of a gallery of images
- [x] Multi-user support, helping you manage a whole group of photographers
- [x] Image groups, helping you sort your favourite memories
- [x] Custom CSS support
- [ ] Password locked images/image groups, helping you share photos only to those who you want to
- [ ] Logging and automatic login attempt warnings and timeouts
- [ ] Searching through tags, file names and users
### Coming soon tm
- Image groups, helping you sort your favorite memories
- Password locked images/image groups, helping you share photos only to those who you want to
- Logging and automatic login attempt warnings and timeouts
- Searching through tags, file names, users (and metadata maybe, no promises)
And many more planned things!
## screenshots
Homescreen
Home-screen
![screenshot](.github/images/homepage.png)
Image view
![screenshot](.github/images/imageview.png)
## Running
Currently only for reference
You first need to install `python poetry`, it's best to follow their getting started guide you can find on the official website.
Next we need to install the required packages for the gallery to function correctly, make sure you're in the directory of the project when you run this command:
poetry install
poetry run python3 -m flask --app gallery --debug run --host 0.0.0.0
poetry run python3 -m gunicorn -w 4 -b 0.0.0.0:5000 'gallery:create_app()'
By default, the app runs on port 5000, 4 workers on `gunicorn` ready for you to use it. You can find more information on this using the `-h` flag. But to run the gallery, use this command.
poetry run python3 run.py
Now follow the provided prompts to fill in the information for the Admin account, and you're ready to go!
### Common issues
#### App failing to create a user config folder
Try checking if you have `XDG_CONFIG_HOME` setup. If you don't, you can set that with this command:
export XDG_CONFIG_HOME="$HOME/.config"
## Final notes
Thank you to everyone who helped me test the previous and current versions of the gallery, especially critters:
- Carty
- Jeetix
- CRT
- mrHDash
- Verg
- FennecBitch
Enjoy using OnlyLegs!

View file

@ -1,79 +1,48 @@
"""
___ _ _
/ _ \ _ __ | |_ _| | ___ __ _ ___
| | | | '_ \| | | | | | / _ \/ _` / __|
| |_| | | | | | |_| | |__| __/ (_| \__ \
\___/|_| |_|_|\__, |_____\___|\__, |___/
|___/ |___/
Created by Fluffy Bean - Version 23.03.04
Onlylegs Gallery
This is the main app file, it loads all the other files and sets up the app
"""
# Import system modules
import os
import sys
import logging
# Flask
from flask_compress import Compress
from flask_caching import Cache
from flask_assets import Environment, Bundle
from flask import Flask, render_template
# Configuration
from dotenv import load_dotenv
import platformdirs
import yaml
from dotenv import load_dotenv
from yaml import FullLoader, safe_load
from . import theme_manager
# Utils
from gallery.utils import theme_manager
USER_DIR = platformdirs.user_config_dir('onlylegs')
INSTANCE_PATH = os.path.join(USER_DIR, 'instance')
# Check if any of the required files are missing
if not os.path.exists(platformdirs.user_config_dir('onlylegs')):
from . import setup
setup.SetupApp()
# Get environment variables
if os.path.exists(os.path.join(USER_DIR, '.env')):
load_dotenv(os.path.join(USER_DIR, '.env'))
print("Loaded environment variables")
else:
print("No environment variables found!")
sys.exit(1)
# Get config file
if os.path.exists(os.path.join(USER_DIR, 'conf.yml')):
with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as f:
conf = yaml.load(f, Loader=yaml.FullLoader)
print("Loaded gallery config")
else:
print("No config file found!")
sys.exit(1)
# Setup the logging config
LOGS_PATH = os.path.join(platformdirs.user_config_dir('onlylegs'), 'logs')
if not os.path.isdir(LOGS_PATH):
os.mkdir(LOGS_PATH)
logging.getLogger('werkzeug').disabled = True
logging.basicConfig(
filename=os.path.join(LOGS_PATH, 'only.log'),
level=logging.INFO,
datefmt='%Y-%m-%d %H:%M:%S',
format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s',
encoding='utf-8')
def create_app(test_config=None):
"""
Create and configure the main app
"""
app = Flask(__name__,instance_path=INSTANCE_PATH)
app = Flask(__name__, instance_path=os.path.join(USER_DIR, 'instance'))
assets = Environment()
cache = Cache(config={'CACHE_TYPE': 'SimpleCache', 'CACHE_DEFAULT_TIMEOUT': 300})
compress = Compress()
# Get environment variables
load_dotenv(os.path.join(USER_DIR, '.env'))
print("Loaded environment variables")
# Get config file
with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as file:
conf = safe_load(file)
print("Loaded gallery config")
# App configuration
app.config.from_mapping(
SECRET_KEY=os.environ.get('FLASK_SECRET'),
@ -94,56 +63,40 @@ def create_app(test_config=None):
except OSError:
pass
# Load theme
theme_manager.CompileTheme('default', app.root_path)
# Bundle JS files
js_scripts = Bundle('js/*.js', output='gen/packed.js')
assets.register('js_all', js_scripts)
@app.errorhandler(405)
def method_not_allowed(err):
error = '405'
msg = err.description
return render_template('error.html', error=error, msg=msg), 404
@app.errorhandler(404)
def page_not_found(err):
error = '404'
msg = err.description
return render_template('error.html', error=error, msg=msg), 404
# Error handlers
@app.errorhandler(403)
def forbidden(err):
error = '403'
msg = err.description
return render_template('error.html', error=error, msg=msg), 403
@app.errorhandler(404)
@app.errorhandler(405)
@app.errorhandler(410)
def gone(err):
error = '410'
msg = err.description
return render_template('error.html', error=error, msg=msg), 410
@app.errorhandler(500)
def internal_server_error(err):
error = '500'
def error_page(err):
error = err.code
msg = err.description
return render_template('error.html', error=error, msg=msg), 500
return render_template('error.html', error=error, msg=msg), err.code
# Load login, registration and logout manager
from . import auth
from gallery import auth
app.register_blueprint(auth.blueprint)
# Load routes for home and images
from . import routing
# Load the different routes
from .routes import api, groups, routing, settings
app.register_blueprint(api.blueprint)
app.register_blueprint(groups.blueprint)
app.register_blueprint(routing.blueprint)
app.add_url_rule('/', endpoint='index')
# Load routes for settings
from . import settings
app.register_blueprint(settings.blueprint)
# Load APIs
from . import api
app.register_blueprint(api.blueprint)
# Log to file that the app has started
logging.info('Gallery started successfully!')
# Initialize extensions and return app
assets.init_app(app)
cache.init_app(app)
compress.init_app(app)
return app

View file

@ -1,227 +0,0 @@
"""
Onlylegs - API endpoints
Used intermally by the frontend and possibly by other applications
"""
from uuid import uuid4
import os
import io
import logging
from flask import (
Blueprint, current_app, send_from_directory, send_file, request, g, abort, flash, jsonify)
from werkzeug.utils import secure_filename
from PIL import Image, ImageOps # ImageFilter
from sqlalchemy.orm import sessionmaker
from gallery.auth import login_required
from . import db # Import db to create a session
from . import metadata as mt
blueprint = Blueprint('api', __name__, url_prefix='/api')
db_session = sessionmaker(bind=db.engine)
db_session = db_session()
@blueprint.route('/uploads/<file>', methods=['GET'])
def uploads(file):
"""
Returns a file from the uploads folder
w and h are the width and height of the image for resizing
f is whether to apply filters to the image, such as blurring NSFW images
"""
# Get args
width = request.args.get('w', default=0, type=int) # Width of image
height = request.args.get('h', default=0, type=int) # Height of image
filtered = request.args.get('f', default=False, type=bool) # Whether to apply filters
# if no args are passed, return the raw file
if width == 0 and height == 0 and not filtered:
if not os.path.exists(os.path.join(current_app.config['UPLOAD_FOLDER'],
secure_filename(file))):
abort(404)
return send_from_directory(current_app.config['UPLOAD_FOLDER'], file ,as_attachment=True)
# Of either width or height is 0, set it to the other value to keep aspect ratio
if width > 0 and height == 0:
height = width
elif width == 0 and height > 0:
width = height
set_ext = current_app.config['ALLOWED_EXTENSIONS']
buff = io.BytesIO()
# Open image and set extension
try:
img = Image.open(os.path.join(current_app.config['UPLOAD_FOLDER'],file))
except FileNotFoundError:
logging.error('File not found: %s, possibly broken upload', file)
abort(404)
except Exception as err:
logging.error('Error opening image: %s', err)
abort(500)
img_ext = os.path.splitext(file)[-1].lower().replace('.', '')
img_ext = set_ext[img_ext]
# Get ICC profile as it alters colours when saving
img_icc = img.info.get("icc_profile")
# Resize image and orientate correctly
img.thumbnail((width, height), Image.LANCZOS)
img = ImageOps.exif_transpose(img)
# If has NSFW tag, blur image, etc.
if filtered:
#img = img.filter(ImageFilter.GaussianBlur(20))
pass
try:
img.save(buff, img_ext, icc_profile=img_icc)
except OSError:
# This usually happens when saving a JPEG with an ICC profile
# Convert to RGB and try again
img = img.convert('RGB')
img.save(buff, img_ext, icc_profile=img_icc)
except Exception as err:
logging.error('Could not resize image %s, error: %s', file, err)
abort(500)
img.close()
# Seek to beginning of buffer and return
buff.seek(0)
return send_file(buff, mimetype='image/' + img_ext)
@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 not form_file:
return abort(404)
img_ext = os.path.splitext(form_file.filename)[-1].replace('.', '').lower()
img_name = f"GWAGWA_{str(uuid4())}.{img_ext}"
if not img_ext in current_app.config['ALLOWED_EXTENSIONS'].keys():
logging.info('File extension not allowed: %s', img_ext)
abort(403)
if os.path.isdir(current_app.config['UPLOAD_FOLDER']) is False:
os.mkdir(current_app.config['UPLOAD_FOLDER'])
# Save to database
try:
db_session.add(db.posts(img_name, form['description'], form['alt'], g.user.id))
db_session.commit()
except Exception as err:
logging.error('Could not save to database: %s', err)
abort(500)
# Save file
try:
form_file.save(
os.path.join(current_app.config['UPLOAD_FOLDER'], img_name))
except Exception as err:
logging.error('Could not save file: %s', err)
abort(500)
return 'Gwa Gwa'
@blueprint.route('/remove/<int:img_id>', methods=['POST'])
@login_required
def remove(img_id):
"""
Deletes an image from the server and database
"""
img = db_session.query(db.posts).filter_by(id=img_id).first()
if img is None:
abort(404)
if img.author_id != g.user.id:
abort(403)
try:
os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name))
except FileNotFoundError:
# File was already deleted or doesn't exist
logging.warning('File not found: %s, already deleted or never existed', img.file_name)
except Exception as err:
logging.error('Could not remove file: %s', err)
abort(500)
try:
db_session.query(db.posts).filter_by(id=img_id).delete()
db_session.commit()
except Exception as err:
logging.error('Could not remove from database: %s', err)
abort(500)
logging.info('Removed image (%s) %s', img_id, img.file_name)
flash(['Image was all in Le Head!', 1])
return 'Gwa Gwa'
@blueprint.route('/metadata/<int:img_id>', methods=['GET'])
def metadata(img_id):
"""
Yoinks metadata from an image
"""
img = db_session.query(db.posts).filter_by(id=img_id).first()
if img is None:
abort(404)
img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name)
exif = mt.Metadata(img_path).yoink()
return jsonify(exif)
@blueprint.route('/logfile')
@login_required
def logfile():
"""
Gets the log file and returns it as a JSON object
"""
filename = logging.getLoggerClass().root.handlers[0].baseFilename
log_dict = {}
i = 0
with open(filename, encoding='utf-8') as file:
for line in file:
line = line.split(' : ')
event = line[0].strip().split(' ')
event_data = {
'date': event[0],
'time': event[1],
'severity': event[2],
'owner': event[3]
}
message = line[1].strip()
try:
message_data = {
'code': int(message[1:4]),
'message': message[5:].strip()
}
except ValueError:
message_data = {'code': 0, 'message': message}
except Exception as err:
logging.error('Could not parse log file: %s', err)
abort(500)
log_dict[i] = {'event': event_data, 'message': message_data}
i += 1 # Line number, starts at 0
return jsonify(log_dict)

View file

@ -1,10 +1,11 @@
"""
OnlyLegs - Authentification
OnlyLegs - Authentication
User registration, login and logout and locking access to pages behind a login
"""
import re
import uuid
import logging
from datetime import datetime as dt
import functools
from flask import Blueprint, flash, g, redirect, request, session, url_for, abort, jsonify
@ -28,7 +29,7 @@ def login_required(view):
@functools.wraps(view)
def wrapped_view(**kwargs):
if g.user is None or session.get('uuid') is None:
logging.error('Authentification failed')
logging.error('Authentication failed')
session.clear()
return redirect(url_for('gallery.index'))
@ -49,14 +50,14 @@ def load_logged_in_user():
g.user = None
session.clear()
else:
is_alive = db_session.query(db.sessions).filter_by(session_uuid=user_uuid).first()
is_alive = db_session.query(db.Sessions).filter_by(session_uuid=user_uuid).first()
if is_alive is None:
logging.info('Session expired')
flash(['Session expired!', '3'])
session.clear()
else:
g.user = db_session.query(db.users).filter_by(id=user_id).first()
g.user = db_session.query(db.Users).filter_by(id=user_id).first()
@blueprint.route('/register', methods=['POST'])
@ -74,7 +75,6 @@ def register():
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')
if not username or not username_regex.match(username):
error.append('Username is invalid!')
@ -94,9 +94,12 @@ def register():
if error:
return jsonify(error)
try:
db_session.add(db.users(username, email, generate_password_hash(password)))
register_user = db.Users(username=username,
email=email,
password=generate_password_hash(password),
created_at=dt.utcnow())
db_session.add(register_user)
db_session.commit()
except exc.IntegrityError:
return f'User {username} is already registered!'
@ -116,10 +119,9 @@ def login():
username = request.form['username']
password = request.form['password']
user = db_session.query(db.users).filter_by(username=username).first()
user = db_session.query(db.Users).filter_by(username=username).first()
error = []
if user is None:
logging.error('User %s does not exist. Login attempt from %s',
username, request.remote_addr)
@ -132,17 +134,19 @@ def login():
if error:
abort(403)
try:
session.clear()
session['user_id'] = user.id
session['uuid'] = str(uuid.uuid4())
db_session.add(db.sessions(user.id,
session.get('uuid'),
request.remote_addr,
request.user_agent.string,
1))
session_query = db.Sessions(user_id=user.id,
session_uuid=session.get('uuid'),
ip_address=request.remote_addr,
user_agent=request.user_agent.string,
active=True,
created_at=dt.utcnow())
db_session.add(session_query)
db_session.commit()
except Exception as err:
logging.error('User %s could not be logged in: %s', username, err)
@ -160,4 +164,4 @@ def logout():
"""
logging.info('User (%s) %s logged out', session.get('user_id'), g.user.username)
session.clear()
return redirect(url_for('index'))
return redirect(url_for('gallery.index'))

View file

@ -1,21 +1,25 @@
"""
OnlyLegs - Database
Database models and functions for SQLAlchemy
OnlyLegs - Database models and functions for SQLAlchemy
"""
import os
from datetime import datetime
import platformdirs
from sqlalchemy import create_engine, Column, Integer, String, Boolean, DateTime, ForeignKey
from sqlalchemy import (
create_engine, Column, Integer, String, Boolean, DateTime, ForeignKey, PickleType)
from sqlalchemy.orm import declarative_base, relationship
path_to_db = os.path.join(platformdirs.user_config_dir('onlylegs'), 'gallery.sqlite')
engine = create_engine(f'sqlite:///{path_to_db}', echo=False)
USER_DIR = platformdirs.user_config_dir('onlylegs')
DB_PATH = os.path.join(USER_DIR, 'gallery.sqlite')
# engine = create_engine('postgresql://username:password@host:port/database_name', echo=False)
# engine = create_engine('mysql://username:password@host:port/database_name', echo=False)
engine = create_engine(f'sqlite:///{DB_PATH}', echo=False)
base = declarative_base()
class users (base): # pylint: disable=too-few-public-methods, C0103
class Users (base): # pylint: disable=too-few-public-methods, C0103
"""
User table
Joins with post, groups, session and log
@ -28,19 +32,13 @@ class users (base): # pylint: disable=too-few-public-methods, C0103
password = Column(String, nullable=False)
created_at = Column(DateTime, nullable=False)
posts = relationship('posts')
groups = relationship('groups')
session = relationship('sessions')
log = relationship('logs')
def __init__(self, username, email, password):
self.username = username
self.email = email
self.password = password
self.created_at = datetime.now()
posts = relationship('Posts', backref='users')
groups = relationship('Groups', backref='users')
session = relationship('Sessions', backref='users')
log = relationship('Logs', backref='users')
class posts (base): # pylint: disable=too-few-public-methods, C0103
class Posts (base): # pylint: disable=too-few-public-methods, C0103
"""
Post table
Joins with group_junction
@ -48,23 +46,34 @@ class posts (base): # pylint: disable=too-few-public-methods, C0103
__tablename__ = 'posts'
id = Column(Integer, primary_key=True)
file_name = Column(String, unique=True, nullable=False)
description = Column(String, nullable=False)
alt = Column(String, nullable=False)
author_id = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, nullable=False)
junction = relationship('group_junction')
file_name = Column(String, unique=True, nullable=False)
file_type = Column(String, nullable=False)
def __init__(self, file_name, description, alt, author_id):
self.file_name = file_name
self.description = description
self.alt = alt
self.author_id = author_id
self.created_at = datetime.now()
image_exif = Column(PickleType, nullable=False)
image_colours = Column(PickleType, nullable=False)
post_description = Column(String, nullable=False)
post_alt = Column(String, nullable=False)
junction = relationship('GroupJunction', backref='posts')
class groups (base): # pylint: disable=too-few-public-methods, C0103
class Thumbnails (base): # pylint: disable=too-few-public-methods, C0103
"""
Thumbnail table
"""
__tablename__ = 'thumbnails'
id = Column(Integer, primary_key=True)
file_name = Column(String, unique=True, nullable=False)
file_ext = Column(String, nullable=False)
data = Column(PickleType, nullable=False)
class Groups (base): # pylint: disable=too-few-public-methods, C0103
"""
Group table
Joins with group_junction
@ -77,16 +86,10 @@ class groups (base): # pylint: disable=too-few-public-methods, C0103
author_id = Column(Integer, ForeignKey('users.id'))
created_at = Column(DateTime, nullable=False)
junction = relationship('group_junction')
def __init__(self, name, description, author_id):
self.name = name
self.description = description
self.author_id = author_id
self.created_at = datetime.now()
junction = relationship('GroupJunction', backref='groups')
class group_junction (base): # pylint: disable=too-few-public-methods, C0103
class GroupJunction (base): # pylint: disable=too-few-public-methods, C0103
"""
Junction table for posts and groups
Joins with posts and groups
@ -94,15 +97,12 @@ class group_junction (base): # pylint: disable=too-few-public-methods, C0103
__tablename__ = 'group_junction'
id = Column(Integer, primary_key=True)
date_added = Column(DateTime, nullable=False)
group_id = Column(Integer, ForeignKey('groups.id'))
post_id = Column(Integer, ForeignKey('posts.id'))
def __init__(self, group_id, post_id):
self.group_id = group_id
self.post_id = post_id
class sessions (base): # pylint: disable=too-few-public-methods, C0103
class Sessions (base): # pylint: disable=too-few-public-methods, C0103
"""
Session table
Joins with user
@ -117,16 +117,8 @@ class sessions (base): # pylint: disable=too-few-public-methods, C0103
active = Column(Boolean, nullable=False)
created_at = Column(DateTime, nullable=False)
def __init__(self, user_id, session_uuid, ip_address, user_agent, active): # pylint: disable=too-many-arguments, C0103
self.user_id = user_id
self.session_uuid = session_uuid
self.ip_address = ip_address
self.user_agent = user_agent
self.active = active
self.created_at = datetime.now()
class logs (base): # pylint: disable=too-few-public-methods, C0103
class Logs (base): # pylint: disable=too-few-public-methods, C0103
"""
Log table
Joins with user
@ -140,15 +132,8 @@ class logs (base): # pylint: disable=too-few-public-methods, C0103
msg = Column(String, nullable=False)
created_at = Column(DateTime, nullable=False)
def __init__(self, user_id, ip_address, code, msg):
self.user_id = user_id
self.ip_address = ip_address
self.code = code
self.msg = msg
self.created_at = datetime.now()
class bans (base): # pylint: disable=too-few-public-methods, C0103
class Bans (base): # pylint: disable=too-few-public-methods, C0103
"""
Bans table
"""
@ -160,11 +145,8 @@ class bans (base): # pylint: disable=too-few-public-methods, C0103
msg = Column(String, nullable=False)
created_at = Column(DateTime, nullable=False)
def __init__(self, ip_address, code, msg):
self.ip_address = ip_address
self.code = code
self.msg = msg
self.created_at = datetime.now()
base.metadata.create_all(engine)
# check if database file exists, if not create it
if not os.path.isfile(DB_PATH):
base.metadata.create_all(engine)
print('Database created')

305
gallery/routes/api.py Normal file
View file

@ -0,0 +1,305 @@
"""
Onlylegs - API endpoints
Used internally by the frontend and possibly by other applications
"""
from uuid import uuid4
import os
import pathlib
import io
import logging
from datetime import datetime as dt
from flask import (Blueprint, send_from_directory, send_file,
abort, flash, jsonify, request, g, current_app)
from werkzeug.utils import secure_filename
from colorthief import ColorThief
from PIL import Image, ImageOps, ImageFilter
from sqlalchemy.orm import sessionmaker
from gallery.auth import login_required
from gallery import db
from gallery.utils import metadata as mt
blueprint = Blueprint('api', __name__, url_prefix='/api')
db_session = sessionmaker(bind=db.engine)
db_session = db_session()
@blueprint.route('/file/<file_name>', methods=['GET'])
def get_file(file_name):
"""
Returns a file from the uploads folder
r for resolution, 400x400 or thumb for thumbnail
f is whether to apply filters to the image, such as blurring NSFW images
b is whether to force blur the image, even if it's not NSFW
"""
# Get args
res = request.args.get('r', default=None, type=str) # Type of file (thumb, etc)
filtered = request.args.get('f', default=False, type=bool) # Whether to apply filters # pylint: disable=W0612
blur = request.args.get('b', default=False, type=bool) # Whether to force blur
file_name = secure_filename(file_name) # Sanitize file name
# if no args are passed, return the raw file
if not request.args:
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)
buff = io.BytesIO()
img = None # Image object to be set
try: # Open image and set extension
img = Image.open(os.path.join(current_app.config['UPLOAD_FOLDER'], file_name))
except FileNotFoundError: # FileNotFound is raised if the file doesn't exist
logging.error('File not found: %s', file_name)
abort(404)
except OSError as err: # OSError is raised if the file is broken or corrupted
logging.error('Possibly broken image %s, error: %s', file_name, err)
abort(500)
img_ext = pathlib.Path(file_name).suffix.replace('.', '').lower() # Get file extension
img_ext = current_app.config['ALLOWED_EXTENSIONS'][img_ext] # Convert to MIME type
img_icc = img.info.get("icc_profile") # Get ICC profile
img = ImageOps.exif_transpose(img) # Rotate image based on EXIF data
# Todo: If type is thumb(nail), return from database instead of file system pylint: disable=W0511
# as it's faster than generating a new thumbnail on every request
if res:
if res in ['thumb', 'thumbnail']:
width, height = 400, 400
elif res in ['prev', 'preview']:
width, height = 1920, 1080
else:
try:
width, height = res.split('x')
width = int(width)
height = int(height)
except ValueError:
abort(400)
img.thumbnail((width, height), Image.LANCZOS)
# Todo: If the image has a NSFW tag, blur image for example pylint: disable=W0511
# if filtered:
# pass
# If forced to blur, blur image
if blur:
img = img.filter(ImageFilter.GaussianBlur(20))
try:
img.save(buff, img_ext, icc_profile=img_icc)
except OSError:
# This usually happens when saving a JPEG with an ICC profile,
# so we convert to RGB and try again
img = img.convert('RGB')
img.save(buff, img_ext, icc_profile=img_icc)
except Exception as err:
logging.error('Could not resize image %s, error: %s', file_name, err)
abort(500)
img.close() # Close image to free memory, learned the hard way
buff.seek(0) # Reset buffer to start
return send_file(buff, mimetype='image/' + img_ext)
@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 Exception as err:
logging.error('Could not save file: %s', 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
try:
query = db.Posts(author_id=g.user.id,
created_at=dt.utcnow(),
file_name=img_name+'.'+img_ext,
file_type=img_ext,
image_exif=img_exif,
image_colours=img_colors,
post_description=form['description'],
post_alt=form['alt'])
db_session.add(query)
db_session.commit()
except Exception as err:
logging.error('Could not save to database: %s', err)
abort(500)
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
"""
img = db_session.query(db.Posts).filter_by(id=image_id).first()
if img is None:
abort(404)
if img.author_id != g.user.id:
abort(403)
try:
os.remove(os.path.join(current_app.config['UPLOAD_FOLDER'],img.file_name))
except FileNotFoundError:
# File was already deleted or doesn't exist
logging.warning('File not found: %s, already deleted or never existed', img.file_name)
except Exception as err:
logging.error('Could not remove file: %s', err)
abort(500)
try:
db_session.query(db.Posts).filter_by(id=image_id).delete()
groups = db_session.query(db.GroupJunction).filter_by(post_id=image_id).all()
for group in groups:
db_session.delete(group)
db_session.commit()
except Exception as err:
logging.error('Could not remove from database: %s', err)
abort(500)
logging.info('Removed image (%s) %s', image_id, img.file_name)
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 = db.Groups(name=request.form['name'],
description=request.form['description'],
author_id=g.user.id,
created_at=dt.utcnow())
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']
group = db_session.query(db.Groups).filter_by(id=group_id).first()
if group is None:
abort(404)
elif group.author_id != g.user.id:
abort(403)
if request.form['action'] == 'add':
if not db_session.query(db.GroupJunction)\
.filter_by(group_id=group_id, post_id=image_id)\
.first():
db_session.add(db.GroupJunction(group_id=group_id,
post_id=image_id,
date_added=dt.utcnow()))
elif request.form['action'] == 'remove':
db_session.query(db.GroupJunction)\
.filter_by(group_id=group_id, post_id=image_id)\
.delete()
db_session.commit()
return ':3'
@blueprint.route('/metadata/<int:img_id>', methods=['GET'])
def metadata(img_id):
"""
Yoinks metadata from an image
"""
img = db_session.query(db.Posts).filter_by(id=img_id).first()
if not img:
abort(404)
img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name)
exif = mt.Metadata(img_path).yoink()
return jsonify(exif)
@blueprint.route('/logfile')
@login_required
def logfile():
"""
Gets the log file and returns it as a JSON object
"""
log_dict = {}
with open('only.log', encoding='utf-8', mode='r') as file:
for i, line in enumerate(file):
line = line.split(' : ')
event = line[0].strip().split(' ')
event_data = {
'date': event[0],
'time': event[1],
'severity': event[2],
'owner': event[3]
}
message = line[1].strip()
try:
message_data = {
'code': int(message[1:4]),
'message': message[5:].strip()
}
except ValueError:
message_data = {'code': 0, 'message': message}
except Exception as err:
logging.error('Could not parse log file: %s', err)
abort(500)
log_dict[i] = {'event': event_data, 'message': message_data}
return jsonify(log_dict)

106
gallery/routes/groups.py Normal file
View file

@ -0,0 +1,106 @@
"""
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, abort, render_template, url_for
from sqlalchemy.orm import sessionmaker
from gallery import db
blueprint = Blueprint('group', __name__, url_prefix='/group')
db_session = sessionmaker(bind=db.engine)
db_session = db_session()
@blueprint.route('/', methods=['GET'])
def groups():
"""
Group overview, shows all image groups
"""
group_list = db_session.query(db.Groups).all()
for group_item in group_list:
thumbnail = db_session.query(db.GroupJunction.post_id)\
.filter(db.GroupJunction.group_id == group_item.id)\
.order_by(db.GroupJunction.date_added.desc())\
.first()
if thumbnail:
group_item.thumbnail = db_session.query(db.Posts.file_name, db.Posts.post_alt,
db.Posts.image_colours, db.Posts.id)\
.filter(db.Posts.id == thumbnail[0])\
.first()
return render_template('groups/list.html', groups=group_list)
@blueprint.route('/<int:group_id>')
def group(group_id):
"""
Group view, shows all images in a group
"""
group_item = db_session.query(db.Groups).filter(db.Groups.id == group_id).first()
if group_item is None:
abort(404, 'Group not found! D:')
group_item.author_username = db_session.query(db.Users.username)\
.filter(db.Users.id == group_item.author_id)\
.first()[0]
group_images = db_session.query(db.GroupJunction.post_id)\
.filter(db.GroupJunction.group_id == group_id)\
.order_by(db.GroupJunction.date_added.desc())\
.all()
images = []
for image in group_images:
image = db_session.query(db.Posts).filter(db.Posts.id == image[0]).first()
images.append(image)
return render_template('groups/group.html', group=group_item, images=images)
@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
"""
img = db_session.query(db.Posts).filter(db.Posts.id == image_id).first()
if img is None:
abort(404, 'Image not found')
img.author_username = db_session.query(db.Users.username)\
.filter(db.Users.id == img.author_id)\
.first()[0]
group_list = db_session.query(db.GroupJunction.group_id)\
.filter(db.GroupJunction.post_id == image_id)\
.all()
img.group_list = []
for group_item in group_list:
group_item = db_session.query(db.Groups).filter(db.Groups.id == group_item[0]).first()
img.group_list.append(group_item)
next_url = db_session.query(db.GroupJunction.post_id)\
.filter(db.GroupJunction.group_id == group_id)\
.filter(db.GroupJunction.post_id > image_id)\
.order_by(db.GroupJunction.date_added.asc())\
.first()
prev_url = db_session.query(db.GroupJunction.post_id)\
.filter(db.GroupJunction.group_id == group_id)\
.filter(db.GroupJunction.post_id < image_id)\
.order_by(db.GroupJunction.date_added.desc())\
.first()
if next_url is not None:
next_url = url_for('group.group_post', group_id=group_id, image_id=next_url[0])
if prev_url is not None:
prev_url = url_for('group.group_post', group_id=group_id, image_id=prev_url[0])
return render_template('image.html', image=img, next_url=next_url, prev_url=prev_url)

81
gallery/routes/routing.py Normal file
View file

@ -0,0 +1,81 @@
"""
Onlylegs Gallery - Routing
"""
from flask import Blueprint, render_template, url_for
from werkzeug.exceptions import abort
from sqlalchemy.orm import sessionmaker
from gallery import db
blueprint = Blueprint('gallery', __name__)
db_session = sessionmaker(bind=db.engine)
db_session = db_session()
@blueprint.route('/')
def index():
"""
Home page of the website, shows the feed of the latest images
"""
images = db_session.query(db.Posts.file_name,
db.Posts.post_alt,
db.Posts.image_colours,
db.Posts.created_at,
db.Posts.id).order_by(db.Posts.id.desc()).all()
return render_template('index.html', images=images)
@blueprint.route('/image/<int:image_id>')
def image(image_id):
"""
Image view, shows the image and its metadata
"""
img = db_session.query(db.Posts).filter(db.Posts.id == image_id).first()
if not img:
abort(404, 'Image not found :<')
img.author_username = db_session.query(db.Users.username)\
.filter(db.Users.id == img.author_id).first()[0]
groups = db_session.query(db.GroupJunction.group_id)\
.filter(db.GroupJunction.post_id == image_id).all()
img.groups = []
for group in groups:
group = db_session.query(db.Groups).filter(db.Groups.id == group[0]).first()
img.groups.append(group)
next_url = db_session.query(db.Posts.id)\
.filter(db.Posts.id > image_id)\
.order_by(db.Posts.id.asc())\
.first()
prev_url = db_session.query(db.Posts.id)\
.filter(db.Posts.id < image_id)\
.order_by(db.Posts.id.desc())\
.first()
if next_url:
next_url = url_for('gallery.image', image_id=next_url[0])
if prev_url:
prev_url = url_for('gallery.image', image_id=prev_url[0])
return render_template('image.html', image=img, next_url=next_url, prev_url=prev_url)
@blueprint.route('/profile')
def profile():
"""
Profile overview, shows all profiles on the onlylegs gallery
"""
return render_template('profile.html', user_id='gwa gwa')
@blueprint.route('/profile/<int:user_id>')
def profile_id(user_id):
"""
Shows user ofa given id, displays their uploads and other info
"""
return render_template('profile.html', user_id=user_id)

View file

@ -17,6 +17,7 @@ def general():
"""
return render_template('settings/general.html')
@blueprint.route('/server')
@login_required
def server():
@ -25,6 +26,7 @@ def server():
"""
return render_template('settings/server.html')
@blueprint.route('/account')
@login_required
def account():
@ -33,6 +35,7 @@ def account():
"""
return render_template('settings/account.html')
@blueprint.route('/logs')
@login_required
def logs():

View file

@ -1,74 +0,0 @@
"""
Onlylegs Gallery - Routing
"""
import os
from flask import Blueprint, render_template, current_app
from werkzeug.exceptions import abort
from sqlalchemy.orm import sessionmaker
from . import db
from . import metadata as mt
blueprint = Blueprint('gallery', __name__)
db_session = sessionmaker(bind=db.engine)
db_session = db_session()
@blueprint.route('/')
def index():
"""
Home page of the website, shows the feed of latest images
"""
images = db_session.query(db.posts).order_by(db.posts.id.desc()).all()
return render_template('index.html',
images=images,
image_count=len(images),
name=current_app.config['WEBSITE']['name'],
motto=current_app.config['WEBSITE']['motto'])
@blueprint.route('/image/<int:image_id>')
def image(image_id):
"""
Image view, shows the image and its metadata
"""
img = db_session.query(db.posts).filter_by(id=image_id).first()
if img is None:
abort(404)
img_path = os.path.join(current_app.config['UPLOAD_FOLDER'], img.file_name)
exif = mt.Metadata(img_path).yoink()
return render_template('image.html', image=img, exif=exif)
@blueprint.route('/group')
def groups():
"""
Group overview, shows all image groups
"""
return render_template('group.html', group_id='gwa gwa')
@blueprint.route('/group/<int:group_id>')
def group(group_id):
"""
Group view, shows all images in a group
"""
return render_template('group.html', group_id=group_id)
@blueprint.route('/profile')
def profile():
"""
Profile overview, shows all profiles on the onlylegs gallery
"""
return render_template('profile.html', user_id='gwa gwa')
@blueprint.route('/profile/<int:user_id>')
def profile_id(user_id):
"""
Shows user ofa given id, displays their uploads and other info
"""
return render_template('profile.html', user_id=user_id)

View file

@ -1,98 +0,0 @@
"""
OnlyLegs - Setup
Runs when the app detects that there is no user directory
"""
import os
import sys
import platformdirs
import yaml
USER_DIR = platformdirs.user_config_dir('onlylegs')
class SetupApp:
"""
Setup the application on first run
"""
def __init__(self):
"""
Main setup function
"""
print("Running setup...")
if not os.path.exists(USER_DIR):
self.make_dir()
if not os.path.exists(os.path.join(USER_DIR, '.env')):
self.make_env()
if not os.path.exists(os.path.join(USER_DIR, 'conf.yml')):
self.make_yaml()
def make_dir(self):
"""
Create the user directory
"""
try:
os.makedirs(USER_DIR)
os.makedirs(os.path.join(USER_DIR, 'instance'))
print("Created user directory at:", USER_DIR)
except Exception as err:
print("Error creating user directory:", err)
sys.exit(1) # exit with error code
def make_env(self):
"""
Create the .env file with default values
"""
env_conf = {
'FLASK_SECRETE': 'dev',
}
try:
with open(os.path.join(USER_DIR, '.env'), encoding='utf-8') as file:
for key, value in env_conf.items():
file.write(f"{key}={value}\n")
print("Created environment variables")
except Exception as err:
print("Error creating environment variables:", err)
sys.exit(1)
print("Generated default .env file. EDIT IT BEFORE RUNNING THE APP AGAIN!")
def make_yaml(self):
"""
Create the YAML config file with default values
"""
yaml_conf = {
'admin': {
'name': 'Real Person',
'username': 'User',
'email': 'real-email@some.place'
},
'upload': {
'allowed-extensions': {
'jpg': 'jpeg',
'jpeg': 'jpeg',
'png': 'png',
'webp': 'webp'
},
'max-size': 69,
'rename': 'GWA_\{\{username\}\}_\{\{time\}\}'
},
'website': {
'name': 'OnlyLegs',
'motto': 'Gwa Gwa',
'language': 'english'
},
'server': {
'host': '0.0.0.0',
'port': 5000
},
}
try:
with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8') as file:
yaml.dump(yaml_conf, file, default_flow_style=False)
print("Created default gallery config")
except Exception as err:
print("Error creating default gallery config:", err)
sys.exit(1)
print("Generated default YAML config. EDIT IT BEFORE RUNNING THE APP AGAIN!")

File diff suppressed because one or more lines are too long

Before

(image error) Size: 19 KiB

File diff suppressed because one or more lines are too long

After

(image error) Size: 225 KiB

Binary file not shown.

Before

(image error) Size: 1.1 MiB

File diff suppressed because one or more lines are too long

After

(image error) Size: 7.9 KiB

File diff suppressed because one or more lines are too long

After

(image error) Size: 8.1 KiB

View file

@ -1,108 +1,123 @@
// Function to show login
function showLogin() {
popUpShow(
'idk what to put here, just login please',
'Login!',
'Need an account? <span class="pop-up__link" onclick="showRegister()">Register!</span>',
'<button class="pop-up__btn pop-up__btn-primary-fill" form="loginForm" type="submit">Login</button>',
'<button class="btn-block" onclick="popupDissmiss()">Cancelee</button>\
<button class="btn-block primary" form="loginForm" type="submit">Login</button>',
'<form id="loginForm" onsubmit="return login(event)">\
<input class="pop-up__input" type="text" placeholder="Namey" id="username"/>\
<input class="pop-up__input" type="password" placeholder="Passywassy" id="password"/>\
<input class="input-block" type="text" placeholder="Namey" id="username"/>\
<input class="input-block" type="password" placeholder="Passywassy" id="password"/>\
</form>'
);
};
// 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;
if (formUsername === "" || formPassword === "") {
addNotification("Please fill in all fields!!!!", 3);
return;
}
// Make form
var formData = new FormData();
formData.append("username", formUsername);
formData.append("password", formPassword);
$.ajax({
url: '/auth/login',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function (response) {
location.reload();
},
error: function (response) {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here... Wrong information', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
});
}
// Function to show register
function showRegister() {
popUpShow(
'Who are you?',
'Already have an account? <span class="pop-up__link" onclick="showLogin()">Login!</span>',
'<button class="pop-up__btn pop-up__btn-primary-fill" form="registerForm" type="submit">Register</button>',
'<button class="btn-block" onclick="popupDissmiss()">Canceleee</button>\
<button class="btn-block primary" form="registerForm" type="submit">Register</button>',
'<form id="registerForm" onsubmit="return register(event)">\
<input class="pop-up__input" type="text" placeholder="Namey" id="username"/>\
<input class="pop-up__input" type="text" placeholder="E mail!" id="email"/>\
<input class="pop-up__input" type="password" placeholder="Passywassy" id="password"/>\
<input class="pop-up__input" type="password" placeholder="Passywassy again!" id="password-repeat"/>\
<input class="input-block" type="text" placeholder="Namey" id="username"/>\
<input class="input-block" type="text" placeholder="E mail!" id="email"/>\
<input class="input-block" type="password" placeholder="Passywassy" id="password"/>\
<input class="input-block" type="password" placeholder="Passywassy again!" id="password-repeat"/>\
</form>'
);
};
function login(event) {
// AJAX takes control of subby form
event.preventDefault();
if ($("#username").val() === "" || $("#password").val() === "") {
addNotification("Please fill in all fields", 3);
} else {
// Make form
var formData = new FormData();
formData.append("username", $("#username").val());
formData.append("password", $("#password").val());
$.ajax({
url: '/auth/login',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function (response) {
location.reload();
},
error: function (response) {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here... Wrong information', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
});
}
}
// Function to register
function register(obj) {
// AJAX takes control of subby form
event.preventDefault();
if ($("#username").val() === "" || $("#email").val() === "" || $("#password").val() === "" || $("#password-repeat").val() === "") {
addNotification("Please fill in all fields", 3);
} else {
// Make form
var formData = new FormData();
formData.append("username", $("#username").val());
formData.append("email", $("#email").val());
formData.append("password", $("#password").val());
formData.append("password-repeat", $("#password-repeat").val());
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;
$.ajax({
url: '/auth/register',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function (response) {
if (response === "gwa gwa") {
addNotification('Registered successfully! Now please login to continue', 1);
showLogin();
} else {
for (var i = 0; i < response.length; i++) {
addNotification(response[i], 2);
}
}
},
error: function (response) {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here...', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
if (formUsername === "" || formEmail === "" || formPassword === "" || formPasswordRepeat === "") {
addNotification("Please fill in all fields!!!!", 3);
return;
}
// Make form
var formData = new FormData();
formData.append("username", formUsername);
formData.append("email", formEmail);
formData.append("password", formPassword);
formData.append("password-repeat", formPasswordRepeat);
$.ajax({
url: '/auth/register',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function (response) {
if (response === "gwa gwa") {
addNotification('Registered successfully! Now please login to continue', 1);
showLogin();
} else {
for (var i = 0; i < response.length; i++) {
addNotification(response[i], 2);
}
}
});
}
},
error: function (response) {
switch (response.status) {
case 500:
addNotification('Server exploded, F\'s in chat', 2);
break;
case 403:
addNotification('None but devils play past here...', 2);
break;
default:
addNotification('Error logging in, blame someone', 2);
break;
}
}
});
}

View file

@ -1,168 +1,92 @@
let navToggle = true;
document.onscroll = function() {
try {
document.querySelector('.background-decoration').style.opacity = `${1 - window.scrollY / 621}`;
document.querySelector('.background-decoration').style.top = `-${window.scrollY / 5}px`;
} catch (e) {
console.log('No background decoration found');
}
try {
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
document.querySelector('.banner').classList = 'banner banner-scrolled';
} else {
document.querySelector('.banner').classList = 'banner';
}
}
catch (e) {
console.log('No banner found');
}
if (document.body.scrollTop > 300 || document.documentElement.scrollTop > 20) {
document.querySelector('.jumpUp').classList = 'jumpUp jumpUp--show';
// fade in images
function imgFade(obj, time = 250) {
$(obj).animate({ opacity: 1 }, time);
}
// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
function colourContrast(bgColor, lightColor, darkColor, threshold = 0.179) {
// if color is in hex format then convert to rgb else parese rgb
if (bgColor.charAt(0) === '#') {
var color = (bgColor.charAt(0) === '#') ? bgColor.substring(1, 7) : bgColor;
var r = parseInt(color.substring(0, 2), 16); // hexToR
var g = parseInt(color.substring(2, 4), 16); // hexToG
var b = parseInt(color.substring(4, 6), 16); // hexToB
} else {
document.querySelector('.jumpUp').classList = 'jumpUp';
var color = bgColor.replace('rgb(', '').replace(')', '').split(',');
var r = color[0];
var g = color[1];
var b = color[2];
}
}
document.querySelector('.jumpUp').onclick = function() {
document.body.scrollTop = 0;
document.documentElement.scrollTop = 0;
}
function imgFade(obj) {
$(obj).animate({opacity: 1}, 250);
}
var times = document.getElementsByClassName('time');
for (var i = 0; i < times.length; i++) {
var time = times[i].innerHTML;
var date = new Date(time);
times[i].innerHTML = date.toLocaleString('en-GB');
}
function addNotification(text='Sample notification', type=4) {
var container = document.querySelector('.notifications');
// Create notification element
var div = document.createElement('div');
div.classList.add('sniffle__notification');
div.onclick = function() {
if (div.parentNode) {
div.classList.add('sniffle__notification--hide');
setTimeout(function() {
container.removeChild(div);
}, 500);
var uicolors = [r / 255, g / 255, b / 255];
var c = uicolors.map((col) => {
if (col <= 0.03928) {
return col / 12.92;
}
};
// Create icon element and append to notification
var icon = document.createElement('span');
icon.classList.add('sniffle__notification-icon');
switch (type) {
case 1:
div.classList.add('sniffle__notification--success');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -7 24 24" fill="currentColor">\
<path d="M5.486 9.73a.997.997 0 0 1-.707-.292L.537 5.195A1 1 0 1 1 1.95 3.78l3.535 3.535L11.85.952a1 1 0 0 1 1.415 1.414L6.193 9.438a.997.997 0 0 1-.707.292z"></path>\
</svg>';
break;
case 2:
div.classList.add('sniffle__notification--error');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -6 24 24" fill="currentColor">\
<path d="M7.314 5.9l3.535-3.536A1 1 0 1 0 9.435.95L5.899 4.485 2.364.95A1 1 0 1 0 .95 2.364l3.535 3.535L.95 9.435a1 1 0 1 0 1.414 1.414l3.535-3.535 3.536 3.535a1 1 0 1 0 1.414-1.414L7.314 5.899z"></path>\
</svg>';
break;
case 3:
div.classList.add('sniffle__notification--warning');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -3 24 24" fill="currentColor">\
<path d="M12.8 1.613l6.701 11.161c.963 1.603.49 3.712-1.057 4.71a3.213 3.213 0 0 1-1.743.516H3.298C1.477 18 0 16.47 0 14.581c0-.639.173-1.264.498-1.807L7.2 1.613C8.162.01 10.196-.481 11.743.517c.428.276.79.651 1.057 1.096zm-2.22.839a1.077 1.077 0 0 0-1.514.365L2.365 13.98a1.17 1.17 0 0 0-.166.602c0 .63.492 1.14 1.1 1.14H16.7c.206 0 .407-.06.581-.172a1.164 1.164 0 0 0 .353-1.57L10.933 2.817a1.12 1.12 0 0 0-.352-.365zM10 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0-9a1 1 0 0 1 1 1v4a1 1 0 0 1-2 0V6a1 1 0 0 1 1-1z"></path>\
</svg>';
break;
default:
div.classList.add('sniffle__notification--info');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">\
<path d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-10a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1zm0-1a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>\
</svg>';
break;
}
div.appendChild(icon);
// Create text element and append to notification
var description = document.createElement('span');
description.classList.add('sniffle__notification-text');
description.innerHTML = text;
div.appendChild(description);
// Create span to show time remaining
var timer = document.createElement('span');
timer.classList.add('sniffle__notification-time');
div.appendChild(timer);
// Append notification to container
container.appendChild(div);
setTimeout(function() {
div.classList.add('sniffle__notification-show');
}, 100);
// Remove notification after 5 seconds
setTimeout(function() {
if (div.parentNode) {
div.classList.add('sniffle__notification--hide');
setTimeout(function() {
container.removeChild(div);
}, 500);
}
}, 5000);
return Math.pow((col + 0.055) / 1.055, 2.4);
});
var L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2]);
return (L > threshold) ? darkColor : lightColor;
}
// Lazy load images when they are in view
function loadOnView() {
let lazyLoad = document.querySelectorAll('#lazy-load');
function popUpShow(title, body, actions, content) {
var popup = document.querySelector('.pop-up');
var popupContent = document.querySelector('.pop-up-content');
var popupActions = document.querySelector('.pop-up-controlls');
// Set tile and description
h3 = document.createElement('h3');
h3.innerHTML = title;
p = document.createElement('p');
p.innerHTML = body;
popupContent.innerHTML = '';
popupContent.appendChild(h3);
popupContent.appendChild(p);
// Set content
if (content != '') {
popupContent.innerHTML += content;
}
// Set buttons that will be displayed
popupActions.innerHTML = '';
if (actions != '') {
popupActions.innerHTML += actions;
}
popupActions.innerHTML += '<button class="pop-up__btn pop-up__btn-fill" onclick="popupDissmiss()">Nooooooo</button>';
// Show popup
popup.classList.add('pop-up__active');
}
function popupDissmiss() {
var popup = document.querySelector('.pop-up');
popup.classList.add('pop-up__hide');
setTimeout(function() {
popup.classList = 'pop-up';
}, 200);
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
if (document.querySelector('.pop-up').classList.contains('pop-up__active')) {
popupDissmiss();
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) {
image.src = `/api/file/${image.getAttribute('data-src')}?r=thumb`
}
}
}
});
}
window.onload = function () {
loadOnView();
const darkColor = '#151515';
const lightColor = '#E8E3E3';
let contrastCheck = document.querySelectorAll('#contrast-check');
for (let i = 0; i < contrastCheck.length; i++) {
console.log(contrastCheck[i].getAttribute('data-color'));
bgColor = contrastCheck[i].getAttribute('data-color');
contrastCheck[i].style.color = colourContrast(bgColor, lightColor, darkColor);
}
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();
}
};
window.onscroll = function () {
loadOnView();
// Jump to top 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;
}
};
window.onresize = function () {
loadOnView();
};

View file

@ -0,0 +1,75 @@
function addNotification(text='Sample notification', type=4) {
var container = document.querySelector('.notifications');
// Create notification element
var div = document.createElement('div');
div.classList.add('sniffle__notification');
div.onclick = function() {
if (div.parentNode) {
div.classList.add('sniffle__notification--hide');
setTimeout(function() {
container.removeChild(div);
}, 500);
}
};
// Create icon element and append to notification
var icon = document.createElement('span');
icon.classList.add('sniffle__notification-icon');
switch (type) {
case 1:
div.classList.add('sniffle__notification--success');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -7 24 24" fill="currentColor">\
<path d="M5.486 9.73a.997.997 0 0 1-.707-.292L.537 5.195A1 1 0 1 1 1.95 3.78l3.535 3.535L11.85.952a1 1 0 0 1 1.415 1.414L6.193 9.438a.997.997 0 0 1-.707.292z"></path>\
</svg>';
break;
case 2:
div.classList.add('sniffle__notification--error');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-6 -6 24 24" fill="currentColor">\
<path d="M7.314 5.9l3.535-3.536A1 1 0 1 0 9.435.95L5.899 4.485 2.364.95A1 1 0 1 0 .95 2.364l3.535 3.535L.95 9.435a1 1 0 1 0 1.414 1.414l3.535-3.535 3.536 3.535a1 1 0 1 0 1.414-1.414L7.314 5.899z"></path>\
</svg>';
break;
case 3:
div.classList.add('sniffle__notification--warning');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -3 24 24" fill="currentColor">\
<path d="M12.8 1.613l6.701 11.161c.963 1.603.49 3.712-1.057 4.71a3.213 3.213 0 0 1-1.743.516H3.298C1.477 18 0 16.47 0 14.581c0-.639.173-1.264.498-1.807L7.2 1.613C8.162.01 10.196-.481 11.743.517c.428.276.79.651 1.057 1.096zm-2.22.839a1.077 1.077 0 0 0-1.514.365L2.365 13.98a1.17 1.17 0 0 0-.166.602c0 .63.492 1.14 1.1 1.14H16.7c.206 0 .407-.06.581-.172a1.164 1.164 0 0 0 .353-1.57L10.933 2.817a1.12 1.12 0 0 0-.352-.365zM10 14a1 1 0 1 1 0-2 1 1 0 0 1 0 2zm0-9a1 1 0 0 1 1 1v4a1 1 0 0 1-2 0V6a1 1 0 0 1 1-1z"></path>\
</svg>';
break;
default:
div.classList.add('sniffle__notification--info');
icon.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">\
<path d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-10a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1zm0-1a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>\
</svg>';
break;
}
div.appendChild(icon);
// Create text element and append to notification
var description = document.createElement('span');
description.classList.add('sniffle__notification-text');
description.innerHTML = text;
div.appendChild(description);
// Create span to show time remaining
var timer = document.createElement('span');
timer.classList.add('sniffle__notification-time');
div.appendChild(timer);
// Append notification to container
container.appendChild(div);
setTimeout(function() {
div.classList.add('sniffle__notification-show');
}, 100);
// Remove notification after 5 seconds
setTimeout(function() {
if (div.parentNode) {
div.classList.add('sniffle__notification--hide');
setTimeout(function() {
container.removeChild(div);
}, 500);
}
}, 5000);
}

View file

@ -0,0 +1,42 @@
function popUpShow(title, body, actions, content) {
// Stop scrolling
document.querySelector("html").style.overflow = "hidden";
// Get popup elements
var popup = document.querySelector('.pop-up');
var popupContent = document.querySelector('.pop-up-content');
var popupActions = document.querySelector('.pop-up-controlls');
// Set popup content
popupContent.innerHTML = `<h3>${title}</h3><p>${body}</p>${content}`;
// Set buttons that will be displayed
popupActions.innerHTML = actions;
// Show popup
popup.style.display = 'block';
setTimeout(function() {
popup.classList.add('active')
}, 10);
}
function popupDissmiss() {
// un-Stop scrolling
document.querySelector("html").style.overflow = "auto";
var popup = document.querySelector('.pop-up');
popup.classList.remove('active');
setTimeout(function() {
popup.style.display = 'none';
}, 200);
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape') {
if (document.querySelector('.pop-up').classList.contains('active')) {
popupDissmiss();
}
}
});

View file

@ -1,69 +0,0 @@
function showUpload() {
popUpShow(
'Upload funny stuff',
'May the world see your stuff 👀',
'<button class="pop-up__btn pop-up__btn-primary-fill" form="uploadForm" type"submit">Upload</button>',
'<form id="uploadForm" onsubmit="return uploadFile(event)">\
<input class="pop-up__input" type="file" id="file"/>\
<input class="pop-up__input" type="text" placeholder="alt" id="alt"/>\
<input class="pop-up__input" type="text" placeholder="description" id="description"/>\
<input class="pop-up__input" type="text" placeholder="tags" id="tags"/>\
</form>'
);
};
function uploadFile(){
// AJAX takes control of subby form
event.preventDefault();
// Check for empty upload
if ($("#file").val() === "") {
addNotification("Please select a file to upload", 2);
} else {
// Make form
var formData = new FormData();
formData.append("file", $("#file").prop("files")[0]);
formData.append("alt", $("#alt").val());
formData.append("description", $("#description").val());
formData.append("tags", $("#tags").val());
formData.append("submit", $("#submit").val());
// Upload the information
$.ajax({
url: '/api/upload',
type: 'post',
data: formData,
contentType: false,
processData: false,
success: function (response) {
addNotification("File uploaded successfully!", 1);
// popupDissmiss(); // Close popup
},
error: function (response) {
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!!!!!!', 3);
break;
default:
addNotification('Error uploading file, blame someone', 2);
break;
}
}
});
// Empty values
$("#file").val("");
$("#alt").val("");
$("#description").val("");
$("#tags").val("");
}
};

View file

@ -0,0 +1,128 @@
// Function to upload images
function uploadFile() {
// AJAX takes control of subby form
event.preventDefault();
const jobList = document.querySelector(".upload-jobs");
// Check for empty upload
if ($("#file").val() === "") {
addNotification("Please select a file to upload", 2);
} else {
// Make form
let formData = new FormData();
formData.append("file", $("#file").prop("files")[0]);
formData.append("alt", $("#alt").val());
formData.append("description", $("#description").val());
formData.append("tags", $("#tags").val());
formData.append("submit", $("#submit").val());
// Upload the information
$.ajax({
url: '/api/upload',
type: 'post',
data: formData,
contentType: false,
processData: false,
beforeSend: function () {
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").prop("files")[0]);
jobImgFilter = document.createElement("span");
jobImgFilter.classList.add("img-filter");
jobContainer.appendChild(jobStatus);
jobContainer.appendChild(jobProgress);
jobContainer.appendChild(jobImg);
jobContainer.appendChild(jobImgFilter);
jobList.appendChild(jobContainer);
},
success: function (response) {
jobContainer.classList.add("success");
jobStatus.innerHTML = "Uploaded!";
if (!document.querySelector(".upload-panel").classList.contains("open")) {
addNotification("Image uploaded successfully", 1);
}
},
error: function (response) {
jobContainer.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);
}
},
});
// Empty values
$("#file").val("");
$("#alt").val("");
$("#description").val("");
$("#tags").val("");
}
};
// open upload tab
function openUploadTab() {
// Stop scrolling
document.querySelector("html").style.overflow = "hidden";
document.querySelector(".content").tabIndex = "-1";
// Open upload tab
const uploadTab = document.querySelector(".upload-panel");
uploadTab.style.display = "block";
setTimeout(function () {
uploadTab.classList.add("open");
}, 10);
}
// close upload tab
function closeUploadTab() {
// un-Stop scrolling
document.querySelector("html").style.overflow = "auto";
document.querySelector(".content").tabIndex = "";
// Close upload tab
const uploadTab = document.querySelector(".upload-panel");
uploadTab.classList.remove("open");
setTimeout(function () {
uploadTab.style.display = "none";
}, 250);
}
// toggle upload tab
function toggleUploadTab() {
if (document.querySelector(".upload-panel").classList.contains("open")) {
closeUploadTab();
} else {
openUploadTab();
}
}

View file

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

View file

@ -1,15 +0,0 @@
{% extends 'layout.html' %}
{% block header %}
<div class="background-decoration">
<img src="{{ url_for('static', filename='images/background.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
</div>
{% endblock %}
{% block nav_groups %}navigation-item__selected{% endblock %}
{% block content %}
<h1>Image Group</h1>
<p>{{group_id}}</p>
{% endblock %}

View file

@ -0,0 +1,91 @@
{% extends 'layout.html' %}
{% block nav_groups %}navigation-item__selected{% endblock %}
{% block content %}
<div class="banner">
{% if images %}
<img
src="/api/file/{{ images.0.file_name }}?w=1920&h=1080"
onload="imgFade(this)"
style="opacity:0; background-color:rgb({{ images.0.image_colours.0.0 }}, {{ images.0.image_colours.0.1 }}, {{ images.0.image_colours.0.2 }})"
/>
<span
class="banner-filter"
style="background: linear-gradient(to right, rgb({{ images.0.image_colours.0.0 }}, {{ images.0.image_colours.0.1 }}, {{ images.0.image_colours.0.2 }}), transparent)"
></span>
<div class="banner-content">
<p><span id="contrast-check" data-color="rgb({{ images.0.image_colours.0.0 }}, {{ images.0.image_colours.0.1 }}, {{ images.0.image_colours.0.2 }})">{{ group.description }}</span></p>
<h1><span id="contrast-check" data-color="rgb({{ images.0.image_colours.0.0 }}, {{ images.0.image_colours.0.1 }}, {{ images.0.image_colours.0.2 }})">{{ group.name }}</span></h1>
<p><span id="contrast-check" data-color="rgb({{ images.0.image_colours.0.0 }}, {{ images.0.image_colours.0.1 }}, {{ images.0.image_colours.0.2 }})">By {{ group.author_username }} - {{ images|length }} Images</span></p>
</div>
{% else %}
<img src="{{ url_for('static', filename='images/bg.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
<div class="banner-content">
<p>{{ group.description }}</p>
<h1>{{ group.name }}</h1>
<p>By {{ group.author_username }} - {{ images|length }} Images</p>
</div>
{% endif %}
</div>
<form id="modifyGroup">
<input type="text" name="group" placeholder="group id" value="{{ group.id }}">
<input type="text" name="image" placeholder="image id">
<input type="text" name="action" placeholder="add/remove" value="add">
<button type="submit">Submit</button>
</form>
{% 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.image_colours.0.0 }}, {{ image.image_colours.0.1 }}, {{ image.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="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>No image!</h1>
{% if g.user %}
<p>You can get started by uploading an image!</p>
{% else %}
<p>Login to start uploading images!</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block script %}
<script>
// /api/group/modify
modForm = document.querySelector('#modifyGroup');
modForm.addEventListener('submit', function (event) {
event.preventDefault();
const formData = new FormData();
formData.append('group', modForm.group.value);
formData.append('image', modForm.image.value);
formData.append('action', modForm.action.value);
$.ajax({
url: '/api/group/modify',
type: 'POST',
data: formData,
processData: false,
contentType: false,
success: function (data) {
addNotification('Image added to group', 1);
}
})
})
</script>
{% endblock %}

View file

@ -0,0 +1,59 @@
{% extends 'layout.html' %}
{% block nav_groups %}navigation-item__selected{% endblock %}
{% block content %}
<div class="banner">
<img src="{{ url_for('static', filename='images/bg.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span class="banner-filter"></span>
<div class="banner-content">
<p>{{ config.WEBSITE.motto }}</p>
<h1>Groups</h1>
<p>{{ groups|length }} Groups</p>
</div>
</div>
<form action="/api/group/create" method="post" enctype="multipart/form-data">
<input type="text" name="name" placeholder="name">
<input type="text" name="description" placeholder="description">
<button type="submit">Submit</button>
</form>
{% if groups %}
<div class="gallery-grid">
{% for group in groups %}
{% if group.thumbnail %}
<a id="group-{{ group.id }}" class="gallery-item" href="{{ url_for('group.group', group_id=group.id) }}" style="background-color: rgb({{ group.thumbnail.image_colours.0.0 }}, {{ group.thumbnail.image_colours.0.1 }}, {{ group.thumbnail.image_colours.0.2 }})">
<div class="image-filter">
<p class="image-subtitle"></p>
<p class="image-title">{{ group.name }}</p>
</div>
<img data-src="{{ group.thumbnail.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/>
</a>
{% else %}
<a id="group-{{ group.id }}" class="gallery-item" href="{{ url_for('group.group', group_id=group.id) }}">
<div class="image-filter">
<p class="image-subtitle"></p>
<p class="image-title">{{ group.name }}</p>
</div>
<img src="{{ url_for('static', filename='images/error.png') }}" onload="imgFade(this)" style="opacity:0;"/>
</a>
{% endif %}
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>No image groups!</h1>
{% if g.user %}
<p>You can get started by creating a new image group!</p>
{% else %}
<p>Login to get started!</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block script %}
<script></script>
{% endblock %}

View file

@ -1,238 +1,253 @@
{% extends 'layout.html' %}
{% block header %}
<div class="background-decoration">
<img src="/api/uploads/{{ image['file_name'] }}?w=1000&h=1000" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
</div>
{% block head %}
<meta property="og:image" content="/api/file/{{ image.file_name }}"/>
<meta name="theme-color" content="#{{ image.image_colours.0.0 }}{{ image.image_colours.0.1 }}{{ image.image_colours.0.2 }}"/>
{% endblock %}
{% block wrapper_class %}image-wrapper{% endblock %}
{% block content %}
<div class="background">
<img src="/api/file/{{ image.file_name }}?r=prev" alt="{{ image.post_alt }}" onload="imgFade(this)" style="opacity:0;"/>
<span style="background-image: linear-gradient(to top, rgba({{ image.image_colours.0.0 }}, {{ image.image_colours.0.1 }}, {{ image.image_colours.0.2 }}, 1), transparent);"></span>
</div>
<div class="image-fullscreen">
<img src="" onload="imgFade(this);" style="opacity:0;" />
<img src="" alt="{{ image.post_alt }}" onload="imgFade(this);" style="opacity:0;" />
</div>
<div class="image-container">
<img
src="/api/uploads/{{ image['file_name'] }}?w=1000&h=1000"
onload="imgFade(this)" style="opacity:0;"
onerror="this.src='/static/images/error.png'"
width="{{ exif['File']['Width']['raw'] }}"
height="{{ exif['File']['Height']['raw'] }}"
/>
</div>
<div class="pill-row" id="image-tools">
<div>
<button class="pill-item" id="img-fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 24 24" fill="currentColor">
<path d="M12.586 2H11a1 1 0 0 1 0-2h4a1 1 0 0 1 1 1v4a1 1 0 0 1-2 0V3.414L9.414 8 14 12.586V11a1 1 0 0 1 2 0v4a1 1 0 0 1-1 1h-4a1 1 0 0 1 0-2h1.586L8 9.414 3.414 14H5a1 1 0 0 1 0 2H1a1 1 0 0 1-1-1v-4a1 1 0 0 1 2 0v1.586L6.586 8 2 3.414V5a1 1 0 1 1-2 0V1a1 1 0 0 1 1-1h4a1 1 0 1 1 0 2H3.414L8 6.586 12.586 2z"></path>
</svg>
<span class="tool-tip">Fullscreen</span>
</button>
<button class="pill-item" id="img-share">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -3 24 24" fill="currentColor">
<path d="M3.19 9.345a.97.97 0 0 1 1.37 0 .966.966 0 0 1 0 1.367l-2.055 2.052a1.932 1.932 0 0 0 0 2.735 1.94 1.94 0 0 0 2.74 0l4.794-4.787a.966.966 0 0 0 0-1.367.966.966 0 0 1 0-1.368.97.97 0 0 1 1.37 0 2.898 2.898 0 0 1 0 4.103l-4.795 4.787a3.879 3.879 0 0 1-5.48 0 3.864 3.864 0 0 1 0-5.47L3.19 9.344zm11.62-.69a.97.97 0 0 1-1.37 0 .966.966 0 0 1 0-1.367l2.055-2.052a1.932 1.932 0 0 0 0-2.735 1.94 1.94 0 0 0-2.74 0L7.962 7.288a.966.966 0 0 0 0 1.367.966.966 0 0 1 0 1.368.97.97 0 0 1-1.37 0 2.898 2.898 0 0 1 0-4.103l4.795-4.787a3.879 3.879 0 0 1 5.48 0 3.864 3.864 0 0 1 0 5.47L14.81 8.656z"></path>
</svg>
<span class="tool-tip">Share</span>
</button>
<a class="pill-item" id="img-download" href="/api/uploads/{{ image['file_name'] }}/0" download>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 24 24" fill="currentColor">
<path d="M8 6.641l1.121-1.12a1 1 0 0 1 1.415 1.413L7.707 9.763a.997.997 0 0 1-1.414 0L3.464 6.934A1 1 0 1 1 4.88 5.52L6 6.641V1a1 1 0 1 1 2 0v5.641zM1 12h12a1 1 0 0 1 0 2H1a1 1 0 0 1 0-2z"></path>
</svg>
<span class="tool-tip">Download</span>
</a>
</div>
{% if g.user['id'] == image['author_id'] %}
<div>
<button class="pill-item pill__critical" id="img-delete">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-3 -2 24 24" fill="currentColor">
<path d="M6 2V1a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v1h4a2 2 0 0 1 2 2v1a2 2 0 0 1-2 2h-.133l-.68 10.2a3 3 0 0 1-2.993 2.8H5.826a3 3 0 0 1-2.993-2.796L2.137 7H2a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h4zm10 2H2v1h14V4zM4.141 7l.687 10.068a1 1 0 0 0 .998.932h6.368a1 1 0 0 0 .998-.934L13.862 7h-9.72zM7 8a1 1 0 0 1 1 1v7a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1zm4 0a1 1 0 0 1 1 1v7a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1z"></path>
</svg>
<span class="tool-tip">Delete</span>
</button>
<button class="pill-item pill__critical" id="img-edit">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2.5 -2.5 24 24" fill="currentColor">
<path d="M12.238 5.472L3.2 14.51l-.591 2.016 1.975-.571 9.068-9.068-1.414-1.415zM13.78 3.93l1.414 1.414 1.318-1.318a.5.5 0 0 0 0-.707l-.708-.707a.5.5 0 0 0-.707 0L13.781 3.93zm3.439-2.732l.707.707a2.5 2.5 0 0 1 0 3.535L5.634 17.733l-4.22 1.22a1 1 0 0 1-1.237-1.241l1.248-4.255 12.26-12.26a2.5 2.5 0 0 1 3.535 0z"></path>
</svg>
<span class="tool-tip">Edit</span>
</button>
</div>
{% endif %}
<!--
<div>
<button class="tool-btn" id="img-info">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">
<path d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-10a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1zm0-1a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>
</svg>
<span class="tool-tip">Info</span>
</button>
</div>
-->
</div>
<div class="image-info__container">
{% if image['alt'] != '' %}
<div class="image-info">
<span class="image-info__collapse" id="collapse-info">
<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>
</span>
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2.5 24 24" fill="currentColor">
<path d="M3.656 17.979A1 1 0 0 1 2 17.243V15a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2H8.003l-4.347 2.979zm.844-3.093a.536.536 0 0 0 .26-.069l2.355-1.638A1 1 0 0 1 7.686 13H12a1 1 0 0 0 1-1V7a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v5c0 .54.429.982 1 1 .41.016.707.083.844.226.128.134.135.36.156.79.003.063.003.177 0 .37a.5.5 0 0 0 .5.5z"></path><path d="M16 10.017a7.136 7.136 0 0 0 0 .369v-.37c.02-.43.028-.656.156-.79.137-.143.434-.21.844-.226.571-.018 1-.46 1-1V3a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1H5V2a2 2 0 0 1 2-2h11a2 2 0 0 1 2 2v7a2 2 0 0 1-2 2v2.243a1 1 0 0 1-1.656.736L16 13.743v-3.726z"></path>
</svg>
<h2>Alt</h2>
</div>
<div class="image-info__content">
<p>{{ image['alt'] }}</p>
</div>
</div>
{% endif %}
{% if image['description'] != '' %}
<div class="image-info">
<span class="image-info__collapse" id="collapse-info">
<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>
</span>
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -2 24 24" fill="currentColor">
<path d="M3 0h10a3 3 0 0 1 3 3v14a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3zm0 2a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1H3zm2 1h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2zm0 12h2a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zm0-4h6a1 1 0 0 1 0 2H5a1 1 0 0 1 0-2zm0-4h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z"></path>
</svg>
<h2>Description</h2>
</div>
<div class="image-info__content">
<p>{{ image['description'] }}</p>
</div>
</div>
{% endif %}
<div class="image-info">
<span class="image-info__collapse" id="collapse-info">
<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>
</span>
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">
<path d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-10a1 1 0 0 1 1 1v5a1 1 0 0 1-2 0V9a1 1 0 0 1 1-1zm0-1a1 1 0 1 1 0-2 1 1 0 0 1 0 2z"></path>
</svg>
<h2>Info</h2>
</div>
<div class="image-info__content">
<table>
<tr>
<td>Image ID</td>
<td>{{ image['id'] }}</td>
</tr>
<tr>
<td>Author</td>
<td>{{ image['author_id'] }}</td>
</tr>
<tr>
<td>Upload date</td>
<td><span class="time">{{ image['created_at'] }}</span></td>
</tr>
</table>
</div>
</div>
{% for tag in exif %}
<div class="image-info">
<span class="image-info__collapse" id="collapse-info">
<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>
</span>
{% if tag == 'Photographer' %}
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -2 24 24" fill="currentColor">
<path d="M3.534 10.07a1 1 0 1 1 .733 1.86A3.579 3.579 0 0 0 2 15.26V17a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-1.647a3.658 3.658 0 0 0-2.356-3.419 1 1 0 1 1 .712-1.868A5.658 5.658 0 0 1 14 15.353V17a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-1.74a5.579 5.579 0 0 1 3.534-5.19zM7 0a4 4 0 0 1 4 4v2a4 4 0 1 1-8 0V4a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0V4a2 2 0 0 0-2-2z"></path>
</svg>
<h2>Photographer</h2>
</div>
{% elif tag == 'Camera' %}
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -4 24 24" fill="currentColor">
<path d="M5.676 5H4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-1.676l-.387-1.501A2.002 2.002 0 0 0 12 2H8a2 2 0 0 0-1.937 1.499L5.676 5zm-1.55-2C4.57 1.275 6.136 0 8 0h4a4.002 4.002 0 0 1 3.874 3H16a4 4 0 0 1 4 4v5a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V7a4 4 0 0 1 4-4h.126zM10 13a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm0-2a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6-3a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"></path>
</svg>
<h2>Camera</h2>
</div>
{% elif tag == 'Software' %}
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -4 24 24" fill="currentColor">
<path d="M2 13v1h3V2H2v9h1v2H2zM1 0h5a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H1a1 1 0 0 1-1-1V1a1 1 0 0 1 1-1zm9 3h8a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2h-8a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm0 2v6h8V5h-8zm2 9h4a1 1 0 0 1 0 2h-4a1 1 0 0 1 0-2z"></path>
</svg>
<h2>Software</h2>
</div>
{% elif tag == 'File' %}
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -2 24 24" fill="currentColor">
<path d="M14 8.322V2H2v12h3.576l3.97-5.292A3 3 0 0 1 14 8.322zm0 3.753l-1.188-2.066a1 1 0 0 0-1.667-.101L8.076 14H14v-1.925zM14 16H2v2h12v-2zM2 0h12a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2zm4 9a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"></path>
</svg>
<h2>File</h2>
</div>
{% else %}
<div class="image-info__header">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">
<path d="M14.95 7.879l-.707-.707a1 1 0 0 1 1.414-1.415l.707.707 1.414-1.414-2.828-2.828L2.222 14.95l2.828 2.828 1.414-1.414L5.05 14.95a1 1 0 0 1 1.414-1.414L7.88 14.95l1.414-1.414-.707-.708A1 1 0 0 1 10 11.414l.707.707 1.414-1.414-1.414-1.414a1 1 0 0 1 1.414-1.414l1.415 1.414 1.414-1.414zM.808 13.536L13.536.808a2 2 0 0 1 2.828 0l2.828 2.828a2 2 0 0 1 0 2.828L6.464 19.192a2 2 0 0 1-2.828 0L.808 16.364a2 2 0 0 1 0-2.828z"></path>
</svg>
<h2>{{tag}}</h2>
</div>
<div class="image-grid">
<div class="image-container" id="image-container">
<img
src="/api/file/{{ image.file_name }}?r=prev"
alt="{{ image.post_alt }}"
onload="imgFade(this)" style="opacity:0;"
onerror="this.src='/static/images/error.png'"
{% if "File" in image.image_exif %}
width="{{ image.image_exif.File.Width.raw }}"
height="{{ image.image_exif.File.Height.raw }}"
{% endif %}
<div class="image-info__content">
<table>
{% for subtag in exif[tag] %}
<tr>
<td>{{subtag}}</td>
{% if exif[tag][subtag]['formatted'] %}
{% if exif[tag][subtag]['type'] == 'date' %}
<td><span class="time">{{exif[tag][subtag]['formatted']}}</span></td>
{% else %}
<td>{{exif[tag][subtag]['formatted']}}</td>
{% endif %}
{% elif exif[tag][subtag]['raw'] %}
<td>{{exif[tag][subtag]['raw']}}</td>
{% else %}
<td class="empty-table">Oops, an error</td>
{% endif %}
</tr>
{% endfor %}
</table>
/>
</div>
<div class="pill-row" id="image-tools">
{% if next_url %}
<div>
<a class="pill-item" href="{{ next_url }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 24 24" width="24" fill="currentColor">
<path d="M3.414 7.657l3.95 3.95A1 1 0 0 1 5.95 13.02L.293 7.364a.997.997 0 0 1 0-1.414L5.95.293a1 1 0 1 1 1.414 1.414l-3.95 3.95H13a1 1 0 0 1 0 2H3.414z"></path>
</svg>
<span class="tool-tip">
Previous
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</a>
</div>
{% endif %}
<div>
<button class="pill-item" id="img-fullscreen">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,48V96L160,48ZM48,208H96L48,160Zm160,0V160l-48,48ZM48,96,96,48H48Z" opacity="0.2"></path><path d="M98.34,146.34,72,172.69,53.66,154.34A8,8,0,0,0,40,160v48a8,8,0,0,0,8,8H96a8,8,0,0,0,5.66-13.66L83.31,184l26.35-26.34a8,8,0,0,0-11.32-11.32ZM56,200V179.31L76.69,200ZM83.31,72l18.35-18.34A8,8,0,0,0,96,40H48a8,8,0,0,0-8,8V96a8,8,0,0,0,13.66,5.66L72,83.31l26.34,26.35a8,8,0,0,0,11.32-11.32ZM56,76.69V56H76.69ZM208,40H160a8,8,0,0,0-5.66,13.66L172.69,72,146.34,98.34a8,8,0,0,0,11.32,11.32L184,83.31l18.34,18.35A8,8,0,0,0,216,96V48A8,8,0,0,0,208,40Zm-8,36.69L179.31,56H200Zm11.06,75.92a8,8,0,0,0-8.72,1.73L184,172.69l-26.34-26.35a8,8,0,0,0-11.32,11.32L172.69,184l-18.35,18.34A8,8,0,0,0,160,216h48a8,8,0,0,0,8-8V160A8,8,0,0,0,211.06,152.61ZM200,200H179.31L200,179.31Z"></path></svg>
<span class="tool-tip">
Fullscreen
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</button>
<button class="pill-item" id="img-share">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M209.94,113.94l-28,28a47.76,47.76,0,0,1-26.52,13.48,47.76,47.76,0,0,1-13.48,26.52l-28,28a48,48,0,0,1-67.88-67.88l28-28a47.76,47.76,0,0,1,26.52-13.48,47.76,47.76,0,0,1,13.48-26.52l28-28a48,48,0,0,1,67.88,67.88Z" opacity="0.2"></path><path d="M137.54,186.36a8,8,0,0,1,0,11.31l-17.94,18A56,56,0,0,1,40.38,136.4L68.5,108.29A56,56,0,0,1,145.31,106a8,8,0,1,1-10.64,12,40,40,0,0,0-54.85,1.63L51.7,147.72a40,40,0,1,0,56.58,56.58l17.94-17.94A8,8,0,0,1,137.54,186.36Zm78.08-146a56.08,56.08,0,0,0-79.22,0L118.46,58.33a8,8,0,0,0,11.32,11.31L147.72,51.7a40,40,0,0,1,56.58,56.58L176.18,136.4A40,40,0,0,1,121.33,138,8,8,0,1,0,110.69,150a56,56,0,0,0,76.81-2.27l28.12-28.11A56.08,56.08,0,0,0,215.62,40.38Z"></path></svg>
<span class="tool-tip">
Share
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</button>
<a class="pill-item" href="/api/file/{{ image.file_name }}" 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="M232,136v64a8,8,0,0,1-8,8H32a8,8,0,0,1-8-8V136a8,8,0,0,1,8-8H224A8,8,0,0,1,232,136Z" opacity="0.2"></path><path d="M240,136v64a16,16,0,0,1-16,16H32a16,16,0,0,1-16-16V136a16,16,0,0,1,16-16H72a8,8,0,0,1,0,16H32v64H224V136H184a8,8,0,0,1,0-16h40A16,16,0,0,1,240,136Zm-117.66-2.34a8,8,0,0,0,11.32,0l48-48a8,8,0,0,0-11.32-11.32L136,108.69V24a8,8,0,0,0-16,0v84.69L85.66,74.34A8,8,0,0,0,74.34,85.66ZM200,168a12,12,0,1,0-12,12A12,12,0,0,0,200,168Z"></path></svg>
<span class="tool-tip">
Download
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</a>
</div>
{% endfor %}
{% if image.author_id == g.user.id %}
<div>
<button class="pill-item pill__critical" id="img-delete">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M200,56V208a8,8,0,0,1-8,8H64a8,8,0,0,1-8-8V56Z" opacity="0.2"></path><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>
<span class="tool-tip">
Delete
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</button>
<button class="pill-item pill__critical" id="img-edit">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M221.66,90.34,192,120,136,64l29.66-29.66a8,8,0,0,1,11.31,0L221.66,79A8,8,0,0,1,221.66,90.34Z" opacity="0.2"></path><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>
<span class="tool-tip">
Edit
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</button>
</div>
{% endif %}
{% if prev_url %}
<div>
<a class="pill-item" href="{{ prev_url }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 24 24" width="24" fill="currentColor">
<path d="M10.586 5.657l-3.95-3.95A1 1 0 0 1 8.05.293l5.657 5.657a.997.997 0 0 1 0 1.414L8.05 13.021a1 1 0 1 1-1.414-1.414l3.95-3.95H1a1 1 0 1 1 0-2h9.586z"></path>
</svg>
<span class="tool-tip">
Next
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,48,88H208a8,8,0,0,1,5.66,13.66Z"></path></svg>
</span>
</a>
</div>
{% endif %}
</div>
<div class="info-container" id="image-info">
{% if image.post_description %}
<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="M224,56V200a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V56a8,8,0,0,1,8-8H216A8,8,0,0,1,224,56Z" opacity="0.2"></path><path d="M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm0,160H40V56H216V200ZM184,96a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,96Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,128Zm0,32a8,8,0,0,1-8,8H80a8,8,0,0,1,0-16h96A8,8,0,0,1,184,160Z"></path></svg>
<h2>Description</h2>
<svg class="collapse-indicator" 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>
</div>
<div class="info-table">
<p>{{ image.post_description }}</p>
</div>
</div>
{% endif %}
<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="M224,128a96,96,0,1,1-96-96A96,96,0,0,1,224,128Z" opacity="0.2"></path><path d="M144,176a8,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,176Zm88-48A104,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,128ZM124,96a12,12,0,1,0-12-12A12,12,0,0,0,124,96Z"></path></svg>
<h2>Info</h2>
<svg class="collapse-indicator" 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>
</div>
<div class="info-table">
<div class="img-colours">
{% for col in image.image_colours %}
<span style="background-color: rgb({{col.0}}, {{col.1}}, {{col.2}})"></span>
{% endfor %}
</div>
<table>
<tr>
<td>Image ID</td>
<td>{{ image['id'] }}</td>
</tr>
<tr>
<td>Author</td>
<td>{{ image.author_username }}</td>
</tr>
<tr>
<td>Upload date</td>
<td><span class="time">{{ image.created_at }}</span></td>
</tr>
</table>
{% if group and image.author_id == g.user.id %}
<div class="img-groups">
{% for group in image.groups %}
<a href="/group/{{ 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="M224,88V200.89a7.11,7.11,0,0,1-7.11,7.11H40a8,8,0,0,1-8-8V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6l27.74,20.8a8,8,0,0,0,4.8,1.6H216A8,8,0,0,1,224,88Z" opacity="0.2"></path><path d="M216,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,16H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72Zm0,128H40V64H93.33l27.74,20.8a16.12,16.12,0,0,0,9.6,3.2H216Z"></path></svg>
{{ group['name'] }}
</a>
{% endfor %}
<button class="tag-icon" id="#img-group">
<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>
Add
</button>
</div>
{% elif image.author_id == g.user.id %}
<div class="img-groups">
<button class="tag-icon" id="#img-group">
<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>
Add
</button>
</div>
{% endif %}
</div>
</div>
{% for tag in image.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="M104,40a24,24,0,1,1,24,24A24,24,0,0,1,104,40Zm108.49,99.51L167.17,88.13a24,24,0,0,0-18-8.13H106.83a24,24,0,0,0-18,8.13L43.51,139.51a12,12,0,0,0,17,17L96,128,73.13,214.93a12,12,0,0,0,21.75,10.14L128,168l33.12,57.07a12,12,0,0,0,21.75-10.14L160,128l35.51,28.49a12,12,0,0,0,17-17Z" opacity="0.2"></path><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,64H176L160,40H96L80,64H48A16,16,0,0,0,32,80V192a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V80A16,16,0,0,0,208,64ZM128,168a36,36,0,1,1,36-36A36,36,0,0,1,128,168Z" opacity="0.2"></path><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="M240,48V208a8,8,0,0,1-8,8H152a8,8,0,0,1-8-8V48a8,8,0,0,1,8-8h80A8,8,0,0,1,240,48Z" opacity="0.2"></path><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="M104,152l48,72H24l36-56,16.36,25.45ZM152,32V88h56Z" opacity="0.2"></path><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,6.67,3.67A7.91,7.91,0,0,0,83,197.89l21-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="M104,152l48,72H24l36-56,16.36,25.45ZM152,32V88h56Z" opacity="0.2"></path><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,6.67,3.67A7.91,7.91,0,0,0,83,197.89l21-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 %}
<svg class="collapse-indicator" 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>
</div>
<div class="info-table">
<table>
{% for subtag in image.image_exif[tag] %}
<tr>
<td>{{ subtag }}</td>
{% if image.image_exif[tag][subtag]['formatted'] %}
{% if image.image_exif[tag][subtag]['type'] == 'date' %}
<td><span class="time">{{ image.image_exif[tag][subtag]['formatted'] }}</span></td>
{% else %}
<td>{{ image.image_exif[tag][subtag]['formatted'] }}</td>
{% endif %}
{% elif image.image_exif[tag][subtag]['raw'] %}
<td>{{ image.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>
var infoCollapse = document.querySelectorAll('#collapse-info');
for (var i = 0; i < infoCollapse.length; i++) {
infoCollapse[i].addEventListener('click', function() {
this.parentNode.classList.toggle('image-info__collapsed');
});
}
var infoHeader = document.querySelectorAll('.image-info__header');
for (var i = 0; i < infoHeader.length; i++) {
infoHeader[i].addEventListener('click', function() {
this.parentNode.classList.toggle('image-info__collapsed');
var infoTab = document.querySelectorAll('.info-tab');
for (var i = 0; i < infoTab.length; i++) {
infoTab[i].querySelector('.info-header').addEventListener('click', function() {
this.parentNode.classList.toggle('collapsed');
});
}
$('.image-fullscreen').click(function() {
$('.image-fullscreen').addClass('image-fullscreen__hide');
// un-Stop scrolling
document.querySelector("html").style.overflow = "auto";
let fullscreen = document.querySelector('.image-fullscreen')
fullscreen.classList.remove('active');
setTimeout(function() {
$('.image-fullscreen').removeClass('image-fullscreen__active image-fullscreen__hide');
fullscreen.style.display = 'none';
}, 200);
});
$('#img-fullscreen').click(function() {
$('.image-fullscreen').addClass('image-fullscreen__active');
if ($('.image-fullscreen img').attr('src') == '') {
$('.image-fullscreen img').attr('src', '/api/uploads/{{ image['file_name'] }}');
}
// Stop scrolling
document.querySelector("html").style.overflow = "hidden";
let fullscreen = document.querySelector('.image-fullscreen')
fullscreen.querySelector('img').src = '/api/file/{{ image.file_name }}';
fullscreen.style.display = 'flex';
setTimeout(function() {
fullscreen.classList.add('active');
}, 10);
});
$('#img-share').click(function() {
@ -243,28 +258,22 @@
addNotification("Failed to copy link! Are you on HTTP?", 2);
}
});
$('#img-download').click(function() {
addNotification("Download started!", 4);
});
{% if g.user['id'] == image['author_id'] %}
$('#img-delete').click(function() {
popUpShow(
'DESTRUCTION!!!!!!',
'This will delete the image and all of its data!!! This action is irreversible!!!!! Are you sure you want to do this?????',
'<button class="pop-up__btn pop-up__btn-critical-fill" onclick="deleteImage()">Dewww eeeet!</button>',
'<img src="/api/uploads/{{ image['file_name'] }}?w=1000&h=1000" />'
'<button class="btn-block" onclick="popupDissmiss()">Nooooooo</button>\
<button class="btn-block critical" onclick="deleteImage()">Dewww eeeet!</button>',
'<img src="/api/file/{{ image.file_name }}?w=1920&h=1080"/>'
);
});
$('#img-edit').click(function() {
window.location.href = '/image/{{ image['id'] }}/edit';
});
function deleteImage() {
popupDissmiss();
$.ajax({
url: '/api/remove/{{ image['id'] }}',
url: '{{ url_for('api.delete_image', image_id=image['id']) }}',
type: 'post',
data: {
action: 'delete'
@ -277,6 +286,10 @@
}
});
}
$('#img-edit').click(function() {
window.location.href = '/image/{{ image.id }}/edit';
});
{% endif %}
</script>
{% endblock %}

View file

@ -1,73 +1,59 @@
{% extends 'layout.html' %}
{% block header %}
<div class="banner">
<img src="{{ url_for('static', filename='images/leaves.jpg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
<div class="banner__content">
{% block banner_subtitle%}{% endblock %}
<p>{{ motto }}</p>
<h1>{{ name }}</h1>
<p>Serving {{ image_count }} images</p>
</div>
</div>
{% endblock %}
{% block nav_home %}navigation-item__selected{% endblock %}
{% block wrapper_class %}index-wrapper{% endblock %}
{% block content %}
<div class="gallery">
{% for image in images %}
<a id="image-{{ image['id'] }}" class="gallery__item" href="/image/{{ image['id'] }}">
<span class="gallery__item-info">
<p>{{ image['id'] }}</p>
<h2><span class="time">{{ image['created_at'] }}</span></h2>
</span>
<img
class="gallery__item-image"
data-src="{{ image['file_name'] }}"
onload="imgFade(this)"
style="opacity:0;"
/>
</a>
{% endfor %}
<div class="banner">
<img src="{{ url_for('static', filename='images/bg.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span class="banner-filter"></span>
<div class="banner-content">
<p>{{ config.WEBSITE.motto }}</p>
<h1>{{ config.WEBSITE.name }}</h1>
<p>Serving {{ images|length }} images</p>
</div>
</div>
{% if images %}
<div class="gallery-grid">
{% for image in images %}
<a id="image-{{ image.id }}" class="gallery-item" href="/image/{{ image.id }}" style="background-color: rgb({{ image.image_colours.0.0 }}, {{ image.image_colours.0.1 }}, {{ image.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="{{ image.post_alt }}" data-src="{{ image.file_name }}" onload="imgFade(this)" style="opacity:0;" id="lazy-load"/>
</a>
{% endfor %}
</div>
{% else %}
<div class="big-text">
<h1>No images</h1>
{% if g.user %}
<p>You can get started by uploading an image!</p>
{% else %}
<p>Login to start uploading images!</p>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block script %}
<script>
function loadOnView() {
var images = document.querySelectorAll('.gallery__item-image');
for (var i = 0; i < images.length; i++) {
var image = images[i];
if (image.getBoundingClientRect().top < window.innerHeight && image.getBoundingClientRect().bottom > 0) {
if (!image.src) {
image.src = `/api/uploads/${image.getAttribute('data-src')}?w=500&h=500`
}
}
}
}
window.onload = function() {
loadOnView();
};
window.onscroll = function() {
loadOnView();
};
window.onresize = function() {
loadOnView();
};
if (document.referrer.includes('image')) {
var referrerId = document.referrer.split('/').pop();
var imgOffset = document.getElementById('image-' + referrerId).offsetTop;
var imgHeight = document.getElementById('image-' + referrerId).offsetHeight;
var windowHeight = window.innerHeight;
document.querySelector('html').style.scrollBehavior = 'auto';
window.scrollTo(0, imgOffset + (imgHeight / 2) - (windowHeight / 2));
document.querySelector('html').style.scrollBehavior = 'smooth';
try {
var referrerId = document.referrer.split('/').pop();
var imgOffset = document.getElementById('image-' + referrerId).offsetTop;
var imgHeight = document.getElementById('image-' + referrerId).offsetHeight;
var windowHeight = window.innerHeight;
document.querySelector('html').style.scrollBehavior = 'auto';
window.scrollTo(0, imgOffset + (imgHeight / 2) - (windowHeight / 2));
document.querySelector('html').style.scrollBehavior = 'smooth';
} catch (e) {
console.log(e);
}
}
</script>
{% endblock %}

View file

@ -3,42 +3,84 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Gallery</title>
<link rel="icon" href="{{url_for('static', filename='images/icon.png')}}">
<title>{{ config.WEBSITE.name }}</title>
<meta name="description" content="{{ config.WEBSITE.motto }}"/>
<meta name="author" content="{{ config.WEBSITE.author }}"/>
<meta property="og:title" content="{{ config.WEBSITE.name }}"/>
<meta property="og:description" content="{{ config.WEBSITE.motto }}"/>
<meta property="og:type" content="website"/>
<meta name="twitter:title" content="{{ config.WEBSITE.name }}"/>
<meta name="twitter:description" content="{{ config.WEBSITE.motto }}"/>
<meta name="twitter:card" content="summary_large_image">
<link
href="{{url_for('static', filename='images/logo-black.svg')}}"
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: light)"/>
<link
href="{{url_for('static', filename='images/logo-white.svg')}}"
rel="icon"
type="image/svg+xml"
media="(prefers-color-scheme: dark)"/>
<link rel="stylesheet" href="{{url_for('static', filename='theme/style.css')}}" defer>
<script src="{{url_for('static', filename='js/jquery-3.6.3.min.js')}}"></script>
{% assets "js_all" %}
<script type="text/javascript" src="{{ ASSET_URL }}"></script>
{% endassets %}
{% block head %}{% endblock %}
</head>
<body>
<div class="notifications"></div>
<svg class="top-of-page" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M224,120H176v88a8,8,0,0,1-8,8H88a8,8,0,0,1-8-8V120H32l96-96Z" opacity="0.2"></path><path d="M229.66,114.34l-96-96a8,8,0,0,0-11.32,0l-96,96A8,8,0,0,0,32,128H72v80a16,16,0,0,0,16,16h80a16,16,0,0,0,16-16V128h40a8,8,0,0,0,5.66-13.66ZM176,112a8,8,0,0,0-8,8v88H88V120a8,8,0,0,0-8-8H51.31L128,35.31,204.69,112Z"></path></svg>
<div class="pop-up">
<span class="pop-up__click-off" onclick="popupDissmiss()"></span>
<div class="pop-up-wrapper">
<div class="pop-up-content">
<h3>Title</h3>
<p>Very very very drawn out example description</p>
</div>
<div class="pop-up-controlls">
<button class="pop-up__btn" onclick="popupDissmiss()">Cancel</button>
</div>
</div>
</div>
<div class="wrapper">
<div class="notifications"></div>
<svg class="jumpUp" xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" fill="currentColor">
<path d="M11 8.414V14a1 1 0 0 1-2 0V8.414L6.464 10.95A1 1 0 1 1 5.05 9.536l4.243-4.243a.997.997 0 0 1 1.414 0l4.243 4.243a1 1 0 1 1-1.414 1.414L11 8.414zM10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16z"></path>
</svg>
{% block header %}{% endblock %}
<div class="navigation">
<img src="{{url_for('static', filename='images/icon.png')}}" alt="Logo" class="logo" onload="imgFade(this)" style="opacity:0;">
<a href="{{url_for('gallery.index')}}" class="navigation-item {% block nav_home %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" width="24" fill="currentColor">
<path d="M2 8v10h12V8H2zm2-2V2a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-2v4a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h2zm2 0h8a2 2 0 0 1 2 2v4h2V2H6v4zm0 9a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"></path><path d="M7 6a3 3 0 1 1 6 0h-2a1 1 0 0 0-2 0H7zm1.864 13.518l2.725-4.672a1 1 0 0 1 1.6-.174l1.087 1.184 1.473-1.354-1.088-1.183a3 3 0 0 0-4.8.52L7.136 18.51l1.728 1.007zm6.512-12.969a2.994 2.994 0 0 1 3.285.77l1.088 1.183-1.473 1.354-1.087-1.184A1 1 0 0 0 16 8.457V8c0-.571-.24-1.087-.624-1.451z"></path>
</svg>
<span>Home</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M208,40H80a8,8,0,0,0-8,8V176a8,8,0,0,0,8,8H96.69l77.65-77.66a8,8,0,0,1,11.32,0L216,136.69V48A8,8,0,0,0,208,40Zm-88,64a16,16,0,1,1,16-16A16,16,0,0,1,120,104Z" opacity="0.2"></path><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,16h96Zm32-32H116l64-64,28,28v36Zm-88-64A24,24,0,1,0,96,88,24,24,0,0,0,120,112Zm0-32a8,8,0,1,1-8,8A8,8,0,0,1,120,80Z"></path></svg>
<span>
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('gallery.groups')}}" class="navigation-item {% block nav_groups %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -4 24 24" width="24" fill="currentColor">
<path d="M17 4H9.415l-.471-1.334A1.001 1.001 0 0 0 8 2H3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h14a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1zm-6.17-2H17a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V3a3 3 0 0 1 3-3h5c1.306 0 2.417.835 2.83 2z"></path>
</svg>
<span>Groups</span>
<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="M208,88v24H146.42a8.07,8.07,0,0,0-4.44,1.34l-20,13.32a8.07,8.07,0,0,1-4.44,1.34H69.42A8,8,0,0,0,62,133L32,208V64a8,8,0,0,1,8-8H93.33a8,8,0,0,1,4.8,1.6l27.74,20.8a8,8,0,0,0,4.8,1.6H200A8,8,0,0,1,208,88Z" opacity="0.2"></path><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.46V64Zm112,136H43.82l25.6-64h48.16a16,16,0,0,0,8.88-2.69l20-13.31H232Z"></path></svg>
<span>
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 g.user %}
<button class="navigation-item {% block nav_upload %}{% endblock %}" onclick="showUpload()">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 24 24" width="24" fill="currentColor">
<path d="M8 3.414v5.642a1 1 0 1 1-2 0V3.414L4.879 4.536A1 1 0 0 1 3.464 3.12L6.293.293a1 1 0 0 1 1.414 0l2.829 2.828A1 1 0 1 1 9.12 4.536L8 3.414zM1 12h12a1 1 0 0 1 0 2H1a1 1 0 0 1 0-2z"></path>
</svg>
<span>Upload</span>
<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="M168,80H88l40-40Z" opacity="0.2"></path><path d="M224,152v56a16,16,0,0,1-16,16H48a16,16,0,0,1-16-16V152a8,8,0,0,1,16,0v56H208V152a8,8,0,0,1,16,0ZM80.61,83.06a8,8,0,0,1,1.73-8.72l40-40a8,8,0,0,1,11.32,0l40,40A8,8,0,0,1,168,88H136v64a8,8,0,0,1-16,0V88H88A8,8,0,0,1,80.61,83.06ZM107.31,72h41.38L128,51.31Z"></path></svg>
<span>
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 %}
@ -46,53 +88,55 @@
{% if g.user %}
<a href="{{url_for('gallery.profile')}}" class="navigation-item {% block nav_profile %}{% endblock %}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-2 -2 24 24" width="24" fill="currentColor">
<path d="M10 20C4.477 20 0 15.523 0 10S4.477 0 10 0s10 4.477 10 10-4.477 10-10 10zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16zm0-14a4 4 0 0 1 4 4v2a4 4 0 1 1-8 0V8a4 4 0 0 1 4-4zm0 2a2 2 0 0 0-2 2v2a2 2 0 1 0 4 0V8a2 2 0 0 0-2-2zM5.91 16.876a8.033 8.033 0 0 1-1.58-1.232 5.57 5.57 0 0 1 2.204-1.574 1 1 0 1 1 .733 1.86c-.532.21-.993.538-1.358.946zm8.144.022a3.652 3.652 0 0 0-1.41-.964 1 1 0 1 1 .712-1.868 5.65 5.65 0 0 1 2.284 1.607 8.032 8.032 0 0 1-1.586 1.225z"></path>
</svg>
<span>Profile</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M216,176a24,24,0,1,1-24-24A24,24,0,0,1,216,176Z" opacity="0.2"></path><path d="M214.61,198.62a32,32,0,1,0-45.23,0,40,40,0,0,0-17.11,23.32,8,8,0,0,0,5.67,9.79A8.15,8.15,0,0,0,160,232a8,8,0,0,0,7.73-5.95C170.56,215.42,180.54,208,192,208s21.44,7.42,24.27,18.05a8,8,0,1,0,15.46-4.11A40,40,0,0,0,214.61,198.62ZM192,160a16,16,0,1,1-16,16A16,16,0,0,1,192,160Zm24-88H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.61A15.4,15.4,0,0,0,39.38,216h81.18a8,8,0,0,0,0-16H40V88H216v32a8,8,0,0,0,16,0V88A16,16,0,0,0,216,72ZM92.69,56l16,16H40V56Z"></path></svg>
<span>
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" viewBox="-1 -2 24 24" width="24" fill="currentColor">
<path d="M9.815 3.094a3.467 3.467 0 0 1-2.78-1.09l-.084-.001a3.467 3.467 0 0 1-2.781 1.09 3.477 3.477 0 0 1-1.727 2.51 3.471 3.471 0 0 1 0 2.794 3.477 3.477 0 0 1 1.727 2.51 3.467 3.467 0 0 1 2.78 1.09h.084a3.467 3.467 0 0 1 2.78-1.09 3.477 3.477 0 0 1 1.727-2.51 3.471 3.471 0 0 1 0-2.794 3.477 3.477 0 0 1-1.726-2.51zM14 5.714a1.474 1.474 0 0 0 0 2.572l-.502 1.684a1.473 1.473 0 0 0-1.553 2.14l-1.443 1.122A1.473 1.473 0 0 0 8.143 14l-2.304-.006a1.473 1.473 0 0 0-2.352-.765l-1.442-1.131A1.473 1.473 0 0 0 .5 9.968L0 8.278a1.474 1.474 0 0 0 0-2.555l.5-1.69a1.473 1.473 0 0 0 1.545-2.13L3.487.77A1.473 1.473 0 0 0 5.84.005L8.143 0a1.473 1.473 0 0 0 2.358.768l1.444 1.122a1.473 1.473 0 0 0 1.553 2.14L14 5.714zm-5.812 9.198a7.943 7.943 0 0 0 2.342-.73 3.468 3.468 0 0 1-.087.215 3.477 3.477 0 0 1 1.727 2.51 3.467 3.467 0 0 1 2.78 1.09h.084a3.467 3.467 0 0 1 2.78-1.09 3.477 3.477 0 0 1 1.727-2.51 3.471 3.471 0 0 1 0-2.794 3.477 3.477 0 0 1-1.726-2.51 3.467 3.467 0 0 1-2.78-1.09h-.084l-.015.016a8.077 8.077 0 0 0 .002-2.016L16.144 6a1.473 1.473 0 0 0 2.358.768l1.444 1.122a1.473 1.473 0 0 0 1.553 2.14L22 11.714a1.474 1.474 0 0 0 0 2.572l-.502 1.684a1.473 1.473 0 0 0-1.553 2.14l-1.443 1.122a1.473 1.473 0 0 0-2.359.768l-2.304-.006a1.473 1.473 0 0 0-2.352-.765l-1.442-1.131a1.473 1.473 0 0 0-1.545-2.13l-.312-1.056zM7 10a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm8 8a3 3 0 1 1 0-6 3 3 0 0 1 0 6zm0-2a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"></path>
</svg>
<span>Settings</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M207.86,123.18l16.78-21a99.14,99.14,0,0,0-10.07-24.29l-26.7-3a81,81,0,0,0-6.81-6.81l-3-26.71a99.43,99.43,0,0,0-24.3-10l-21,16.77a81.59,81.59,0,0,0-9.64,0l-21-16.78A99.14,99.14,0,0,0,77.91,41.43l-3,26.7a81,81,0,0,0-6.81,6.81l-26.71,3a99.43,99.43,0,0,0-10,24.3l16.77,21a81.59,81.59,0,0,0,0,9.64l-16.78,21a99.14,99.14,0,0,0,10.07,24.29l26.7,3a81,81,0,0,0,6.81,6.81l3,26.71a99.43,99.43,0,0,0,24.3,10l21-16.77a81.59,81.59,0,0,0,9.64,0l21,16.78a99.14,99.14,0,0,0,24.29-10.07l3-26.7a81,81,0,0,0,6.81-6.81l26.71-3a99.43,99.43,0,0,0,10-24.3l-16.77-21A81.59,81.59,0,0,0,207.86,123.18ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z" opacity="0.2"></path><path d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.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.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8.06,8.06,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8.06,8.06,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z"></path></svg>
<span>
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" viewBox="-5 -3 24 24" fill="currentColor">
<path d="M6.641 9.828H1a1 1 0 1 1 0-2h5.641l-1.12-1.12a1 1 0 0 1 1.413-1.415L9.763 8.12a.997.997 0 0 1 0 1.415l-2.829 2.828A1 1 0 0 1 5.52 10.95l1.121-1.122zM13 0a1 1 0 0 1 1 1v16a1 1 0 0 1-2 0V1a1 1 0 0 1 1-1z"></path>
</svg>
<span>Login</span>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256"><path d="M136,128,96,168V88Z" opacity="0.2"></path><path d="M141.66,122.34l-40-40A8,8,0,0,0,88,88v32H24a8,8,0,0,0,0,16H88v32a8,8,0,0,0,13.66,5.66l40-40A8,8,0,0,0,141.66,122.34ZM104,148.69V107.31L124.69,128ZM208,48V208a16,16,0,0,1-16,16H136a8,8,0,0,1,0-16h56V48H136a8,8,0,0,1,0-16h56A16,16,0,0,1,208,48Z"></path></svg>
<span>
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>
<div class="content {% block wrapper_class %}{% endblock %}">
{% if g.user %}
<div class="upload-panel">
<span class="click-off" onclick="closeUploadTab()"></span>
<div class="container">
<h3>Upload stuffs</h3>
<p>May the world see your stuff 👀</p>
<form id="uploadForm" onsubmit="return uploadFile(event)">
<input class="input-block file" type="file" id="file"/>
<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 class="pop-up">
<span class="pop-up__click-off" onclick="popupDissmiss()"></span>
<div class="pop-up-wrapper">
<div class="pop-up-content">
<h3>Title</h3>
<p>Very very very drawn out example description</p>
</div>
<div class="pop-up-controlls">
<button class="pop-up__btn" onclick="popupDissmiss()">Cancel</button>
</div>
</div>
</div>
</div>
<script src="{{ url_for('static', filename='js/main.js') }}"></script>
{% if g.user %}
<script src="{{ url_for('static', filename='js/upload.js') }}"></script>
{% else %}
<script src="{{ url_for('static', filename='js/login.js') }}"></script>
{% endif %}
<script>
{% for message in get_flashed_messages() %}
// Show notifications on page load

View file

@ -1,25 +1,18 @@
{% extends 'layout.html' %}
{% block header %}
<div class="background-decoration">
<img src="{{ url_for('static', filename='images/background.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
</div>
{% endblock %}
{% block nav_profile %}navigation-item__selected{% endblock %}
{% block content %}
<div class="banner">
<img src="{{ url_for('static', filename='images/bg.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
<div class="banner-content">
<h1>Profile</h1>
<p>Hello {{ g.user['username'] }}</p>
</div>
</div>
<h1>User</h1>
<p>{{user_id}}</p>
<ul>
{% if g.user %}
<li><span>{{ g.user['username'] }}</span>
<li><a href="{{ url_for('auth.logout') }}">Log Out</a>
{% else %}
<li><a href="{{ url_for('auth.register') }}">Register</a>
<li><a href="{{ url_for('auth.login') }}">Log In</a>
{% endif %}
</ul>
{% endblock %}

View file

@ -3,4 +3,5 @@
{% block settings_account %}settings-nav__item-selected{% endblock %}
{% block settings_content %}
<h2>Account</h2>
<a href="{{ url_for( 'auth.logout' ) }}">Logout</a>
{% endblock %}

View file

@ -1,21 +1,18 @@
{% extends 'layout.html' %}
{% block header %}
{% block nav_settings %}navigation-item__selected{% endblock %}
{% block content %}
<div class="banner">
<img src="{{ url_for('static', filename='images/leaves.jpg') }}" onload="imgFade(this)" style="opacity:0;"/>
<img src="{{ url_for('static', filename='images/bg.svg') }}" onload="imgFade(this)" style="opacity:0;"/>
<span></span>
<div class="banner__content">
<div class="banner-content">
{% block banner_subtitle%}{% endblock %}
<h1>Settings</h1>
<p>All the red buttons in one place, what could go wrong?</p>
</div>
</div>
{% endblock %}
{% block nav_settings %}navigation-item__selected{% endblock %}
{% block wrapper_class %}settings-wrapper{% endblock %}
{% block content %}
<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>

View file

@ -17,3 +17,9 @@
100%
left: 0%
height: 0
@keyframes uploadingLoop
0%
left: -100%
100%
left: 100%

View file

@ -0,0 +1,93 @@
.banner
width: 100%
height: 40vh
position: relative
background-color: $black
color: $black
overflow: hidden
transition: opacity 0.3s ease-in-out
img
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background-color: $black
object-fit: cover
object-position: center center
.banner-filter
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: linear-gradient(to right, rgba($primary, 1), rgba($primary, 0))
z-index: +1
.banner-content
padding: 1rem
width: 100%
height: inherit
position: relative
display: flex
flex-direction: column
justify-content: flex-end
gap: 0.5rem
z-index: +2
h1
margin: 0
padding: 0
font-size: 6.9rem
font-weight: 700
line-height: 1
text-align: left
color: $black
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 600
line-height: 1
text-align: left
color: $black
@media (max-width: $breakpoint)
.banner
width: 100vw
height: 25vh
.banner-content
padding: 0.5rem
display: flex
justify-content: center
align-items: center
h1
font-size: 3.5rem
text-align: center
p
font-size: 1.1rem
text-align: center

View file

@ -0,0 +1,120 @@
@mixin btn-block($color)
background-color: transparent
color: $color
&:hover
background-color: $color
color: $black
&:focus-visible
outline: 2px solid rgba($color, 0.5)
.btn-block
padding: 0.5rem 1rem
width: auto
min-height: 2.5rem
display: flex
justify-content: center
align-items: center
gap: 0.5rem
position: relative
font-size: 1rem
font-weight: 600
text-align: center
background-color: transparent
color: $white
border: none
border-radius: $rad-inner
cursor: pointer
transition: background-color 0.2s ease-in-out, color 0.2s ease-in-out
&:hover
background-color: $black
&:focus-visible
outline: 2px solid rgba($black, 0.5)
&.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)
.input-block
padding: 0.5rem 1rem
width: auto
min-height: 2.5rem
display: flex
justify-content: flex-start
align-items: center
position: relative
font-size: 1rem
font-weight: 600
text-align: left
background-color: transparent
color: $white
border: none
border-bottom: 3px solid $gray
border-radius: $rad-inner
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: $white
&:hover
border-color: $white
&:focus
border-color: $primary
outline: none
&.file
padding: 0 1rem 0 0
border: none
background-color: $black2
&:focus-visible
outline: 2px solid rgba($white, 0.5)
&::file-selector-button
margin-right: 1rem
padding: 0.5rem 1rem
width: auto
height: 100%
font-size: 1rem
text-decoration: none
background-color: $white
color: $black
border: none
border-radius: $rad-inner
&:not([value=""])
display: none

View file

@ -47,7 +47,7 @@
.tool-tip
opacity: 1
top: -2.5rem
top: -2.7rem
transform: translateX(calc(-50% + 1.25rem ))
.pill__critical
@ -84,7 +84,7 @@
font-size: 1rem
font-weight: 600
background-color: $black2
background-color: #000000
color: $white
opacity: 0
border-radius: $rad-inner
@ -92,3 +92,19 @@
transition: opacity 0.2s cubic-bezier(.76,0,.17,1), top 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
left: 50%
bottom: -0.46rem
transform: translateX(-50%)
color: #000000

View file

@ -1,6 +1,6 @@
.jumpUp
.top-of-page
margin: 0
padding: 0.5rem
padding: 0.55rem
width: 2.5rem
height: 2.5rem
@ -28,10 +28,10 @@
background-color: $black
color: $primary
.jumpUp--show
right: 0.75rem
opacity: 1
&.show
right: 0.75rem
opacity: 1
@media (max-width: $breakpoint)
.jumpUp
.top-of-page
bottom: 4.25rem

View file

@ -93,7 +93,7 @@
animation: notificationTimeout 5.1s linear
@each $name, $colour in (success: $succes, error: $critical, warning: $warning, info: $info)
@each $name, $colour in (success: $success, error: $critical, warning: $warning, info: $info)
.sniffle__notification--#{$name}
@include notification($colour)

View file

@ -0,0 +1,179 @@
.pop-up
width: calc(100% - 3.5rem)
height: 100vh
position: fixed
top: 0
left: 3.5rem
display: none
background-color: rgba($black, 0.8)
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: $black
border-radius: $rad
overflow: hidden
z-index: +2
transition: transform 0.2s $animation-smooth
.pop-up-content
margin: 0
padding: 1rem
width: 100%
height: auto
display: flex
flex-direction: column
gap: 1rem
overflow-y: auto
overflow-x: hidden
text-size-adjust: auto;
text-overflow: ellipsis
h3
margin: 0
width: 100%
position: sticky
top: 0
font-size: 2.25rem
font-weight: 600
text-align: center
line-height: 1
color: $white
background-color: $black
p
margin: 0
width: 100%
font-size: 1rem
font-weight: 500
text-align: center
line-height: 1
color: $white
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.5rem
width: 100%
height: auto
display: flex
flex-direction: row
justify-content: flex-end
gap: 0.5rem
background-color: $black2
&.active
opacity: 1
.pop-up-wrapper
transform: translate(-50%, 50%) scale(1)
.pop-up__link
color: $primary
text-decoration: none
&:hover
text-decoration: underline
cursor: pointer
@media (max-width: $breakpoint)
.pop-up
width: 100%
height: 100vh
height: 100dvh
position: fixed
left: 0
bottom: 0
.pop-up-wrapper
width: calc(100vw - 1rem)
max-height: 99vh
left: 0.5rem
bottom: 0.5rem
border-radius: $rad
transform: translateY(5rem)
.pop-up-content
max-height: 100%
img
max-height: 50vh
.pop-up-controlls
flex-direction: column
justify-content: center
&.active
opacity: 1
.pop-up-wrapper
transform: translateY(0)

View file

@ -0,0 +1,24 @@
.tag-icon
margin: 0
padding: 0.25rem 0.5rem
display: flex
align-items: center
justify-content: center
gap: 0.25rem
font-size: 1rem
font-weight: 500
text-decoration: none
border-radius: $rad-inner
background-color: $primary
color: $black
border: none
cursor: pointer
svg
width: 1.15rem
height: 1.15rem

View file

@ -0,0 +1,202 @@
.upload-panel
position: fixed
left: 3.5rem
bottom: 0
display: none
width: calc(100% - 3.5rem)
height: 100vh
background-color: rgba($black, 0)
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: 600
color: $white
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 500
color: $white
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: -400px
width: 400px
height: 100%
display: flex
flex-direction: column
gap: 1rem
background-color: $black
opacity: 0
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), opacity 0.25s cubic-bezier(0.76, 0, 0.17, 1)
.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: $black2
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, rgba($black, 0.8), rgba($black, 0))
.job__status
margin: 0
padding: 0
position: absolute
top: 0.5rem
left: 0.5rem
font-size: 1rem
font-weight: 600
color: $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: $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: $critical
&.success
.job__status
color: $success
.progress
height: 0
animation: none
&.warning
.job__status, .progress
color: $warning
&.critical, &.success, &.warning
.progress
height: 0
&.open
background-color: rgba($black, 0.5)
.container
left: 0
opacity: 1
@media (max-width: $breakpoint)
.upload-panel
width: 100%
height: calc(100vh - 3.5rem)
left: 0
bottom: 3.5rem
.container
width: 100%
height: calc(100% - 10rem)
left: 0
bottom: calc(-100vh + 3.5rem)
border-radius: $rad $rad 0 0
&.open
.container
left: 0
bottom: 0

View file

@ -0,0 +1,110 @@
.gallery-grid
margin: 0
padding: 0.5rem
width: 100%
display: grid
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
gap: 0.5rem
.gallery-item
margin: 0
padding: 0
height: auto
position: relative
border-radius: $rad
box-sizing: border-box
overflow: hidden
.image-filter
margin: 0
padding: 0.5rem
width: 100%
height: 100%
position: absolute
left: 0
bottom: -1rem
display: flex
flex-direction: column
justify-content: flex-end
background-image: linear-gradient(to bottom, rgba($black, 0), rgba($black, 0.8))
z-index: +1
opacity: 0 // hide
transform: scale(1.05) // scale up
transition: all 0.3s cubic-bezier(.79, .14, .15, .86)
.image-title
margin: 0
font-size: 1rem
font-weight: 600
color: $white
text-overflow: ellipsis
overflow: hidden
opacity: 0 // hide
transition: all 0.2s ease-in-out
.image-subtitle
margin: 0
font-size: 0.8rem
font-weight: 500
color: $white
text-overflow: ellipsis
overflow: hidden
opacity: 0 // hide
transition: all 0.2s ease-in-out
img
width: 100%
height: 100%
position: absolute
inset: 0
object-fit: cover
object-position: center
background-color: $white
transform: scale(1.05)
transition: all 0.3s cubic-bezier(.79, .14, .15, .86)
&:after
content: ""
display: block
padding-bottom: 100%
&:hover
.image-filter
bottom: 0
opacity: 1
transform: scale(1)
.image-title,
.image-subtitle
opacity: 1
img
transform: scale(1)
@media (max-width: 800px)
.gallery-grid
grid-template-columns: auto auto auto

View file

@ -1,7 +1,4 @@
.background-decoration
margin: 0
padding: 0
.background
width: 100%
height: 100vh
@ -17,6 +14,7 @@
user-select: none
overflow: hidden
z-index: 1
img
position: absolute
@ -42,6 +40,4 @@
width: 100%
height: 100%
background-image: linear-gradient(to bottom, rgba($white, 0), rgba($white, 1))
z-index: +1
z-index: +1

View file

@ -0,0 +1,42 @@
.image-fullscreen
margin: 0
padding: 0 0 0 3.5rem
width: 100%
height: 100%
height: 100dvh
position: fixed
top: 0
left: 0
display: none
opacity: 0 // hide
background-color: rgba($black, 0.8)
z-index: 21
box-sizing: border-box
transition: opacity 0.2s cubic-bezier(.79, .14, .15, .86)
img
margin: auto
padding: 0
width: auto
height: auto
max-width: 100%
max-height: 100%
object-fit: contain
object-position: center
transform: scale(0.8)
transition: transform 0.2s cubic-bezier(.68,-0.55,.27,1.55)
&.active
opacity: 1 // show
img
transform: scale(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,190 @@
.info-container
width: 100%
height: 100%
display: flex
flex-direction: column
gap: 0.5rem
background-color: $black
overflow-y: auto
.info-tab
width: 100%
max-height: 100%
display: flex
flex-direction: column
position: relative
background-color: $black
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
padding: 0
opacity: 0
.collapse-indicator
width: 1.25rem
height: 1.25rem
position: absolute
top: 0.6rem
right: 0.6rem
color: $primary
z-index: +2
transition: transform 0.15s cubic-bezier(.79, .14, .15, .86)
user-select: none
.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: $black2
svg
margin: 0
padding: 0
width: 1.25rem
height: 1.25rem
fill: $primary
h2
margin: 0
padding: 0
font-size: 1.25rem
font-weight: 600
color: $primary
text-overflow: ellipsis
overflow: hidden
.info-table
margin: 0
padding: 0.5rem
display: flex
flex-direction: column
gap: 1rem
color: $white
overflow-x: hidden
transition: opacity 0.3s cubic-bezier(.79, .14, .15, .86)
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 500
text-overflow: ellipsis
overflow: hidden
table
margin: 0
padding: 0
max-width: 100%
overflow-x: hidden
border-collapse: collapse
tr
margin: 0
padding: 0
width: 100%
white-space: nowrap
td
padding-bottom: 0.5rem
td:first-child
padding-right: 0.5rem
width: 50%
max-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
font-size: 1rem
font-weight: 500
td:last-child
padding: 0
width: 50%
max-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
font-size: 1rem
font-weight: 500
td.empty-table
opacity: 0.3
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: 50%
// border: 1px solid $white
.img-groups
width: 100%
display: flex
gap: 0.5rem

View file

@ -0,0 +1,65 @@
@import 'background'
@import 'fullscreen'
@import 'info-tab'
@import 'image'
.image-grid
padding: 0
position: relative
display: grid
grid-template-areas: 'info image' 'info tools'
grid-template-columns: 25rem 1fr
grid-template-rows: 1fr auto
gap: 0
height: 100vh
z-index: 3
#image-info
grid-area: info
#image-tools
grid-area: tools
padding: 0 0 0.5rem 0
#image-container
grid-area: image
@media (max-width: 1100px)
.image-grid
padding: 0.5rem
display: flex
flex-direction: column
gap: 0.5rem
height: auto
.image-container
margin: 0 auto
padding: 0
max-height: 69vh
img
max-height: 69vh
#image-tools
padding: 0
.info-container
background: transparent
.info-header
border-radius: $rad $rad 0 0
.info-tab.collapsed .info-header
border-radius: $rad
@media (max-width: $breakpoint)
.image-fullscreen
padding: 0 0 3.5rem 0

View file

@ -3,6 +3,7 @@
padding: 0
width: 3.5rem
height: 100%
height: 100dvh
display: flex
@ -22,6 +23,18 @@
> span
height: 100%
.logo
margin: 0
padding: 0
width: 3.5rem
height: 3.5rem
min-height: 3.5rem
display: flex
flex-direction: row
align-items: center
.navigation-item
margin: 0
padding: 1rem
@ -40,7 +53,7 @@
text-decoration: none
svg
> svg
margin: 0
font-size: 1.5rem
color: $white
@ -60,7 +73,7 @@
font-size: 1rem
font-weight: 600
background-color: $black2
background-color: #000000
color: $white
opacity: 0
border-radius: $rad-inner
@ -69,26 +82,42 @@
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: #000000
&:hover
background-color: $black2
svg
> svg
color: $primary
span
opacity: 1
left: 3.8rem
left: 3.9rem
.navigation-item__selected
background: $primary
svg
> svg
color: $black
&:hover
background: $primary
svg
> svg
color: $white
@ -108,6 +137,9 @@
> span
display: none
.logo
display: none
.navigation-item
margin: 0.25rem
padding: 0

View file

@ -4,16 +4,20 @@
@import "variables"
@import "animations"
@import "ui/notification"
@import "ui/pop-up"
@import "ui/navigation"
@import "ui/content"
@import "ui/background"
@import "ui/banner"
@import "ui/gallery"
@import "components/elements/notification"
@import "components/elements/pop-up"
@import "components/elements/upload-panel"
@import "components/elements/tags"
@import "buttons/jumpUp"
@import "buttons/pill"
@import "components/navigation"
@import "components/banner"
@import "components/gallery"
@import "components/buttons/top-of-page"
@import "components/buttons/pill"
@import "components/buttons/block"
@import "components/image-view/view"
// Reset
*
@ -34,9 +38,91 @@ html, body
.wrapper
margin: 0
padding: 0
padding: 0 0 0 3.5rem
min-height: 100vh
display: flex
flex-direction: column
background-color: $white
color: $black
.big-text
height: 20rem
display: flex
flex-direction: column
justify-content: center
align-items: center
color: $black
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: $black
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: $white
#contrast-check
transition: color 0.15s ease-in-out
@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

@ -1,108 +0,0 @@
.banner
margin: 0
padding: 0
width: calc(100vw - 3.5rem)
height: 40vh
position: relative
top: 0
left: 3.5rem
background-color: $white
color: $black
background-image: linear-gradient(to right, darken($white, 1%) 15%, darken($white, 10%) 35%, darken($white, 1%) 50%)
background-size: 1000px 640px
animation: imgLoading 1.8s linear infinite forwards
overflow: hidden
transition: opacity 0.3s ease-in-out
img
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background-color: $white
object-fit: cover
object-position: center center
span
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: linear-gradient(to right, rgba($primary, 1), rgba($primary, 0))
z-index: +1
.banner__content
margin: 0
padding: 1rem
width: 100%
height: 100%
position: relative
display: flex
flex-direction: column
justify-content: flex-end
gap: 0.5rem
z-index: +2
h1
margin: 0
padding: 0
font-size: 6.9rem
font-weight: 700
line-height: 1
text-align: left
color: $black
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 600
line-height: 1
text-align: left
color: $black
@media (max-width: $breakpoint)
.banner
width: 100vw
height: 25vh
left: 0
span
background-image: linear-gradient(to bottom, rgba($primary, 1), rgba($primary, 0))
.banner__content
padding: 0.5rem
display: flex
justify-content: center
align-items: center
h1
font-size: 3.5rem
text-align: center
p
font-size: 1.1rem
text-align: center

View file

@ -1,16 +0,0 @@
@import "wrappers/index"
@import "wrappers/image"
@import "wrappers/settings"
@import "wrappers/error"
.content
width: calc(100% - 3.5rem)
min-height: 100vh
position: relative
left: 3.5rem
@media (max-width: $breakpoint)
.content
width: 100%
left: 0

View file

@ -1,115 +0,0 @@
.gallery
margin: 0
padding: 0
width: 100%
display: grid
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
gap: 0.5rem
@media (max-width: 800px)
.gallery
grid-template-columns: auto auto auto
.gallery__item
margin: 0
padding: 0
height: auto
position: relative
background: linear-gradient(to right, darken($white, 1%) 15%, darken($white, 10%) 35%, darken($white, 1%) 50%)
background-size: 1000px 640px
animation: imgLoading 1.8s linear infinite forwards
border-radius: $rad
box-sizing: border-box
overflow: hidden
&:after
content: ""
display: block
padding-bottom: 100%
&:hover
.gallery__item-info
opacity: 1
transform: scale(1)
h2, p
opacity: 1
.gallery__item-image
transform: scale(1.1)
.gallery__item-info
margin: 0
padding: 0.5rem
width: 100%
height: 100%
position: absolute
left: 0
bottom: 0
display: flex
flex-direction: column
justify-content: flex-end
background-image: linear-gradient(to bottom, rgba($black, 0), rgba($black, 0.8))
z-index: +1
opacity: 0 // hide
transform: scale(1.05) // scale up
transition: all 0.3s cubic-bezier(.79, .14, .15, .86)
h2
margin: 0
padding: 0
font-size: 1rem
font-weight: 600
color: $primary
text-overflow: ellipsis
overflow: hidden
opacity: 0 // hide
transition: all 0.2s ease-in-out
p
margin: 0
padding: 0
font-size: 0.8rem
font-weight: 500
color: $white
text-overflow: ellipsis
overflow: hidden
opacity: 0 // hide
transition: all 0.2s ease-in-out
.gallery__item-image
width: 100%
height: 100%
position: absolute
top: 0
left: 0
right: 0
bottom: 0
object-fit: cover
object-position: center
background-color: $white
transition: all 0.3s cubic-bezier(.79, .14, .15, .86)

View file

@ -1,277 +0,0 @@
@mixin pop-up-btn($color, $fill: false)
@if $fill
color: $white
background-color: $color
border: 2px solid $color
&:hover
background-color: $white
color: $color
@else
color: $color
background-color: $white
border: 2px solid $color
&:hover
background-color: $color
color: $white
.pop-up
width: calc(100% - 3.5rem)
height: 100vh
position: fixed
top: 100%
left: 3.5rem
background-color: rgba($black, 0.8)
backdrop-filter: blur(1rem)
opacity: 0
z-index: 101
transition: opacity 0.2s ease
.pop-up__click-off
width: 100vw
height: 100vh
height: 100dvh
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: $white
border-radius: $rad
overflow: hidden
transition: transform 0.2s cubic-bezier(.68,-0.55,.27,1.55)
z-index: +2
.pop-up-content
margin: 0
padding: 0.5rem 0.5rem 0
width: 100%
height: auto
max-height: 50vh
display: flex
flex-direction: column
gap: 0.5rem
overflow-y: auto
overflow-x: hidden
text-size-adjust: auto;
text-overflow: ellipsis
h3
margin: 0
width: 100%
position: sticky
top: 0
font-size: 2.5rem
font-weight: 600
text-align: center
line-height: 1
color: $black
background-color: $white
p
margin: 0
width: 100%
font-size: 1rem
font-weight: 500
text-align: center
line-height: 1
color: $black
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.5rem
width: 100%
height: auto
display: flex
flex-direction: row
justify-content: flex-end
gap: 0.5rem
.pop-up__btn
margin: 0
padding: 0.5rem 1rem
width: auto
height: 2.5rem
display: flex
justify-content: center
align-items: center
font-size: 1rem
font-weight: 600
text-align: center
line-height: 1
border-radius: $rad-inner
cursor: pointer
transition: background-color 0.2s ease, color 0.2s ease
@include pop-up-btn($black)
&:focus
outline: none
.pop-up__btn-fill
@include pop-up-btn($black, true)
@each $name, $colour in (primary: $primary, info: $info, warning: $warning, critical: $critical)
.pop-up__btn-#{$name}
@include pop-up-btn($colour)
.pop-up__btn-#{$name}-fill
@include pop-up-btn($colour, true)
.pop-up__input
margin: 0
padding: 0.5rem
width: 100%
height: 2.5rem
font-size: 1rem
font-weight: 600
text-align: left
line-height: 1
border-radius: $rad-inner
background-color: $white
border: 2px solid $black
&:focus
outline: none
border-color: $primary
.pop-up__link
color: $primary
text-decoration: none
&:hover
text-decoration: underline
cursor: pointer
.pop-up__active
opacity: 1
top: 0
.pop-up-wrapper
transform: translate(-50%, 50%) scale(1)
.pop-up__hide
opacity: 0
transition: opacity 0.2s ease
.pop-up-wrapper
transform: translate(-50%, 50%) scaleY(0)
transition: transform 0.2s ease
@media (max-width: $breakpoint)
.pop-up
width: 100%
height: 100vh
height: 100dvh
position: fixed
left: 0
bottom: 0
backdrop-filter: blur(0.5rem)
.pop-up-wrapper
width: calc(100vw - 1rem)
max-height: calc(100vh - 1rem)
max-height: calc(100dvh - 1rem)
left: 0.5rem
bottom: 0.5rem
border-radius: $rad
transform: translateY(5rem)
.pop-up-content
max-height: 100%
img
max-height: 50vh
.pop-up-controlls
flex-direction: column
justify-content: center
.pop-up__btn
width: 100%
.pop-up__active
opacity: 1
top: unset
.pop-up-wrapper
transform: translateY(0)
.pop-up__hide
opacity: 0
transition: opacity 0.2s ease
.pop-up-wrapper
transform: translateY(5rem)
transition: transform 0.2s ease

View file

@ -1,35 +0,0 @@
.error-wrapper
display: flex
flex-direction: column
justify-content: center
align-items: center
background-color: $black
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: $white
@media (max-width: $breakpoint)
.error-wrapper
h1
font-size: 4.5rem
p
max-width: 100%
font-size: 1rem

View file

@ -1,328 +0,0 @@
.image-wrapper
padding: 0
display: grid
grid-template-areas: 'info image' 'info tools'
grid-template-columns: 25rem 1fr
grid-template-rows: 1fr auto
gap: 0
height: 100vh
.image-fullscreen
margin: 0
padding: 0 0 0 3.5rem
width: 100%
height: 100dvh
position: fixed
top: -100%
left: 0
display: flex
opacity: 0 // hide
background-color: rgba($black, 0.8)
backdrop-filter: blur(1rem)
z-index: 21
box-sizing: border-box
img
margin: auto
padding: 0
width: auto
height: auto
max-width: 100%
max-height: 100%
object-fit: contain
object-position: center
transform: scale(0.8)
.image-fullscreen__active
top: 0
opacity: 1 // show
transition: opacity 0.3s cubic-bezier(.79, .14, .15, .86)
img
transform: scale(1)
transition: transform 0.2s cubic-bezier(.68,-0.55,.27,1.55)
.image-fullscreen__hide
opacity: 0 // hide
transition: opacity 0.2s cubic-bezier(.79, .14, .15, .86)
img
transform: scaleY(0) // scale(0.8)
transition: transform 0.2s ease
.image-container
margin: auto
padding: 0.5rem
width: 100%
height: 100%
display: flex
overflow: hidden
grid-area: image
img
margin: auto
padding: 0
width: auto
height: auto
max-width: 100%
max-height: 100%
object-fit: contain
object-position: center
.image-info__container
margin: 0
padding: 0
width: 100%
height: 100%
display: flex
flex-direction: column
background-color: $black
overflow-y: auto
grid-area: info
.image-info
margin: 0
padding: 0
width: 100%
display: flex
flex-direction: column
position: relative
background-color: $black
border-radius: $rad
.image-info__collapse
margin: 0
padding: 0
width: 1.25rem
height: 1.25rem
display: flex
justify-content: center
align-items: center
position: absolute
top: 0.6rem
right: 0.6rem
cursor: pointer
z-index: +2
transition: transform 0.15s cubic-bezier(.79, .14, .15, .86)
svg
margin: 0
padding: 0
fill: $primary
.image-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-image: linear-gradient(to right, rgba($black2, 1), rgba($black, 1))
svg
margin: 0
padding: 0
width: 1.25rem
height: 1.25rem
fill: $primary
h2
margin: 0
padding: 0
font-size: 1.25rem
font-weight: 600
color: $primary
text-overflow: ellipsis
overflow: hidden
.image-info__content
margin: 0
padding: 0.5rem
display: flex
flex-direction: column
gap: 0.5rem
color: $white
overflow-x: hidden
transition: opacity 0.3s cubic-bezier(.79, .14, .15, .86)
p
margin: 0
padding: 0
font-size: 1rem
font-weight: 500
text-overflow: ellipsis
overflow: hidden
table
margin: 0
padding: 0
max-width: 100%
overflow-x: hidden
border-collapse: collapse
tr
margin: 0
padding: 0
width: 100%
white-space: nowrap
td
padding-bottom: 0.5rem
td:first-child
padding-right: 0.5rem
width: 50%
max-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
font-size: 1rem
font-weight: 500
td:last-child
padding: 0
width: 50%
max-width: 0
overflow: hidden
text-overflow: ellipsis
white-space: nowrap
font-size: 1rem
font-weight: 500
td.empty-table
opacity: 0.3
tr:last-of-type td
padding-bottom: 0
.image-info__collapsed
height: 2.5rem
.image-info__collapse
transform: rotate(90deg)
.image-info__content
padding: 0
opacity: 0
#image-tools
margin-bottom: 0.5rem
grid-area: tools
@media (max-width: 1100px)
.image-wrapper
padding: 0.5rem
display: flex !important
flex-direction: column
gap: 0.5rem
height: auto
.image-container
margin: 0 auto
padding: 0
max-height: 69vh
img
max-height: 69vh
#image-tools
margin: 0
.image-info__container
margin: 0
padding: 0
width: 100%
height: auto
display: flex
flex-direction: column
gap: 0.5rem
background: none
.image-info__container
border-radius: $rad
.image-info__header
border-radius: $rad $rad 0 0
.image-info__collapsed
.image-info__header
border-radius: $rad
@media (max-width: $breakpoint)
.image-fullscreen
padding: 0 0 3.5rem 0
.image-wrapper
padding-bottom: 4rem
.image-info__header
background-image: none
background-color: $black2

View file

@ -1,12 +0,0 @@
.index-wrapper
padding: 0.5rem
position: relative
display: flex
flex-direction: column
gap: 0.5rem
@media (max-width: $breakpoint)
.index-wrapper
padding-bottom: 4rem

View file

@ -1,115 +0,0 @@
@mixin settings-btn($color, $fill: false)
@if $fill
color: $white
background-color: $color
border: 2px solid $color
&:hover
background-color: $white
color: $color
@else
color: $color
background-color: $white
border: 2px solid $color
&:hover
background-color: $color
color: $white
@mixin settings-log($color)
font-size: 1rem
font-weight: 600
color: $white
background-color: $black
background-image: linear-gradient(120deg, rgba($color, 0.3), rgba($color, 0));
//border-left: 3px solid $color
.settings-wrapper
margin: 0
padding: 0.5rem
display: flex
flex-direction: column
gap: 0
.settings-nav
width: 100%
height: auto
position: sticky
top: 0
left: 0
display: flex
flex-direction: row
justify-content: center
gap: 0.5rem
background-color: $white
.settings-nav__item
margin: 0
padding: 0.5rem
width: 100%
height: 2.5rem
display: flex
justify-content: center
align-items: center
font-size: 1rem
font-weight: 600
text-align: center
line-height: 1
text-decoration: none
border-radius: $rad
cursor: pointer
transition: background-color 0.2s ease, color 0.2s ease
@include settings-btn($black)
&:focus
outline: none
.settings-nav__item-selected
@include settings-btn($black, true)
.settings-list
margin: 0
padding: 0
width: 100%
height: auto
display: flex
flex-direction: column
gap: 0.5rem
.log
margin: 0
padding: 1rem
height: auto
display: flex
flex-direction: column
gap: 0.5rem
border-radius: $rad
@include settings-log($critical)
@media (max-width: 450px)
.settings-nav
position: relative
flex-direction: column
gap: 0.5rem
.settings-wrapper
padding-bottom: 4rem

View file

@ -1,5 +1,6 @@
$black: #151515
$black2: #101010
$gray: #666
$white: #E8E3E3
$red: #B66467
@ -12,18 +13,18 @@ $purple: #A988B0
$primary: $green
$warning: $orange
$critical: $red
$succes: $green
$success: $green
$info: $blue
$rad: 6px
$rad-inner: 3px
//$font: "Work Sans", sans-serif
$animation-smooth: cubic-bezier(0.76, 0, 0.17, 1)
$animation-bounce: cubic-bezier(.68,-0.55,.27,1.55)
$font: "Work Sans", sans-serif
$breakpoint: 800px
$breakpoint: 800px // responsive breakpoint for mobile
// Work Sans
@font-face
font-family: 'Work Sans'
src: url('fonts/worksans-regular.woff2')

View file

View file

@ -1,17 +1,17 @@
"""
OnlyLegs - Metatada Parser
OnlyLegs - Metadata Parser
Parse metadata from images if available
otherwise get some basic information from the file
"""
import os
import logging
from PIL import Image
from PIL.ExifTags import TAGS, GPSTAGS
from PIL.ExifTags import TAGS
from .helpers import *
from .mapping import *
class Metadata:
"""
Metadata parser
@ -54,7 +54,8 @@ class Metadata:
return None
return self.format_data(self.encoded)
def format_data(self, encoded_exif): # pylint: disable=R0912 # For now, this is fine
@staticmethod
def format_data(encoded_exif):
"""
Formats the data into a dictionary
"""
@ -65,43 +66,18 @@ class Metadata:
'File': {},
}
for data in encoded_exif:
if data in PHOTOGRAHER_MAPPING:
exif['Photographer'][PHOTOGRAHER_MAPPING[data][0]] = {
'raw': encoded_exif[data],
}
elif data in CAMERA_MAPPING:
if len(CAMERA_MAPPING[data]) == 2:
exif['Camera'][CAMERA_MAPPING[data][0]] = {
'raw': encoded_exif[data],
'formatted':
getattr(helpers, CAMERA_MAPPING[data][1])(encoded_exif[data]), # pylint: disable=E0602
# Thanks chatGPT xP
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, mapping_val[key][1])(value), # pylint: disable=E0602
}
else:
exif['Camera'][CAMERA_MAPPING[data][0]] = {
'raw': encoded_exif[data],
}
elif data in SOFTWARE_MAPPING:
if len(SOFTWARE_MAPPING[data]) == 2:
exif['Software'][SOFTWARE_MAPPING[data][0]] = {
'raw': encoded_exif[data],
'formatted':
getattr(helpers, SOFTWARE_MAPPING[data][1])(encoded_exif[data]), # pylint: disable=E0602
}
else:
exif['Software'][SOFTWARE_MAPPING[data][0]] = {
'raw': encoded_exif[data],
}
elif data in FILE_MAPPING:
if len(FILE_MAPPING[data]) == 2:
exif['File'][FILE_MAPPING[data][0]] = {
'raw': encoded_exif[data],
'formatted':
getattr(helpers, FILE_MAPPING[data][1])(encoded_exif[data]), # pylint: disable=E0602
}
else:
exif['File'][FILE_MAPPING[data][0]] = {
'raw': encoded_exif[data]
else:
exif[mapping_name][mapping_val[key][0]] = {
'raw': value,
}
# Remove empty keys

View file

@ -4,6 +4,7 @@ Metadata formatting helpers
"""
from datetime import datetime
def human_size(value):
"""
Formats the size of a file in a human readable format
@ -215,13 +216,6 @@ def scene_capture_type(value):
return None
def scene_type(value): # pylint: disable=W0613 # Itss fiiiineeee
"""
Maps the value of the scene type to a human readable format
"""
return 'Directly photographed image'
def white_balance(value):
"""
Maps the value of the white balance to a human readable format
@ -283,7 +277,10 @@ def lens_specification(value):
"""
Maps the value of the lens specification to a human readable format
"""
return str(value[0] / value[1]) + 'mm - ' + str(value[2] / value[3]) + 'mm'
try:
return str(value[0] / value[1]) + 'mm - ' + str(value[2] / value[3]) + 'mm'
except Exception:
return None
def compression_type(value):
@ -352,6 +349,7 @@ 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',

View file

@ -2,6 +2,7 @@
OnlyLegs - Metatada Parser
Mapping for metadata
"""
PHOTOGRAHER_MAPPING = {
'Artist': ['Artist'],
'UserComment': ['Comment'],
@ -31,7 +32,7 @@ CAMERA_MAPPING = {
'ISOSpeedRatings': ['ISO Speed Ratings', 'iso'],
'ISOSpeed': ['ISO Speed', 'iso'],
'SensitivityType': ['Sensitivity Type', 'sensitivity_type'],
'ExposureBiasValue': ['Exposure Bias', 'ev'],
'ExposureBiasValue': ['Exposure Bias', 'exposure'],
'ExposureTime': ['Exposure Time', 'shutter'],
'ExposureMode': ['Exposure Mode', 'exposure_mode'],
'ExposureProgram': ['Exposure Program', 'exposure_program'],
@ -40,9 +41,6 @@ CAMERA_MAPPING = {
'MeteringMode': ['Metering Mode', 'metering_mode'],
'LightSource': ['Light Source', 'light_source'],
'SceneCaptureType': ['Scene Capture Type', 'scene_capture_type'],
'SceneType': ['Scene Type', 'scene_type'],
'Rating': ['Rating', 'rating'],
'RatingPercent': ['Rating Percent', 'rating_percent'],
}
SOFTWARE_MAPPING = {
'Software': ['Software'],
@ -59,4 +57,13 @@ FILE_MAPPING = {
'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

@ -8,7 +8,7 @@ from datetime import datetime
import sass
class CompileTheme():
class CompileTheme:
"""
Compiles the theme into the static folder
"""
@ -27,13 +27,16 @@ class CompileTheme():
print("Theme does not exist!")
sys.exit(1)
if not os.path.exists(theme_dest):
os.makedirs(theme_dest)
self.load_sass(theme_path, theme_dest)
self.load_fonts(theme_path, theme_dest)
now = datetime.now()
print(f"{now.hour}:{now.minute}:{now.second} - Done!\n")
print(f"{datetime.now().hour}:{datetime.now().minute}:{datetime.now().second} - Done!\n")
def load_sass(self, source_path, css_dest):
@staticmethod
def load_sass(source_path, css_dest):
"""
Compile the sass (or scss) file into css and save it to the static folder
"""
@ -45,7 +48,7 @@ class CompileTheme():
print("No sass file found!")
sys.exit(1)
with open(os.path.join(css_dest, 'style.css'), 'w', encoding='utf-8') as file:
with open(os.path.join(css_dest, 'style.css'), encoding='utf-8', mode='w+') as file:
try:
file.write(sass.compile(filename=sass_path,output_style='compressed'))
except sass.CompileError as err:
@ -54,7 +57,8 @@ class CompileTheme():
print("Compiled successfully!")
def load_fonts(self, source_path, font_dest):
@staticmethod
def load_fonts(source_path, font_dest):
"""
Copy the fonts folder to the static folder
"""
@ -63,7 +67,6 @@ class CompileTheme():
font_dest = os.path.join(font_dest, 'fonts')
if os.path.exists(font_dest):
print("Updating fonts...")
try:
shutil.rmtree(font_dest)
except Exception as err:
@ -72,7 +75,8 @@ class CompileTheme():
try:
shutil.copytree(source_path, font_dest)
print("Copied new fonts!")
except Exception as err:
print("Failed to copy fonts!\n", err)
sys.exit(1)
print("Fonts copied successfully!")

176
poetry.lock generated
View file

@ -2,14 +2,14 @@
[[package]]
name = "astroid"
version = "2.14.2"
version = "2.15.0"
description = "An abstract syntax tree for Python with inference support."
category = "main"
optional = false
python-versions = ">=3.7.2"
files = [
{file = "astroid-2.14.2-py3-none-any.whl", hash = "sha256:0e0e3709d64fbffd3037e4ff403580550f14471fd3eaae9fa11cc9a5c7901153"},
{file = "astroid-2.14.2.tar.gz", hash = "sha256:a3cf9f02c53dd259144a7e8f3ccd75d67c9a8c716ef183e0c1f291bc5d7bb3cf"},
{file = "astroid-2.15.0-py3-none-any.whl", hash = "sha256:e3e4d0ffc2d15d954065579689c36aac57a339a4679a679579af6401db4d3fdb"},
{file = "astroid-2.15.0.tar.gz", hash = "sha256:525f126d5dc1b8b0b6ee398b33159105615d92dc4a17f2cd064125d57f6186fa"},
]
[package.dependencies]
@ -112,6 +112,18 @@ files = [
{file = "Brotli-1.0.9.zip", hash = "sha256:4d1b810aa0ed773f81dceda2cc7b403d01057458730e309856356d4ef4188438"},
]
[[package]]
name = "cachelib"
version = "0.9.0"
description = "A collection of cache libraries in the same API interface."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "cachelib-0.9.0-py3-none-any.whl", hash = "sha256:811ceeb1209d2fe51cd2b62810bd1eccf70feba5c52641532498be5c675493b3"},
{file = "cachelib-0.9.0.tar.gz", hash = "sha256:38222cc7c1b79a23606de5c2607f4925779e37cdcea1c2ad21b8bae94b5425a5"},
]
[[package]]
name = "click"
version = "8.1.3"
@ -192,6 +204,38 @@ Werkzeug = ">=2.2.2"
async = ["asgiref (>=3.2)"]
dotenv = ["python-dotenv"]
[[package]]
name = "flask-assets"
version = "2.0"
description = "Asset management for Flask, to compress and merge CSS and Javascript files."
category = "main"
optional = false
python-versions = "*"
files = [
{file = "Flask-Assets-2.0.tar.gz", hash = "sha256:1dfdea35e40744d46aada72831f7613d67bf38e8b20ccaaa9e91fdc37aa3b8c2"},
{file = "Flask_Assets-2.0-py3-none-any.whl", hash = "sha256:2845bd3b479be9db8556801e7ebc2746ce2d9edb4e7b64a1c786ecbfc1e5867b"},
]
[package.dependencies]
Flask = ">=0.8"
webassets = ">=2.0"
[[package]]
name = "flask-caching"
version = "2.0.2"
description = "Adds caching support to Flask applications."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "Flask-Caching-2.0.2.tar.gz", hash = "sha256:24b60c552d59a9605cc1b6a42c56cdb39a82a28dab4532bbedb9222ae54ecb4e"},
{file = "Flask_Caching-2.0.2-py3-none-any.whl", hash = "sha256:19571f2570e9b8dd9dd9d2f49d7cbee69c14ebe8cc001100b1eb98c379dd80ad"},
]
[package.dependencies]
cachelib = ">=0.9.0,<0.10.0"
Flask = "<3"
[[package]]
name = "flask-compress"
version = "1.13"
@ -305,14 +349,14 @@ tornado = ["tornado (>=0.2)"]
[[package]]
name = "importlib-metadata"
version = "6.0.0"
version = "6.1.0"
description = "Read metadata from Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"},
{file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"},
{file = "importlib_metadata-6.1.0-py3-none-any.whl", hash = "sha256:ff80f3b5394912eb1b108fcfd444dc78b7f1f3e16b16188054bd01cb9cb86f09"},
{file = "importlib_metadata-6.1.0.tar.gz", hash = "sha256:43ce9281e097583d758c2c708c4376371261a02c34682491a8e98352365aad20"},
]
[package.dependencies]
@ -597,14 +641,14 @@ tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "pa
[[package]]
name = "platformdirs"
version = "3.1.0"
version = "3.1.1"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-3.1.0-py3-none-any.whl", hash = "sha256:13b08a53ed71021350c9e300d4ea8668438fb0046ab3937ac9a29913a1a1350a"},
{file = "platformdirs-3.1.0.tar.gz", hash = "sha256:accc3665857288317f32c7bebb5a8e482ba717b474f3fc1d18ca7f9214be0cef"},
{file = "platformdirs-3.1.1-py3-none-any.whl", hash = "sha256:e5986afb596e4bb5bde29a79ac9061aa955b94fca2399b7aaac4090860920dd8"},
{file = "platformdirs-3.1.1.tar.gz", hash = "sha256:024996549ee88ec1a9aa99ff7f8fc819bb59e2c3477b410d90a16d32d6e707aa"},
]
[package.extras]
@ -613,18 +657,18 @@ test = ["appdirs (==1.4.4)", "covdefaults (>=2.2.2)", "pytest (>=7.2.1)", "pytes
[[package]]
name = "pylint"
version = "2.16.3"
version = "2.17.0"
description = "python code static checker"
category = "main"
optional = false
python-versions = ">=3.7.2"
files = [
{file = "pylint-2.16.3-py3-none-any.whl", hash = "sha256:3e803be66e3a34c76b0aa1a3cf4714b538335e79bd69718d34fcf36d8fff2a2b"},
{file = "pylint-2.16.3.tar.gz", hash = "sha256:0decdf8dfe30298cd9f8d82e9a1542da464db47da60e03641631086671a03621"},
{file = "pylint-2.17.0-py3-none-any.whl", hash = "sha256:e097d8325f8c88e14ad12844e3fe2d963d3de871ea9a8f8ad25ab1c109889ddc"},
{file = "pylint-2.17.0.tar.gz", hash = "sha256:1460829b6397cb5eb0cdb0b4fc4b556348e515cdca32115f74a1eb7c20b896b4"},
]
[package.dependencies]
astroid = ">=2.14.2,<=2.16.0-dev0"
astroid = ">=2.15.0,<=2.17.0-dev0"
colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
dill = [
{version = ">=0.2", markers = "python_version < \"3.11\""},
@ -708,14 +752,14 @@ files = [
[[package]]
name = "setuptools"
version = "67.4.0"
version = "67.6.0"
description = "Easily download, build, install, upgrade, and uninstall Python packages"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "setuptools-67.4.0-py3-none-any.whl", hash = "sha256:f106dee1b506dee5102cc3f3e9e68137bbad6d47b616be7991714b0c62204251"},
{file = "setuptools-67.4.0.tar.gz", hash = "sha256:e5fd0a713141a4a105412233c63dc4e17ba0090c8e8334594ac790ec97792330"},
{file = "setuptools-67.6.0-py3-none-any.whl", hash = "sha256:b78aaa36f6b90a074c1fa651168723acbf45d14cb1196b6f02c0fd07f17623b2"},
{file = "setuptools-67.6.0.tar.gz", hash = "sha256:2ee892cd5f29f3373097f5a814697e397cf3ce313616df0af11231e2ad118077"},
]
[package.extras]
@ -725,53 +769,53 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
[[package]]
name = "sqlalchemy"
version = "2.0.4"
version = "2.0.7"
description = "Database Abstraction Library"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "SQLAlchemy-2.0.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b67d6e626caa571fb53accaac2fba003ef4f7317cb3481e9ab99dad6e89a70d6"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b01dce097cf6f145da131a53d4cce7f42e0bfa9ae161dd171a423f7970d296d0"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:738c80705e11c1268827dbe22c01162a9cdc98fc6f7901b429a1459db2593060"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6363697c938b9a13e07f1bc2cd433502a7aa07efd55b946b31d25b9449890621"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a42e6831e82dfa6d16b45f0c98c69e7b0defc64d76213173456355034450c414"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:011ef3c33f30bae5637c575f30647e0add98686642d237f0c3a1e3d9b35747fa"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-win32.whl", hash = "sha256:c1e8edc49b32483cd5d2d015f343e16be7dfab89f4aaf66b0fa6827ab356880d"},
{file = "SQLAlchemy-2.0.4-cp310-cp310-win_amd64.whl", hash = "sha256:77a380bf8721b416782c763e0ff66f80f3b05aee83db33ddfc0eac20bcb6791f"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2a2f9120eb32190bdba31d1022181ef08f257aed4f984f3368aa4e838de72bc0"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:679b9bd10bb32b8d3befed4aad4356799b6ec1bdddc0f930a79e41ba5b084124"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:582053571125895d008d4b8d9687d12d4bd209c076cdbab3504da307e2a0a2bd"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c82395e2925639e6d320592943608070678e7157bd1db2672a63be9c7889434"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:25e4e54575f9d2af1eab82d3a470fca27062191c48ee57b6386fe09a3c0a6a33"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:9946ee503962859f1a9e1ad17dff0859269b0cb453686747fe87f00b0e030b34"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-win32.whl", hash = "sha256:c621f05859caed5c0aab032888a3d3bde2cae3988ca151113cbecf262adad976"},
{file = "SQLAlchemy-2.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:662a79e80f3e9fe33b7861c19fedf3d8389fab2413c04bba787e3f1139c22188"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3f927340b37fe65ec42e19af7ce15260a73e11c6b456febb59009bfdfec29a35"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:67901b91bf5821482fcbe9da988cb16897809624ddf0fde339cd62365cc50032"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1644c603558590f465b3fa16e4557d87d3962bc2c81fd7ea85b582ecf4676b31"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:9a7ecaf90fe9ec8e45c86828f4f183564b33c9514e08667ca59e526fea63893a"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8a88b32ce5b69d18507ffc9f10401833934ebc353c7b30d1e056023c64f0a736"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-win32.whl", hash = "sha256:2267c004e78e291bba0dc766a9711c389649cf3e662cd46eec2bc2c238c637bd"},
{file = "SQLAlchemy-2.0.4-cp37-cp37m-win_amd64.whl", hash = "sha256:59cf0cdb29baec4e074c7520d7226646a8a8f856b87d8300f3e4494901d55235"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:dd801375f19a6e1f021dabd8b1714f2fdb91cbc835cd13b5dd0bd7e9860392d7"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d8efdda920988bcade542f53a2890751ff680474d548f32df919a35a21404e3f"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:918c2b553e3c78268b187f70983c9bc6f91e451a4f934827e9c919e03d258bd7"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77d05773d5c79f2d3371d81697d54ee1b2c32085ad434ce9de4482e457ecb018"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:fdb2686eb01f670cdc6c43f092e333ff08c1cf0b646da5256c1237dc4ceef4ae"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8ff0a7c669ec7cdb899eae7e622211c2dd8725b82655db2b41740d39e3cda466"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-win32.whl", hash = "sha256:57dcd9eed52413f7270b22797aa83c71b698db153d1541c1e83d45ecdf8e95e7"},
{file = "SQLAlchemy-2.0.4-cp38-cp38-win_amd64.whl", hash = "sha256:54aa9f40d88728dd058e951eeb5ecc55241831ba4011e60c641738c1da0146b7"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:817aab80f7e8fe581696dae7aaeb2ceb0b7ea70ad03c95483c9115970d2a9b00"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:dc7b9f55c2f72c13b2328b8a870ff585c993ba1b5c155ece5c9d3216fa4b18f6"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f696828784ab2c07b127bfd2f2d513f47ec58924c29cff5b19806ac37acee31c"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ce54965a94673a0ebda25e7c3a05bf1aa74fd78cc452a1a710b704bf73fb8402"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:f342057422d6bcfdd4996e34cd5c7f78f7e500112f64b113f334cdfc6a0c593d"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:b5deafb4901618b3f98e8df7099cd11edd0d1e6856912647e28968b803de0dae"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-win32.whl", hash = "sha256:81f1ea264278fcbe113b9a5840f13a356cb0186e55b52168334124f1cd1bc495"},
{file = "SQLAlchemy-2.0.4-cp39-cp39-win_amd64.whl", hash = "sha256:954f1ad73b78ea5ba5a35c89c4a5dfd0f3a06c17926503de19510eb9b3857bde"},
{file = "SQLAlchemy-2.0.4-py3-none-any.whl", hash = "sha256:0adca8a3ca77234a142c5afed29322fb501921f13d1d5e9fa4253450d786c160"},
{file = "SQLAlchemy-2.0.4.tar.gz", hash = "sha256:95a18e1a6af2114dbd9ee4f168ad33070d6317e11bafa28d983cc7b585fe900b"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7917632606fc5d4be661dcde45cc415df835e594e2c50cc999a44f24b6bf6d92"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:32f508fef9c5a7d19411d94ef64cf5405e42c4689e51ddbb81ac9a7be045cce8"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0995b92612979d208189245bf87349ad9243b97b49652347a28ddee0803225a"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cebd161f964af58290596523c65e41a5a161a99f7212b1ae675e288a4b5e0a7c"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:c38641f5c3714505d65dbbd8fb1350408b9ad8461769ec8e440e1177f9c92d1d"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:921485d1f69ed016e1f756de67d02ad4f143eb6b92b9776bfff78786d8978ab5"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-win32.whl", hash = "sha256:a65a8fd09bdffd63fa23b39cd902e6a4ca23d86ecfe129513e43767a1f3e91fb"},
{file = "SQLAlchemy-2.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:d2e7411d5ea164c6f4d003f5d4f5e72e202956aaa7496b95bb4a4c39669e001c"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:432cfd77642771ee7ea0dd0f3fb664f18506a3625eab6e6d5d1d771569171270"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ce076e25f1170000b4ecdc57a1ff8a70dbe4a5648ec3da0563ef3064e8db4f15"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14854bdb2a35af536d14f77dfa8dbc20e1bb1972996d64c4147e0d3165c9aaf5"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9020125e3be677c64d4dda7048e247343f1663089cf268a4cc98c957adb7dbe0"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:fb649c5473f79c9a7b6133f53a31f4d87de14755c79224007eb7ec76e628551e"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:33f73cc45ffa050f5c3b60ff4490e0ae9e02701461c1600d5ede1b008076b1b9"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-win32.whl", hash = "sha256:0789e199fbce8cb1775337afc631ed12bcc5463dd77d7a06b8dafd758cde51f8"},
{file = "SQLAlchemy-2.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:013f4f330001e84a2b0ef1f2c9bd73169c79d582e54e1a144be1be1dbc911711"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:4339110be209fea37a2bb4f35f1127c7562a0393e9e6df5d9a65cc4f5c167cb6"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e7e61e2e4dfe175dc3510889e44eda1c32f55870d6950ef40519640cb266704d"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6d44ff7573016fc26311b5a5c54d5656fb9e0c39e138bc8b81cb7c8667485203"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:57b80e877eb6ec63295835f8a3b86ca3a44829f80c4748e1b019e03adea550fc"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e90f0be674e0845c5c1ccfa5e31c9ee28fd406546a61afc734355cc7ea1f8f8b"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-win32.whl", hash = "sha256:e735a635126b2338dfd3a0863b675437cb53d85885a7602b8cffb24345df33ed"},
{file = "SQLAlchemy-2.0.7-cp37-cp37m-win_amd64.whl", hash = "sha256:ea1c63e61b5c13161c8468305f0a5837c80aae2070e33654c68dd12572b638eb"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cc337b96ec59ef29907eeadc2ac11188739281568f14c719e61550ca6d201a41"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:0eac488be90dd3f7a655d2e34fa59e1305fccabc4abfbd002e3a72ae10bd2f89"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8ab8f90f4a13c979e6c41c9f011b655c1b9ae2df6cffa8fa2c7c4d740f3512e"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc370d53fee7408330099c4bcc2573a107757b203bc61f114467dfe586a0c7bd"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:494db0026918e3f707466a1200a5dedbf254a4bce01a3115fd95f04ba8258f09"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:486015a58c9a67f65a15b4f19468b35b97cee074ae55386a9c240f1da308fbfe"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-win32.whl", hash = "sha256:5f7c40ec2e3b31293184020daba95850832bea523a08496ac89b27a5276ec804"},
{file = "SQLAlchemy-2.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:3da3dff8d9833a7d7f66a3c45a79a3955f775c79f47bb7eea266d0b4c267b17a"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:774965c41b71c8ebe3c5728bf5b9a948231fc3a0422d9fdace0686f5bb689ad6"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:94556a2a7fc3de094ea056b62845e2e6e271e26d1e1b2540a1cd2d2506257a10"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f15c54713a8dd57a01c974c9f96476688f6f6374d348819ed7e459535844b614"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ea9461f6955f3cf9eff6eeec271686caed7792c76f5b966886a36a42ea46e6b2"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:18795e87601b4244fd08b542cd6bff9ef674b17bcd34e4a3c9935398e2cc762c"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0b698440c477c00bdedff87348b19a79630a235864a8f4378098d61079c16ce9"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-win32.whl", hash = "sha256:38e26cf6b9b4c6c37846f7e31b42e4d664b35f055691265f07e06aeb6167c494"},
{file = "SQLAlchemy-2.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:a6f7d1debb233f1567d700ebcdde0781a0b63db0ef266246dfbf75ae41bfdf85"},
{file = "SQLAlchemy-2.0.7-py3-none-any.whl", hash = "sha256:fc67667c8e8c04e5c3250ab2cd51df40bc7c28c7c253d0475b377eff86fe4bb0"},
{file = "SQLAlchemy-2.0.7.tar.gz", hash = "sha256:a4c1e1582492c66dfacc9eab52738f3e64d9a2a380e412668f75aa06e540f649"},
]
[package.dependencies]
@ -837,6 +881,18 @@ files = [
{file = "typing_extensions-4.5.0.tar.gz", hash = "sha256:5cb5f4a79139d699607b3ef622a1dedafa84e115ab0024e0d9c044a9479ca7cb"},
]
[[package]]
name = "webassets"
version = "2.0"
description = "Media asset management for Python, with glue code for various web frameworks"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "webassets-2.0-py3-none-any.whl", hash = "sha256:a31a55147752ba1b3dc07dee0ad8c8efff274464e08bbdb88c1fd59ffd552724"},
{file = "webassets-2.0.tar.gz", hash = "sha256:167132337677c8cedc9705090f6d48da3fb262c8e0b2773b29f3352f050181cd"},
]
[[package]]
name = "werkzeug"
version = "2.2.3"
@ -958,5 +1014,5 @@ testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "de131da70fd04213714611f747ff9102979dbc6855e68645ea93fa83a6d433d8"
python-versions = "^3.8"
content-hash = "28f7d599f912b9385b4a497f89da59cf2b087ac1ec12c3582e603f47f8e11492"

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "onlylegs"
version = "23.03.03"
version = "23.03.20"
description = "Gallery built for fast and simple image management"
authors = ["Fluffy-Bean <michal-gdula@protonmail.com>"]
license = "MIT"
@ -9,15 +9,17 @@ readme = ".github/README.md"
[tool.poetry.dependencies]
python = "^3.8"
Flask = "^2.2.2"
flask-compress = "^1.13"
gunicorn = "^20.1.0"
Flask-Compress = "^1.13"
Flask-Caching = "^2.0.2"
Flask-Assets = "^2.0"
SQLAlchemy = "^2.0.3"
python-dotenv = "^0.21.0"
gunicorn = "^20.1.0"
pyyaml = "^6.0"
libsass = "^0.22.0"
colorthief = "^0.2.1"
Pillow = "^9.4.0"
platformdirs = "^3.0.0"
SQLAlchemy = "^2.0.3"
pylint = "^2.16.3"
[build-system]

42
run.py Normal file
View file

@ -0,0 +1,42 @@
"""
Run script for OnlyLegs
"""
from setup.args import PORT, ADDRESS, WORKERS, DEBUG
from setup.configuration import Configuration
print("""
___ _ _
/ _ \ _ __ | |_ _| | ___ __ _ ___
| | | | '_ \| | | | | | / _ \/ _` / __|
| |_| | | | | | |_| | |__| __/ (_| \__ \
\___/|_| |_|_|\__, |_____\___|\__, |___/
|___/ |___/
Created by Fluffy Bean - Version 23.03.20
""")
Configuration() # Run pre-checks
if DEBUG:
from gallery import create_app
# If no address is specified, use localhost
if not ADDRESS:
ADDRESS = 'localhost'
create_app().run(host=ADDRESS, port=PORT, debug=True, threaded=True)
else:
from setup.runner import OnlyLegs # pylint: disable=C0412
# If no address is specified, bind the server to all interfaces
if not ADDRESS:
ADDRESS = '0.0.0.0'
options = {
'bind': f'{ADDRESS}:{PORT}',
'workers': WORKERS,
}
OnlyLegs(options).run()

27
setup/args.py Normal file
View file

@ -0,0 +1,27 @@
"""
Startup arguments for the OnlyLegs gallery
-p, --port: Port to run on (default: 5000)
-a, --address: Address to run on (default: For Debug: localhost, For Production: 0.0.0.0)
-w, --workers: Number of workers to run (default: 4)
-d, --debug: Run as Flask app in debug mode (default: False)
-S, --scream: Show verbose output (default: False)
-h, --help: Show a help message
"""
import argparse
parser = argparse.ArgumentParser(description='Run the OnlyLegs gallery')
parser.add_argument('-p', '--port', type=int, default=5000, help='Port to run on')
parser.add_argument('-a', '--address', type=str, default=None, help='Address to run on')
parser.add_argument('-w', '--workers', type=int, default=4, help='Number of workers to run')
parser.add_argument('-d', '--debug', action='store_true', help='Run as Flask app in debug mode')
args = parser.parse_args()
PORT = args.port
ADDRESS = args.address
WORKERS = args.workers
DEBUG = args.debug

163
setup/configuration.py Normal file
View file

@ -0,0 +1,163 @@
"""
OnlyLegs - Setup
Runs when the app detects that there is no user directory
"""
import os
import sys
import logging
import re
import platformdirs
import yaml
USER_DIR = platformdirs.user_config_dir('onlylegs')
class Configuration:
"""
Setup the application on first run
"""
def __init__(self):
"""
Main setup function
"""
print("Running startup checks...")
# Check if the user directory exists
if not os.path.exists(USER_DIR):
self.make_dir()
# Check if the .env file exists
if not os.path.exists(os.path.join(USER_DIR, '.env')):
self.make_env()
# Check if the conf.yml file exists
if not os.path.exists(os.path.join(USER_DIR, 'conf.yml')):
self.make_yaml()
# Load the config files
self.logging_config()
@staticmethod
def make_dir():
"""
Create the user directory
"""
try:
os.makedirs(USER_DIR)
os.makedirs(os.path.join(USER_DIR, 'instance'))
os.makedirs(os.path.join(USER_DIR, 'uploads'))
except Exception as err:
print("Error creating user directory:", err)
sys.exit(1)
print("Created user directory at:", USER_DIR)
@staticmethod
def make_env():
"""
Create the .env file with default values
"""
env_conf = {
'FLASK_SECRET': os.urandom(32).hex(),
}
try:
with open(os.path.join(USER_DIR, '.env'), encoding='utf-8', mode='w+') as file:
for key, value in env_conf.items():
file.write(f"{key}={value}\n")
except Exception as err:
print("Error creating environment variables:", err)
sys.exit(1)
print("""
####################################################
# A NEW KEY WAS GENERATED FOR YOU! PLEASE NOTE #
# DOWN THE FLASK_SECRET KEY LOCATED IN YOUR #
# .config/onlylegs/.env FOLDER! LOOSING THIS KEY #
# WILL RESULT IN YOU BEING UNABLE TO LOG IN! #
####################################################
""")
@staticmethod
def make_yaml():
"""
Create the YAML config file with default values
"""
is_correct = False
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')
print("\nNo config file found, please enter the following information:")
while not is_correct:
username = input("Admin username: ")
name = input("Admin name: ")
email = input("Admin email: ")
# Check if the values are valid
if not username or not username_regex.match(username):
print("Username is invalid!")
continue
if not name:
print("Name is invalid!")
continue
if not email or not email_regex.match(email):
print("Email is invalid!")
continue
# Check if user is happy with the values
if input("Is this correct? (y/n): ").lower() == 'y':
is_correct = True
yaml_conf = {
'admin': {
'name': name,
'username': username,
'email': email,
},
'upload': {
'allowed-extensions': {
'jpg': 'jpeg',
'jpeg': 'jpeg',
'png': 'png',
'webp': 'webp',
},
'max-size': 69,
'rename': 'GWA_{{username}}_{{time}}',
},
'website': {
'name': 'OnlyLegs',
'motto': 'A gallery built for fast and simple image management!',
'language': 'en',
}
}
try:
with open(os.path.join(USER_DIR, 'conf.yml'), encoding='utf-8', mode='w+') as file:
yaml.dump(yaml_conf, file, default_flow_style=False)
except Exception as err:
print("Error creating default gallery config:", err)
sys.exit(1)
print("Generated config file, you can change these values in the settings of the app")
@staticmethod
def logging_config():
"""
Set the logging config
"""
logs_path = os.path.join(platformdirs.user_config_dir('onlylegs'), 'logs')
if not os.path.isdir(logs_path):
os.mkdir(logs_path)
print("Created logs directory at:", logs_path)
logging.getLogger('werkzeug').disabled = True
logging.basicConfig(
filename=os.path.join(logs_path, 'only.log'),
level=logging.INFO,
datefmt='%Y-%m-%d %H:%M:%S',
format='%(asctime)s %(levelname)s %(name)s %(threadName)s : %(message)s',
encoding='utf-8')

32
setup/runner.py Normal file
View file

@ -0,0 +1,32 @@
"""
Gunicorn configuration file
"""
from gunicorn.app.base import Application
from gunicorn import util
class OnlyLegs(Application):
"""
Gunicorn application
"""
def __init__(self, options={}): # pylint: disable=W0102, W0231
self.usage = None
self.callable = None
self.options = options
self.do_load_config()
def init(self, *args):
"""
Initialize the application
"""
cfg = {}
for setting, value in self.options.items():
if setting.lower() in self.cfg.settings and value is not None:
cfg[setting.lower()] = value
return cfg
def prog(self): # pylint: disable=C0116, E0202
return 'OnlyLegs'
def load(self):
return util.import_app('gallery:create_app()')