From da5c1ee0081f1117fc8448bf83eb50982e0532c6 Mon Sep 17 00:00:00 2001 From: Ethan Dalool Date: Sun, 17 Sep 2023 14:07:22 -0700 Subject: [PATCH] Let PhotoTags have timestamps; use more js cards. Tags on photos can now have timestamps, so that if you are tagging a video or audio you can reference a specific moment with your tag. In the interface, this means the tag is clickable and seeks to that point in the media. For the user interface, I am finding I need to move away from jinja for the object cards because it is too much hassle to keep the code for jinja-based cards for static rendering and the js-based cards for dynamic rendering in sync. Rather than write the same cards in two languages I can dump the JSON into the script and render the cards on load. Which makes the static HTML worse but that's what the JSON API is for anyway. --- etiquette/constants.py | 6 +- etiquette/objects.py | 220 +++++++++++++----- .../backend/endpoints/album_endpoints.py | 12 +- .../backend/endpoints/photo_endpoints.py | 40 ++-- .../backend/endpoints/tag_endpoints.py | 2 +- .../etiquette_flask/static/css/cards.css | 23 +- .../etiquette_flask/static/css/etiquette.css | 3 +- frontends/etiquette_flask/static/js/api.js | 16 +- frontends/etiquette_flask/static/js/cards.js | 217 ++++++++++++++++- frontends/etiquette_flask/static/js/common.js | 12 +- .../etiquette_flask/templates/bookmarks.html | 46 ++-- .../etiquette_flask/templates/cards.html | 49 +--- .../etiquette_flask/templates/clipboard.html | 2 +- .../etiquette_flask/templates/photo.html | 173 +++++++++----- frontends/etiquette_flask/templates/root.html | 2 - .../etiquette_flask/templates/search.html | 6 + .../etiquette_flask/templates/swipe.html | 124 +++++----- frontends/etiquette_flask/templates/tags.html | 112 ++++----- frontends/etiquette_flask/templates/user.html | 102 +++++--- utilities/database_upgrader.py | 29 +++ 20 files changed, 834 insertions(+), 362 deletions(-) diff --git a/etiquette/constants.py b/etiquette/constants.py index 0b7fa76..a362034 100644 --- a/etiquette/constants.py +++ b/etiquette/constants.py @@ -41,7 +41,7 @@ ffmpeg = _load_ffmpeg() # Database ######################################################################################### -DATABASE_VERSION = 24 +DATABASE_VERSION = 25 DB_INIT = ''' CREATE TABLE IF NOT EXISTS albums( @@ -165,10 +165,12 @@ CREATE INDEX IF NOT EXISTS index_album_photo_rel_albumid on album_photo_rel(albu CREATE INDEX IF NOT EXISTS index_album_photo_rel_photoid on album_photo_rel(photoid); ---------------------------------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS photo_tag_rel( + id INT PRIMARY KEY NOT NULL, photoid INT NOT NULL, tagid INT NOT NULL, created INT, - PRIMARY KEY(photoid, tagid), + timestamp REAL, + UNIQUE(photoid, tagid, timestamp), FOREIGN KEY(photoid) REFERENCES photos(id), FOREIGN KEY(tagid) REFERENCES tags(id) ); diff --git a/etiquette/objects.py b/etiquette/objects.py index 544c689..f12426a 100644 --- a/etiquette/objects.py +++ b/etiquette/objects.py @@ -583,7 +583,14 @@ class Album(ObjectBase, GroupableMixin): ) return exists is not None - def jsonify(self, include_photos=True, minimal=False) -> dict: + def jsonify( + self, + include_photos=True, + include_parents=True, + include_children=True, + count_children=False, + count_photos=False, + ) -> dict: j = { 'type': 'album', 'id': self.id, @@ -594,12 +601,23 @@ class Album(ObjectBase, GroupableMixin): 'thumbnail_photo': self.thumbnail_photo.id if self._thumbnail_photo else None, 'author': self.author.jsonify() if self._author_id else None, } - if not minimal: + if self.deleted: + j['deleted'] = True + + if include_parents: j['parents'] = [parent.id for parent in self.get_parents()] + + if include_children: j['children'] = [child.id for child in self.get_children()] - if include_photos: - j['photos'] = [photo.id for photo in self.get_photos()] + if include_photos: + j['photos'] = [photo.id for photo in self.get_photos()] + + if count_children: + j['children_count'] = self.sum_children(recurse=False) + + if count_photos: + j['photos_count'] = self.sum_photos(recurse=False) return j @@ -884,6 +902,9 @@ class Bookmark(ObjectBase): 'title': self.title, 'display_name': self.display_name, } + if self.deleted: + j['deleted'] = True + return j class Photo(ObjectBase): @@ -958,7 +979,7 @@ class Photo(ObjectBase): return cleaned def _assign_mimetype(self): - # This function is defined separately because it is a derivative + # This method is defined separately because it is a derivative # property of the file's basename and needs to be recalculated after # file renames. However, I decided not to write it as a @property # because that would require either wasted computation or using private @@ -977,23 +998,27 @@ class Photo(ObjectBase): def _uncache(self): self.photodb.caches[Photo].remove(self.id) - # Will add -> Tag when forward references are supported by Python. + # Will add -> PhotoTagRel when forward references are supported by Python. @decorators.required_feature('photo.add_remove_tag') @worms.atomic - def add_tag(self, tag): + def add_tag(self, tag, timestamp=None): tag = self.photodb.get_tag(name=tag) - if self.has_tag(tag, check_children=False): - return tag + existing = self.has_tag(tag, check_children=False, match_timestamp=timestamp) + if existing: + return existing log.info('Applying %s to %s.', tag, self) data = { + 'id': self.photodb.generate_id(PhotoTagRel), 'photoid': self.id, 'tagid': tag.id, 'created': timetools.now().timestamp(), + 'timestamp': PhotoTagRel.normalize_timestamp(timestamp) } - self.photodb.insert(table='photo_tag_rel', pairs=data) + self.photodb.insert(table=PhotoTagRel, pairs=data) + photo_tag = PhotoTagRel(self.photodb, data) data = { 'id': self.id, @@ -1001,7 +1026,7 @@ class Photo(ObjectBase): } self.photodb.update(table=Photo, pairs=data, where_key='id') - return tag + return photo_tag def atomify(self, web_root='') -> bs4.BeautifulSoup: web_root = web_root.rstrip('/') @@ -1129,6 +1154,7 @@ class Photo(ObjectBase): self.set_thumbnail(image) return image + @decorators.cache_until_commit def get_containing_albums(self) -> set[Album]: ''' Return the albums of which this photo is a member. @@ -1137,7 +1163,7 @@ class Photo(ObjectBase): 'SELECT albumid FROM album_photo_rel WHERE photoid == ?', [self.id] ) - albums = set(self.photodb.get_albums_by_id(album_ids)) + albums = frozenset(self.photodb.get_albums_by_id(album_ids)) return albums @decorators.cache_until_commit @@ -1145,12 +1171,16 @@ class Photo(ObjectBase): ''' Return the tags assigned to this Photo. ''' - tag_ids = self.photodb.select_column( - 'SELECT tagid FROM photo_tag_rel WHERE photoid == ?', + photo_tags = frozenset(self.photodb.get_objects_by_sql( + PhotoTagRel, + 'SELECT * FROM photo_tag_rel WHERE photoid == ?', [self.id] - ) - tags = set(self.photodb.get_tags_by_id(tag_ids)) - return tags + )) + return photo_tags + + @decorators.cache_until_commit + def get_tag_names(self) -> set: + return set(photo_tag.tag.name for photo_tag in self.get_tags()) def get_thumbnail(self): query = 'SELECT thumbnail FROM photo_thumbnails WHERE photoid = ?' @@ -1158,9 +1188,9 @@ class Photo(ObjectBase): return blob # Will add -> Tag/False when forward references are supported. - def has_tag(self, tag, *, check_children=True): + def has_tag(self, tag, *, check_children=True, match_timestamp=False): ''' - Return the Tag object if this photo contains that tag. + Return the PhotoTagRel object if this photo contains that tag. Otherwise return False. check_children: @@ -1176,15 +1206,19 @@ class Photo(ObjectBase): tag_by_id = {t.id: t for t in tag_options} tag_option_ids = sqlhelpers.listify(tag_by_id) - tag_id = self.photodb.select_one_value( - f'SELECT tagid FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids}', - [self.id] - ) - if tag_id is None: + if match_timestamp is False or match_timestamp is None: + query = f'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids}' + bindings = [self.id] + else: + query = f'SELECT * FROM photo_tag_rel WHERE photoid == ? AND tagid IN {tag_option_ids} AND timestamp == ?' + bindings = [self.id, match_timestamp] + + results = list(self.photodb.get_objects_by_sql(PhotoTagRel, query, bindings)) + if not results: return False - return tag_by_id[tag_id] + return results[0] def has_thumbnail(self) -> bool: if self._has_thumbnail is not None: @@ -1192,7 +1226,7 @@ class Photo(ObjectBase): self._has_thumbnail = self.photodb.exists('SELECT 1 FROM photo_thumbnails WHERE photoid = ?', [self.id]) return self._has_thumbnail - def jsonify(self, include_albums=True, include_tags=True, minimal=False) -> dict: + def jsonify(self, include_albums=True, include_tags=True) -> dict: j = { 'type': 'photo', 'id': self.id, @@ -1213,13 +1247,14 @@ class Photo(ObjectBase): 'searchhidden': bool(self.searchhidden), 'simple_mimetype': self.simple_mimetype, } + if self.deleted: + j['deleted'] = True - if not minimal: - if include_albums: - j['albums'] = [album.id for album in self.get_containing_albums()] + if include_albums: + j['albums'] = [album.id for album in self.get_containing_albums()] - if include_tags: - j['tags'] = [tag.id for tag in self.get_tags()] + if include_tags: + j['tags'] = [photo_tag.jsonify() for photo_tag in self.get_tags()] return j @@ -1377,6 +1412,11 @@ class Photo(ObjectBase): @decorators.required_feature('photo.add_remove_tag') @worms.atomic def remove_tag(self, tag) -> None: + ''' + This method removes all PhotoTagRel between this photo and tag. + If you just want to remove one timestamped instance, get the PhotoTagRel + object and call its delete method. + ''' tag = self.photodb.get_tag(name=tag) log.info('Removing %s from %s.', tag, self) @@ -1384,6 +1424,7 @@ class Photo(ObjectBase): 'photoid': self.id, 'tagid': tag.id, } + self.photodb.delete(table='photo_tag_rel', pairs=pairs) data = { @@ -1395,6 +1436,10 @@ class Photo(ObjectBase): @decorators.required_feature('photo.add_remove_tag') @worms.atomic def remove_tags(self, tags) -> None: + ''' + This method removes all PhotoTagRel between this photo and + multiple tags. + ''' tags = [self.photodb.get_tag(name=tag) for tag in tags] log.info('Removing %s from %s.', tags, self) @@ -1551,6 +1596,67 @@ class Photo(ObjectBase): self._tagged_at_dt = helpers.utcfromtimestamp(self.tagged_at_unix) return self._tagged_at_dt +class PhotoTagRel(ObjectBase): + table = 'photo_tag_rel' + + def __init__(self, photodb, db_row): + super().__init__(photodb) + self.photodb = photodb + self.id = db_row['id'] + self.photo_id = db_row['photoid'] + self.photo = photodb.get_photo(self.photo_id) + self.tag_id = db_row['tagid'] + self.tag = photodb.get_tag_by_id(self.tag_id) + self.created_unix = db_row['created'] + self.timestamp = db_row['timestamp'] + + def __hash__(self): + return hash(f'{self.photo_id}.{self.tag_id}') + + def __lt__(self, other): + my_tuple = (self.photo_id, self.tag.name, (self.timestamp or 0)) + other_tuple = (other.photo_id, other.tag.name, (other.timestamp or 0)) + return my_tuple < other_tuple + + @staticmethod + def normalize_timestamp(timestamp) -> float: + if timestamp is None: + return timestamp + + if timestamp == '': + return None + + if isinstance(timestamp, str): + return float(timestamp) + + if isinstance(timestamp, (int, float)): + return timestamp + + else: + raise TypeError(f'timestamp should be {float}, not {type(timestamp)}.') + + @decorators.required_feature('photo.add_remove_tag') + @worms.atomic + def delete(self) -> None: + log.info('Removing %s from %s.', self.tag.name, self.photo.id) + self.photodb.delete(table=PhotoTagRel, pairs={'id': self.id}) + self.deleted = True + + def jsonify(self): + j = { + 'type': 'photo_tag_rel', + 'id': self.id, + 'photo_id': self.photo_id, + 'tag_id': self.tag_id, + 'tag_name': self.tag.name, + 'created': self.created_unix, + 'timestamp': self.timestamp, + } + if self.deleted: + j['deleted'] = True + + return j + class Search: ''' FILE METADATA @@ -1702,11 +1808,16 @@ class Search: results = [ result.jsonify(include_albums=False) if isinstance(result, Photo) else - result.jsonify(minimal=True) + result.jsonify( + include_photos=False, + include_parents=False, + include_children=False, + ) for result in self.results ] j = { + 'type': 'search', 'kwargs': kwargs, 'results': results, 'more_after_limit': self.more_after_limit, @@ -2060,28 +2171,6 @@ class Tag(ObjectBase, GroupableMixin): def _uncache(self): self.photodb.caches[Tag].remove(self.id) - def _add_child(self, member): - ret = super()._add_child(member) - if ret is BAIL: - return BAIL - - # Suppose a photo has tags A and B. Then, B is added as a child of A. - # We should remove A from the photo leaving only the more specific B. - # We must walk all ancestors, not just the immediate parent, because - # the same situation could apply to a photo that has tag A, where A - # already has children B.C.D, and then E is added as a child of D, - # obsoleting A. - # I expect that this method, which calls `search`, will be inefficient - # when used in a large `add_children` loop. I would consider batching - # up all the ancestors and doing it all at once. Just need to make sure - # I don't cause any collateral damage e.g. removing A from a photo that - # only has A because some other photo with A and B thinks A is obsolete. - # This technique is nice and simple to understand for now. - ancestors = list(member.walk_parents()) - photos = self.photodb.search(tag_musts=[member], is_searchhidden=None, yield_photos=True, yield_albums=False) - for photo in photos.results: - photo.remove_tags(ancestors) - @decorators.required_feature('tag.edit') @worms.atomic def add_child(self, member): @@ -2246,21 +2335,26 @@ class Tag(ObjectBase, GroupableMixin): self._cached_synonyms = synonyms return synonyms - def jsonify(self, include_synonyms=False, minimal=False) -> dict: + def jsonify(self, include_synonyms=False, include_parents=True, include_children=True) -> dict: j = { 'type': 'tag', 'id': self.id, 'name': self.name, 'created': self.created_unix, + 'author': self.author.jsonify() if self._author_id else None, + 'description': self.description, } - if not minimal: - j['author'] = self.author.jsonify() if self._author_id else None - j['description'] = self.description + if self.deleted: + j['deleted'] = True + + if include_parents: j['parents'] = [parent.id for parent in self.get_parents()] + + if include_children: j['children'] = [child.id for child in self.get_children()] - if include_synonyms: - j['synonyms'] = list(self.get_synonyms()) + if include_synonyms: + j['synonyms'] = list(self.get_synonyms()) return j @@ -2532,6 +2626,9 @@ class User(ObjectBase): 'created': self.created_unix, 'display_name': self.display_name, } + if self.deleted: + j['deleted'] = True + return j @decorators.required_feature('user.edit') @@ -2573,4 +2670,5 @@ class WarningBag: self.warnings.add(warning) def jsonify(self): - return [getattr(w, 'error_message', str(w)) for w in self.warnings] + j = [getattr(w, 'error_message', str(w)) for w in self.warnings] + return j diff --git a/frontends/etiquette_flask/backend/endpoints/album_endpoints.py b/frontends/etiquette_flask/backend/endpoints/album_endpoints.py index 2b29408..4b65825 100644 --- a/frontends/etiquette_flask/backend/endpoints/album_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/album_endpoints.py @@ -186,7 +186,11 @@ def post_album_edit(album_id): album = common.P_album(album_id, response_type='json') album.edit(title=title, description=description) - response = album.jsonify(minimal=True) + response = album.jsonify( + include_parents=False, + include_children=False, + include_photos=False, + ) return flasktools.json_response(response) @site.route('/album//show_in_folder', methods=['POST']) @@ -257,7 +261,11 @@ def post_albums_create(): if parent_id is not None: parent.add_child(album) - response = album.jsonify(minimal=False) + response = album.jsonify( + include_parents=False, + include_children=False, + include_photos=False, + ) return flasktools.json_response(response) @site.route('/album//delete', methods=['POST']) diff --git a/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py b/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py index e47ceea..738c305 100644 --- a/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/photo_endpoints.py @@ -84,11 +84,6 @@ def get_thumbnail(photo_id, basename=None): headers=outgoing_headers, ) return response - # if photo.thumbnail: - # path = photo.thumbnail - # else: - # flask.abort(404, 'That file doesnt have a thumbnail') - # return common.send_file(path) # Photo create and delete ########################################################################## @@ -104,21 +99,22 @@ def post_photo_delete(photo_id): # Photo tag operations ############################################################################# -def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove): +def post_photo_add_remove_tag_core(photo_ids, tagname, add_or_remove, timestamp=None): if isinstance(photo_ids, str): photo_ids = stringtools.comma_space_split(photo_ids) photos = list(common.P_photos(photo_ids, response_type='json')) tag = common.P_tag(tagname, response_type='json') + response = {'action': add_or_remove, 'tagname': tag.name} with common.P.transaction: for photo in photos: if add_or_remove == 'add': - photo.add_tag(tag) + photo_tag = photo.add_tag(tag, timestamp=timestamp) + response["photo_tag_rel_id"] = photo_tag.id elif add_or_remove == 'remove': photo.remove_tag(tag) - response = {'action': add_or_remove, 'tagname': tag.name} return flasktools.json_response(response) @site.route('/photo//add_tag', methods=['POST']) @@ -128,12 +124,13 @@ def post_photo_add_tag(photo_id): Add a tag to this photo. ''' common.permission_manager.basic() - response = post_photo_add_remove_tag_core( - photo_ids=photo_id, - tagname=request.form['tagname'], - add_or_remove='add', - ) - return response + photo = common.P_photo(photo_id, response_type='json') + tag = common.P_tag(request.form['tagname'], response_type='json') + + with common.P.transaction: + tag_timestamp = request.form.get('timestamp').strip() or None + photo_tag = photo.add_tag(tag, timestamp=tag_timestamp) + return flasktools.json_response(photo_tag.jsonify()) @site.route('/photo//copy_tags', methods=['POST']) @flasktools.required_fields(['other_photo'], forbid_whitespace=True) @@ -146,7 +143,7 @@ def post_photo_copy_tags(photo_id): photo = common.P_photo(photo_id, response_type='json') other = common.P_photo(request.form['other_photo'], response_type='json') photo.copy_tags(other) - return flasktools.json_response([tag.jsonify(minimal=True) for tag in photo.get_tags()]) + return flasktools.json_response([tag.jsonify() for tag in photo.get_tags()]) @site.route('/photo//remove_tag', methods=['POST']) @flasktools.required_fields(['tagname'], forbid_whitespace=True) @@ -162,6 +159,17 @@ def post_photo_remove_tag(photo_id): ) return response +@site.route('/photo_tag_rel//delete', methods=['POST']) +def post_photo_tag_rel_delete(photo_tag_rel_id): + ''' + Remove a tag from a photo. + ''' + common.permission_manager.basic() + with common.P.transaction: + photo_tag = common.P.get_object_by_id(etiquette.objects.PhotoTagRel, photo_tag_rel_id) + photo_tag.delete() + return flasktools.json_response(photo_tag.jsonify()) + @site.route('/batch/photos/add_tag', methods=['POST']) @flasktools.required_fields(['photo_ids', 'tagname'], forbid_whitespace=True) def post_batch_photos_add_tag(): @@ -499,7 +507,7 @@ def get_search_html(): total_tags = set() for result in search.results: if isinstance(result, etiquette.objects.Photo): - total_tags.update(result.get_tags()) + total_tags.update(photo_tag.tag for photo_tag in result.get_tags()) total_tags = sorted(total_tags, key=lambda t: t.name) # PREV-NEXT PAGE URLS diff --git a/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py b/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py index 403edd0..d5ef4ec 100644 --- a/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py +++ b/frontends/etiquette_flask/backend/endpoints/tag_endpoints.py @@ -114,7 +114,7 @@ def post_tag_remove_synonym(tagname): @common.permission_manager.basic_decorator @flasktools.cached_endpoint(max_age=15) def get_all_tag_names(): - all_tags = list(common.P.get_all_tag_names()) + all_tags = list(sorted(common.P.get_all_tag_names())) all_synonyms = common.P.get_all_synonyms() response = {'tags': all_tags, 'synonyms': all_synonyms} return flasktools.json_response(response) diff --git a/frontends/etiquette_flask/static/css/cards.css b/frontends/etiquette_flask/static/css/cards.css index 13bdcac..3f6c547 100644 --- a/frontends/etiquette_flask/static/css/cards.css +++ b/frontends/etiquette_flask/static/css/cards.css @@ -8,7 +8,7 @@ } .album_card_list { - display: grid; + display: inline-grid; grid-template: "title metadata" /1fr; @@ -122,6 +122,7 @@ .photo_card { background-color: var(--color_secondary); + width: max-content; } .photo_card:hover { @@ -301,8 +302,20 @@ /* ########################################################################## */ /* ########################################################################## */ +.photo_tag_card +{ + position: relative; + display: inline; +} + +/* ########################################################################## */ +/* ########################################################################## */ +/* ########################################################################## */ + .tag_card { + position: relative; + display: inline-block; border-radius: 2px; padding-left: 2px; padding-right: 2px; @@ -311,7 +324,11 @@ color: var(--color_tag_card_fg); font-size: 0.9em; - text-decoration: none; font-family: monospace; - line-height: 1.5; +} +.tag_card, +.tag_card a +{ + color: var(--color_tag_card_fg); + text-decoration: none; } diff --git a/frontends/etiquette_flask/static/css/etiquette.css b/frontends/etiquette_flask/static/css/etiquette.css index 89e0918..e0f3b7b 100644 --- a/frontends/etiquette_flask/static/css/etiquette.css +++ b/frontends/etiquette_flask/static/css/etiquette.css @@ -191,12 +191,13 @@ is hovered over. display: none; } +li:hover .remove_tag_button, .tag_card:hover ~ * .remove_tag_button, .tag_card:hover ~ .remove_tag_button, .remove_tag_button:hover, .remove_tag_button_perm:hover { - display:inline; + display: inline; } #message_area diff --git a/frontends/etiquette_flask/static/js/api.js b/frontends/etiquette_flask/static/js/api.js index bcf980b..ff6703c 100644 --- a/frontends/etiquette_flask/static/js/api.js +++ b/frontends/etiquette_flask/static/js/api.js @@ -221,11 +221,11 @@ function edit(bookmark_id, title, b_url, callback) api.photos = {}; api.photos.add_tag = -function add_tag(photo_id, tagname, callback) +function add_tag(photo_id, tagname, timestamp, callback) { return http.post({ url: `/photo/${photo_id}/add_tag`, - data: {"tagname": tagname}, + data: {"tagname": tagname, "timestamp": timestamp}, callback: callback, }); } @@ -416,6 +416,18 @@ function callback_go_to_search(response) window.location.href = "/search"; } +/**************************************************************************************************/ +api.photo_tag_rel = {}; + +api.photo_tag_rel.delete = +function _delete({id, callback=null}) +{ + return http.post({ + url: `/photo_tag_rel/${id}/delete`, + callback: callback, + }); +} + /**************************************************************************************************/ api.tags = {}; diff --git a/frontends/etiquette_flask/static/js/cards.js b/frontends/etiquette_flask/static/js/cards.js index fd2c93c..ad139de 100644 --- a/frontends/etiquette_flask/static/js/cards.js +++ b/frontends/etiquette_flask/static/js/cards.js @@ -3,6 +3,127 @@ const cards = {}; /******************************************************************************/ cards.albums = {}; +cards.albums.create = +function create({ + album, + view="grid", + unlink_parent=null, + draggable=false, +}) +{ + const viewparam = view == "list" ? "?view=list" : ""; + + const album_card = document.createElement("div"); + album_card.classList.add("album_card"); + album_card.classList.add(`album_card_${view}`); + album_card.dataset.id = album == "root" ? "root" : album.id; + album_card.ondragstart = cards.albums.drag_start + album_card.ondragend = cards.albums.drag_end + album_card.ondragover = cards.albums.drag_over + album_card.ondrop = cards.albums.drag_drop + album_card.draggable = draggable && album != "root"; + + const thumbnail_link = document.createElement("a"); + thumbnail_link.classList.add("album_card_thumbnail"); + thumbnail_link.draggable = false; + album_card.append(thumbnail_link); + + const thumbnail_img = document.createElement("img"); + thumbnail_img.loading = "lazy"; + thumbnail_img.draggable = false; + thumbnail_link.append(thumbnail_img); + + let href; + if (album == "root") + { + href = `/albums${viewparam}`; + } + else + { + href = `/album/${album.id}${viewparam}` + } + thumbnail_link.href = href; + + if (album == "root") + { + thumbnail_img.src = "/static/basic_thumbnails/album.png"; + } + else + { + if (album.thumbnail_photo) + { + thumbnail_img.src = `/photo/${album.thumbnail_photo}/thumbnail/${album.thumbnail_photo.id}.jpg`; + } + else + { + thumbnail_img.src = "/static/basic_thumbnails/album.png"; + } + } + + const album_title = document.createElement("a"); + album_card.append(album_title); + album_title.classList.add("album_card_title"); + album_title.draggable = false; + album_title.href = href; + if (album == "root") + { + album_title.innerText = "Albums"; + } + else + { + album_title.innerText = album.display_name; + } + + const album_metadata = document.createElement("div"); + album_metadata.classList.add("album_card_metadata"); + album_card.append(album_metadata); + if (album != "root") + { + const child_count = document.createElement("span"); + child_count.classList.add("album_card_child_count"); + child_count.title = `${album.children_count} child albums`; + child_count.innerText = album.children_count; + album_metadata.append(child_count); + + album_metadata.append(" | "); + + const photo_count = document.createElement("span"); + photo_count.classList.add("album_card_photo_count"); + photo_count.title = `${album.photos_count} photos`; + photo_count.innerText = album.photos_count; + album_metadata.append(photo_count); + } + + const album_tools = document.createElement("div"); + album_tools.classList.add("album_card_tools"); + album_card.append(album_tools); + + if (unlink_parent !== null) + { + const unlink_button = document.createElement("button"); + unlink_button.classList.add("remove_child_button"); + unlink_button.classList.add("red_button"); + + unlink_button.classList.add("button_with_confirm"); + unlink_button.dataset.prompt = "Remove child?"; + unlink_button.dataset.holderClass = "remove_child_button"; + unlink_button.dataset.confirmClass = "red_button"; + unlink_button.dataset.cancelClass = "gray_button"; + unlink_button.innerText = "Unlink"; + + unlink_button.addEventListener("click", function(event) + { + return api.albums.remove_child( + unlink_parent, + album.id, + common.refresh_or_alert + ); + }); + album_tools.append(unlink_button); + } + return album_card; +} + cards.albums.drag_start = function drag_start(event) { @@ -72,7 +193,12 @@ function drag_drop(event) cards.bookmarks = {}; cards.bookmarks.create = -function create(bookmark, add_author, add_delete_button, add_url_element) +function create({ + bookmark, + add_author=false, + add_delete_button=false, + add_url_element=false, +}) { const bookmark_card = document.createElement("div"); bookmark_card.className = "bookmark_card" @@ -119,6 +245,16 @@ function create(bookmark, add_author, add_delete_button, add_url_element) } } + if (add_author && bookmark.author !== null) + { + const p = document.createElement("p"); + const authorlink = document.createElement("a"); + authorlink.href = "/userid/" + bookmark.author.id; + authorlink.innerText = bookmark.author.display_name; + p.append(authorlink); + bookmark_card.append(p); + } + return bookmark_card; } @@ -150,7 +286,7 @@ function file_link(photo, short) } cards.photos.create = -function create(photo, view) +function create({photo, view="grid"}) { if (view !== "list" && view !== "grid") { @@ -230,16 +366,16 @@ function create(photo, view) photo_card.appendChild(photo_card_thumbnail); } - let tag_names_title = []; + let tag_names_title = new Set(); let tag_names_inner = ""; - for (const tag of photo.tags) + for (const photo_tag of photo.tags) { - tag_names_title.push(tag.name); + tag_names_title.add(photo_tag.tag_name); tag_names_inner = "T"; } const photo_card_tags = document.createElement("span"); photo_card_tags.className = "photo_card_tags"; - photo_card_tags.title = tag_names_title.join(","); + photo_card_tags.title = Array.from(tag_names_title).join(","); photo_card_tags.innerText = tag_names_inner; photo_card.appendChild(photo_card_tags); @@ -320,5 +456,74 @@ function photo_rightclick(event) return false; } +/******************************************************************************/ +cards.photo_tags = {}; + +cards.photo_tags.create = +function create({photo_tag, timestamp_onclick=null, remove_button_onclick=null}) +{ + const photo_tag_card = document.createElement("div"); + console.log(photo_tag); + photo_tag_card.dataset.id = photo_tag.id; + photo_tag_card.classList.add("photo_tag_card"); + + const tag = {"id": photo_tag.tag_id, "name": photo_tag.tag_name}; + const tag_card = cards.tags.create({tag: tag}); + photo_tag_card.append(tag_card); + + if (photo_tag.timestamp !== null) + { + const timestamp = document.createElement("a"); + timestamp.innerText = " " + common.seconds_to_hms({seconds: photo_tag.timestamp}); + timestamp.addEventListener("click", timestamp_onclick); + photo_tag_card.append(timestamp) + } + if (remove_button_onclick !== null) + { + const remove_button = document.createElement("button"); + remove_button.classList.add("remove_tag_button"); + remove_button.classList.add("red_button"); + remove_button.addEventListener("click", remove_button_onclick); + photo_tag_card.append(remove_button); + } + return photo_tag_card; +} + /******************************************************************************/ cards.tags = {}; + +cards.tags.create = +function create({tag, extra_classes=[], link="info", innertext=null, add_alt_description=false}) +{ + const tag_card = document.createElement("div"); + tag_card.dataset.id = tag.id; + tag_card.classList.add("tag_card"); + for (const cls of extra_classes) + { + tag_card.classList.add(cls); + } + + const a_or_span = link === null ? "span" : "a"; + const tag_text = document.createElement(a_or_span); + tag_text.innerText = innertext || tag.name; + if (add_alt_description && tag.description != "") + { + tag_text.title = tag.description; + } + tag_card.append(tag_text); + + const href_options = { + "search": `/search?tag_musts=${encodeURIComponent(tag.name)}`, + "search_musts":`/search?tag_musts=${encodeURIComponent(tag.name)}`, + "search_mays": `/search?tag_mays=${encodeURIComponent(tag.name)}`, + "search_forbids": `/search?tag_forbids=${encodeURIComponent(tag.name)}`, + "info": `/tag/${encodeURIComponent(tag.name)}`, + }; + const href = href_options[link] || null; + if (href !== null) + { + tag_text.href = href; + } + + return tag_card; +} diff --git a/frontends/etiquette_flask/static/js/common.js b/frontends/etiquette_flask/static/js/common.js index 86957e0..c7a74cb 100644 --- a/frontends/etiquette_flask/static/js/common.js +++ b/frontends/etiquette_flask/static/js/common.js @@ -88,13 +88,13 @@ function hms_render_colons(hours, minutes, seconds) } common.seconds_to_hms = -function seconds_to_hms(seconds, args) +function seconds_to_hms({ + seconds, + renderer=common.hms_render_colons, + force_minutes=false, + force_hours=false, +}) { - args = args || {}; - const renderer = args["renderer"] || common.hms_render_colons; - const force_minutes = args["force_minutes"] || false; - const force_hours = args["force_hours"] || false; - if (seconds > 0 && seconds < 1) { seconds = 1; diff --git a/frontends/etiquette_flask/templates/bookmarks.html b/frontends/etiquette_flask/templates/bookmarks.html index 875a3d2..e17d109 100644 --- a/frontends/etiquette_flask/templates/bookmarks.html +++ b/frontends/etiquette_flask/templates/bookmarks.html @@ -18,6 +18,7 @@ @@ -37,53 +49,90 @@

User since {{user.created|timestamp_to_naturaldate}}.

- {% set photos = user.get_photos(direction='desc')|islice(0, 15)|list %} - {% if photos %}

Photos by {{user.display_name}}

- {% for photo in photos %} - {{cards.create_photo_card(photo)}} - {% endfor %} +
+
- {% endif %} - {% set tags = user.get_tags(direction='desc')|islice(0, 100)|list %} - {% if tags %}

Tags by {{user.display_name}}

- {% for tag in tags %} - {{cards.create_tag_card(tag, with_alt_description=True)}} - {% endfor %} +
+
- {% endif %} - {% set albums = user.get_albums()|islice(0, 20)|list %} - {% if albums %}

Albums by {{user.display_name}}

- {% for album in albums %} - {{cards.create_album_card(album)}} - {% endfor %} +
+
- {% endif %} - {% set bookmarks = user.get_bookmarks()|islice(0, 50)|list %} - {% if bookmarks %}

Bookmarks by {{user.display_name}}

- {% for bookmark in bookmarks %} - {{cards.create_bookmark_card(bookmark, add_author=False)}} - {% endfor %} +
+
- {% endif %}