From 8a4fe891ef789b5776c15a92050dbc8bbadeae3e Mon Sep 17 00:00:00 2001 From: Fluffy-Bean <michal-gdula@protonmail.com> Date: Sun, 24 Sep 2023 19:53:14 +0100 Subject: [PATCH] Add context menu script --- onlylegs/api.py | 107 ++++++++++---- onlylegs/models.py | 24 +++- onlylegs/static/js/contextMenu.js | 135 ++++++++++++++++++ onlylegs/static/js/imagePage.js | 64 ++++++++- onlylegs/static/js/popup.js | 16 ++- .../static/sass/components/context-menu.sass | 128 +++++++++++++++++ onlylegs/static/sass/style.sass | 1 + onlylegs/templates/image.html | 67 +++------ onlylegs/views/image.py | 17 +-- 9 files changed, 463 insertions(+), 96 deletions(-) create mode 100644 onlylegs/static/js/contextMenu.js create mode 100644 onlylegs/static/sass/components/context-menu.sass diff --git a/onlylegs/api.py b/onlylegs/api.py index fffeea5..44c187c 100644 --- a/onlylegs/api.py +++ b/onlylegs/api.py @@ -7,6 +7,9 @@ import re import logging from uuid import uuid4 +from PIL import Image +from PIL.ExifTags import TAGS + from flask import ( Blueprint, abort, @@ -20,8 +23,7 @@ from flask_login import login_required, current_user from colorthief import ColorThief from onlylegs.extensions import db -from onlylegs.models import Users, Pictures -from onlylegs.utils.metadata import yoink +from onlylegs.models import Users, Pictures, Exif from onlylegs.utils.generate_image import generate_thumbnail @@ -137,46 +139,91 @@ def upload(): """ Uploads an image to the server and saves it to the database """ - form_file = request.files.get("file", None) - form = request.form + image_description = request.form.get("description", "").strip() + image_alt = request.form.get("alt", "").strip() + image_file = request.files.get("file", None) - if not form_file: + if not image_file: return jsonify({"message": "No file"}), 400 # 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 - ) + image_mime = pathlib.Path(image_file.filename).suffix.replace(".", "").lower() + image_name = "GWAGWA_" + str(uuid4()) + image_filename = image_name + "." + image_mime + save_path = os.path.join(current_app.config["UPLOAD_FOLDER"], image_filename) - # 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) + if image_mime not in current_app.config["ALLOWED_EXTENSIONS"].keys(): + logging.info("File extension not allowed: %s", image_mime) return jsonify({"message": "File extension not allowed"}), 403 - # Save file - try: - form_file.save(img_path) - except OSError as err: - logging.info("Error saving file %s because of %s", img_path, err) - return jsonify({"message": "Error saving file"}), 500 + image_file.save(save_path) - img_exif = yoink(img_path) # Get EXIF data - img_colors = ColorThief(img_path).get_palette(color_count=3) # Get color palette + image_colours = ColorThief(save_path).get_palette(color_count=3) - # Save to database - query = Pictures( + image_record = Pictures( author_id=current_user.id, - filename=img_name + "." + img_ext, - mimetype=img_ext, - exif=img_exif, - colours=img_colors, - description=form["description"], - alt=form["alt"], + filename=image_filename, + mimetype=image_mime, + colours=image_colours, + description=image_description, + alt=image_alt, ) + db.session.add(image_record) + db.session.commit() - db.session.add(query) + image_exif = [] + with Image.open(save_path) as file: + image_exif.append( + Exif( + picture_id=image_record.id, + key="FileName", + value=image_filename, + ) + ) + image_exif.append( + Exif( + picture_id=image_record.id, + key="FileSize", + value=os.path.getsize(save_path), + ) + ) + image_exif.append( + Exif( + picture_id=image_record.id, + key="FileFormat", + value=image_mime, + ) + ) + image_exif.append( + Exif( + picture_id=image_record.id, + key="FileWidth", + value=file.size[0], + ) + ) + image_exif.append( + Exif( + picture_id=image_record.id, + key="FileHeight", + value=file.size[1], + ) + ) + + try: + tags = file._getexif() + for tag, value in TAGS.items(): + if tag in tags: + image_exif.append( + Exif( + picture_id=image_record.id, + key=value, + value=tags[tag], + ) + ) + except TypeError: + pass + + db.session.add_all(image_exif) db.session.commit() return jsonify({"message": "File uploaded"}), 200 diff --git a/onlylegs/models.py b/onlylegs/models.py index 6970a9b..565b130 100644 --- a/onlylegs/models.py +++ b/onlylegs/models.py @@ -6,7 +6,7 @@ from flask_login import UserMixin from onlylegs.extensions import db -class AlbumJunction(db.Model): # pylint: disable=too-few-public-methods, C0103 +class AlbumJunction(db.Model): """ Junction table for picturess and albums Joins with picturess and albums @@ -26,7 +26,7 @@ class AlbumJunction(db.Model): # pylint: disable=too-few-public-methods, C0103 ) -class Pictures(db.Model): # pylint: disable=too-few-public-methods, C0103 +class Pictures(db.Model): """ Pictures table """ @@ -38,7 +38,6 @@ class Pictures(db.Model): # pylint: disable=too-few-public-methods, C0103 filename = db.Column(db.String, unique=True, nullable=False) mimetype = db.Column(db.String, nullable=False) - exif = db.Column(db.PickleType, nullable=False) colours = db.Column(db.PickleType, nullable=False) description = db.Column(db.String, nullable=False) @@ -51,9 +50,24 @@ class Pictures(db.Model): # pylint: disable=too-few-public-methods, C0103 ) album_fk = db.relationship("AlbumJunction", backref="pictures") + exif_fk = db.relationship("Exif", backref="pictures") -class Albums(db.Model): # pylint: disable=too-few-public-methods, C0103 +class Exif(db.Model): + """ + Exif data for pictures + """ + + __tablename__ = "exif" + + id = db.Column(db.Integer, primary_key=True) + picture_id = db.Column(db.Integer, db.ForeignKey("pictures.id")) + + key = db.Column(db.String, nullable=False) + value = db.Column(db.String, nullable=False) + + +class Albums(db.Model): """ albums table """ @@ -75,7 +89,7 @@ class Albums(db.Model): # pylint: disable=too-few-public-methods, C0103 album_fk = db.relationship("AlbumJunction", backref="albums") -class Users(db.Model, UserMixin): # pylint: disable=too-few-public-methods, C0103 +class Users(db.Model, UserMixin): """ Users table """ diff --git a/onlylegs/static/js/contextMenu.js b/onlylegs/static/js/contextMenu.js new file mode 100644 index 0000000..c89829a --- /dev/null +++ b/onlylegs/static/js/contextMenu.js @@ -0,0 +1,135 @@ +function showContextMenu(obj, menu, position='mouse') { + // If the context menu is already open, close it first + if (document.querySelector(".contextMenu")) { + dissmissContextMenu(); + } + + // Add span to close the context menu + let contextCloseSpan = document.createElement("span"); + contextCloseSpan.className = "contextMenuClose"; + contextCloseSpan.onclick = dissmissContextMenu; + + // Create the context menu + let contextMenu = document.createElement("div"); + contextMenu.className = "contextMenu"; + + // Create the menu items + menu.forEach(array => { + if (array.value === "divider") { + let divider = document.createElement("hr"); + divider.className = "contextMenuDivider"; + + contextMenu.appendChild(divider); + return; + } else if (array['value'] === "title") { + let titleP = document.createElement("p"); + titleP.className = "contextMenuTitle"; + titleP.innerHTML = array.text; + + contextMenu.appendChild(titleP); + let divider = document.createElement("hr"); + divider.className = "contextMenuDivider"; + + contextMenu.appendChild(divider); + return; + } + + let itemBtn = document.createElement("button"); + itemBtn.className = "contextMenuItem"; + itemBtn.onclick = array['function']; + + if (array['type'] === "critical") { + itemBtn.classList.add("contextMenuItem__critical"); + } else if (array['type'] === "warning") { + itemBtn.classList.add("contextMenuItem__warning"); + } else if (array['type'] === "success") { + itemBtn.classList.add("contextMenuItem__success"); + } else if (array['type'] === "info") { + itemBtn.classList.add("contextMenuItem__info"); + } + + let itemIcon = document.createElement("span"); + itemIcon.className = "contextMenuIcon"; + if (array['icon']) { + itemIcon.innerHTML = array['icon']; + } else { + itemIcon.innerHTML = ''; + } + itemBtn.appendChild(itemIcon); + + // Create the text for the action + let itemText = document.createElement("p"); + itemText.className = "contextMenuText"; + itemText.innerHTML = array.value; + itemBtn.appendChild(itemText); + + contextMenu.appendChild(itemBtn); + }); + + // Add the context menu to the body + document.body.appendChild(contextMenu); + document.body.appendChild(contextCloseSpan); + + let posX; + let posY; + + if (position === 'mouse') { + posX = event.clientX + 5; + posY = event.clientY + 5; + } else if (position === 'button') { + posX = obj.offsetLeft + (obj.offsetWidth / 2) - (contextMenu.offsetWidth / 2); + posY = obj.offsetTop + obj.offsetHeight + 5; + } else if (position === 'center') { + posX = (window.innerWidth / 2) - (contextMenu.offsetWidth / 2); + posY = (window.innerHeight / 2) - (contextMenu.offsetHeight / 2); + } else { + posX = event.clientX + 5; + posY = event.clientY + 5; + } + + // Move the context menu if it is off the screen + if (posX + contextMenu.offsetWidth > window.innerWidth) { + posX = window.innerWidth - (contextMenu.offsetWidth + 5); + } else if (posX < 0) { + posX = 5; + } + if (posY < 0) { + posY = 5; + } + contextMenu.style.left = posX + "px"; + contextMenu.style.top = posY + "px"; + + // Timeout otherwise animation doesn't work + setTimeout(function() { + if (position === 'mouse') { + contextMenu.classList.add("contextMenu__show--mouse"); + } else if (position === 'button') { + contextMenu.classList.add("contextMenu__show--button"); + } else if (position === 'center') { + contextMenu.classList.add("contextMenu__show--center"); + } else { + contextMenu.classList.add("contextMenu__show"); + } + }, 1); +} + +function dissmissContextMenu() { + // Remove the close span + let contextSpan = document.querySelectorAll(".contextMenuClose"); + contextSpan.forEach(menu => { + menu.remove(); + }); + + // Get the context menu + let contextMenu = document.querySelectorAll(".contextMenu"); + contextMenu.forEach(menu => { + menu.classList.add("contextMenu__hide"); + setTimeout(function() { + menu.remove(); + }, 500); + }); +} + +window.onresize = () => { + dissmissContextMenu(); +} diff --git a/onlylegs/static/js/imagePage.js b/onlylegs/static/js/imagePage.js index b98d46d..a1eb24c 100644 --- a/onlylegs/static/js/imagePage.js +++ b/onlylegs/static/js/imagePage.js @@ -12,6 +12,69 @@ function imageFullscreen() { document.cookie = "image-info=1" } } + +function imageShowOptionsPopup(obj) { + // let title = 'Options'; + // let subtitle = null; + // + // let body = document.createElement('div'); + // body.style.cssText = 'display: flex; flex-direction: column; gap: 0.5rem;'; + // + // let copyBtn = document.createElement('button'); + // copyBtn.classList.add('btn-block'); + // copyBtn.innerHTML = 'Copy URL'; + // copyBtn.onclick = () => { + // copyToClipboard(window.location.href) + // } + // + // let downloadBtn = document.createElement('a'); + // downloadBtn.classList.add('btn-block'); + // downloadBtn.innerHTML = 'Download'; + // downloadBtn.href = '/api/media/uploads/' + image_data["filename"]; + // downloadBtn.download = ''; + // + // body.appendChild(copyBtn); + // body.appendChild(downloadBtn); + // + // if (image_data["owner"]) { + // let editBtn = document.createElement('button'); + // editBtn.classList.add('btn-block'); + // editBtn.classList.add('critical'); + // editBtn.innerHTML = 'Edit'; + // editBtn.onclick = imageEditPopup; + // + // let deleteBtn = document.createElement('button'); + // deleteBtn.classList.add('btn-block'); + // deleteBtn.classList.add('critical'); + // deleteBtn.innerHTML = 'Delete'; + // deleteBtn.onclick = imageDeletePopup; + // + // body.appendChild(editBtn); + // body.appendChild(deleteBtn); + // } + // + // popupShow(title, subtitle, body, [popupCancelButton]); + + showContextMenu(obj, [ + { + 'value': 'Edit', + 'function': () => { + dissmissContextMenu(); + imageEditPopup(); + }, + 'type': 'critical' + }, + { + 'value': 'Delete', + 'function': () => { + dissmissContextMenu(); + imageDeletePopup(); + }, + 'type': 'critical' + } + ], 'button') +} + function imageDeletePopup() { let title = 'DESTRUCTION!!!!!!'; let subtitle = @@ -92,7 +155,6 @@ function imageEditConfirm(event) { body: form, }).then(response => { if (response.ok) { - popupDismiss(); window.location.reload(); } else { addNotification('Image *clings*', 2); diff --git a/onlylegs/static/js/popup.js b/onlylegs/static/js/popup.js index 0ef8cc4..31914cd 100644 --- a/onlylegs/static/js/popup.js +++ b/onlylegs/static/js/popup.js @@ -9,13 +9,17 @@ function popupShow(titleText, subtitleText, bodyContent=null, userActions=null) actionsSelector.innerHTML = ''; // Set popup header and subtitle - let titleElement = document.createElement('h2'); - titleElement.innerHTML = titleText; - headerSelector.appendChild(titleElement); + if (titleText) { + let titleElement = document.createElement('h2'); + titleElement.innerHTML = titleText; + headerSelector.appendChild(titleElement); + } - let subtitleElement = document.createElement('p'); - subtitleElement.innerHTML = subtitleText; - headerSelector.appendChild(subtitleElement); + if (subtitleText) { + let subtitleElement = document.createElement('p'); + subtitleElement.innerHTML = subtitleText; + headerSelector.appendChild(subtitleElement); + } if (bodyContent) { headerSelector.appendChild(bodyContent) } diff --git a/onlylegs/static/sass/components/context-menu.sass b/onlylegs/static/sass/components/context-menu.sass new file mode 100644 index 0000000..15ab78b --- /dev/null +++ b/onlylegs/static/sass/components/context-menu.sass @@ -0,0 +1,128 @@ +.contextMenuClose + width: 100% + height: 100% + + position: fixed + top: 0 + left: 0 + + background-color: transparent + z-index: 99998 + +.contextMenu + margin: 0 + padding: 0.25rem + + width: calc( 100vw - 10px ) + height: auto + max-width: 300px + + position: absolute + + display: flex + flex-direction: column + justify-content: flex-start + align-items: flex-start + gap: 0.25rem + + background-color: RGB($bg-300) + border: 1px solid RGB($bg-200) + border-radius: 6px + + overflow: hidden + + transition: transform 0.2s ease-in-out, opacity 0.2s ease-in-out + transform-origin: center center + opacity: 0.5 + transform: scale(0, 0) + z-index: 99999 + +.contextMenuTitle + margin: 0 + padding: 0.25rem 0.5rem + + width: 100% + + text-align: center + font-size: 1.2rem + font-weight: 400 + color: RGB($fg-white) + +.contextMenuItem + margin: 0 + padding: 0.5rem + + width: 100% + height: auto + + display: flex + flex-direction: row + justify-content: flex-start + align-items: center + gap: 0.5rem + + background-color: RGB($bg-300) + color: RGB($fg-white) + border: none + border-radius: 3px + + cursor: pointer +.contextMenuItem:hover + background-color: RGB($bg-200) +.contextMenuItem__critical + color: RGB($critical) +.contextMenuItem__warning + color: RGB($warning) +.contextMenuItem__success + color: RGB($success) +.contextMenuItem__info + color: RGB($primary) + +.contextMenuText + margin: 0 + padding: 0 + + font-size: 1rem + font-weight: 400 + +.contextMenuIcon + margin: 0 + padding: 0 + + width: 1.25rem + height: 1.25rem + + display: flex + justify-content: center + align-items: center + +.contextMenuDivider + margin: 0 auto + padding: 0 + + width: 100% + height: 1px + + border: none + background-color: RGB($bg-200) + +.contextMenu__show + opacity: 1 + transform: scale(1, 1) + +.contextMenu__show--mouse + opacity: 1 + transform: scale(1, 1) + transform-origin: top left +.contextMenu__show--button + opacity: 1 + transform: scale(1, 1) + transform-origin: top center +.contextMenu__show--center + opacity: 1 + transform: scale(1, 1) + transform-origin: center center + +.contextMenu__hide + opacity: 0 + transform: scale(0, 0) diff --git a/onlylegs/static/sass/style.sass b/onlylegs/static/sass/style.sass index daf7094..c363234 100644 --- a/onlylegs/static/sass/style.sass +++ b/onlylegs/static/sass/style.sass @@ -6,6 +6,7 @@ @import "components/notification" @import "components/pop-up" +@import "components/context-menu" @import "components/upload-panel" @import "components/tags" diff --git a/onlylegs/templates/image.html b/onlylegs/templates/image.html index 2a76cf0..edb8a12 100644 --- a/onlylegs/templates/image.html +++ b/onlylegs/templates/image.html @@ -13,7 +13,8 @@ 'id': {{ image.id }}, 'description': '{{ image.description }}', 'alt': '{{ image.alt }}', - }; + 'filename': '{{ image.filename }}', + } </script> <style> @@ -31,16 +32,12 @@ <div class="pill-row"> {% if next_url %}<div><a class="pill-item" href="{{ next_url }}"><i class="ph ph-arrow-left"></i></a></div>{% endif %} <div> - <button class="pill-item" onclick="imageFullscreen()" id="fullscreenImage"><i class="ph ph-info"></i></button> + <button class="pill-item" onclick="imageFullscreen()"><i class="ph ph-info"></i></button> <button class="pill-item" onclick="copyToClipboard(window.location.href)"><i class="ph ph-export"></i></button> - <a class="pill-item" href="{{ url_for('api.media', path='uploads/' + image.filename) }}" download onclick="addNotification('Download started!', 4)"><i class="ph ph-file-arrow-down"></i></a> + {% if image.author.id == current_user.id %} + <button class="pill-item pill__critical" onclick="imageShowOptionsPopup(this)"><i class="ph-fill ph-dots-three-outline-vertical"></i></button> + {% endif %} </div> - {% if current_user.id == image.author.id %} - <div> - <button class="pill-item pill__critical" onclick="imageDeletePopup()"><i class="ph ph-trash"></i></button> - <button class="pill-item pill__critical" onclick="imageEditPopup()"><i class="ph ph-pencil-simple"></i></button> - </div> - {% endif %} {% if prev_url %}<div><a class="pill-item" href="{{ prev_url }}"><i class="ph ph-arrow-right"></i></a></div>{% endif %} </div> </div> @@ -116,42 +113,20 @@ </div> {% endif %} </details> - {% for tag in image.exif %} - <details open> - <summary> - {% if tag == 'Photographer' %} - <i class="ph ph-person"></i><h2>Photographer</h2> - {% elif tag == 'Camera' %} - <i class="ph ph-camera"></i><h2>Camera</h2> - {% elif tag == 'Software' %} - <i class="ph ph-desktop-tower"></i><h2>Software</h2> - {% elif tag == 'File' %} - <i class="ph ph-file-image"></i><h2>File</h2> - {% else %} - <i class="ph ph-file-image"></i><h2>{{ tag }}</h2> - {% endif %} - <span style="width: 100%"></span> - <i class="ph ph-caret-down collapse-indicator"></i> - </summary> - <table> - {% for subtag in image.exif[tag] %} - <tr> - <td>{{ subtag }}</td> - {% if image.exif[tag][subtag]['formatted'] %} - {% if image.exif[tag][subtag]['type'] == 'date' %} - <td><span class="time">{{ image.exif[tag][subtag]['formatted'] }}</span></td> - {% else %} - <td>{{ image.exif[tag][subtag]['formatted'] }}</td> - {% endif %} - {% elif image.exif[tag][subtag]['raw'] %} - <td>{{ image.exif[tag][subtag]['raw'] }}</td> - {% else %} - <td class="empty-table">Oops, an error</td> - {% endif %} - </tr> - {% endfor %} - </table> - </details> - {% endfor %} + <details open> + <summary> + <i class="ph ph-file-image"></i><h2>Metadata</h2> + <span style="width: 100%"></span> + <i class="ph ph-caret-down collapse-indicator"></i> + </summary> + <table> + {% for tag in image_exif %} + <tr> + <td>{{ tag.key }}</td> + <td>{{ tag.value }}</td> + </tr> + {% endfor %} + </table> + </details> </div> {% endblock %} diff --git a/onlylegs/views/image.py b/onlylegs/views/image.py index 75a70fd..16ee582 100644 --- a/onlylegs/views/image.py +++ b/onlylegs/views/image.py @@ -15,7 +15,7 @@ from flask import ( jsonify, ) from flask_login import current_user -from onlylegs.models import Pictures, AlbumJunction, Albums +from onlylegs.models import Pictures, AlbumJunction, Albums, Exif from onlylegs.extensions import db @@ -28,7 +28,8 @@ def image(image_id): Image view, shows the image and its metadata """ # Get the image, if it doesn't exist, 404 - image = db.get_or_404(Pictures, image_id, description="Image not found :<") + image_record = db.get_or_404(Pictures, image_id, description="Image not found :<") + image_record_exif = Exif.query.filter(Exif.picture_id == image_id).all() # Get all groups the image is in groups = ( @@ -38,9 +39,9 @@ def image(image_id): ) # Get the group data for each group the image is in - image.groups = [] + image_record.groups = [] for group in groups: - image.groups.append( + image_record.groups.append( Albums.query.with_entities(Albums.id, Albums.name) .filter(Albums.id == group[0]) .first() @@ -72,9 +73,8 @@ def image(image_id): limit = current_app.config["UPLOAD_CONF"]["max-load"] # If the number of items is less than the limit, no point of calculating the page - if len(total_images) <= limit: - return_page = None - else: + return_page = None + if len(total_images) > limit: # How many pages should there be for i in range(ceil(len(total_images) / limit)): # Slice the list of IDs into chunks of the limit @@ -90,7 +90,8 @@ def image(image_id): return render_template( "image.html", - image=image, + image=image_record, + image_exif=image_record_exif, next_url=next_url, prev_url=prev_url, return_page=return_page,