checkpoint
add photo and search css for narrow screens; incorporate new expressionmatch kit; entry_with_history cursor moves to end; albums indicate total filesize; etc
This commit is contained in:
		
							parent
							
								
									80cb66b825
								
							
						
					
					
						commit
						c80e2003ff
					
				
					 16 changed files with 266 additions and 743 deletions
				
			
		|  | @ -4,8 +4,8 @@ Etiquette | |||
| This is the readme file. | ||||
| 
 | ||||
| ### To do list | ||||
| - At the moment I don't like the way that warnings and exceptions are so far apart, and need to be updated individually. Consider moving the warning strings to be class properties of the matching exceptions. | ||||
| - User account system, permission levels, private pages. | ||||
| - Bookmark system. Maybe the ability to submit URLs as photo objects. | ||||
| - Generalize the filename expression filter so it can work with any strings. | ||||
| - Improve the "tags on this page" list. Maybe add separate buttons for must/may/forbid on each. | ||||
| - Some way for the database to re-identify a file that was moved / renamed (lost & found). Maybe file hash of the first few mb is good enough. | ||||
|  | @ -21,10 +21,3 @@ This is the readme file. | |||
| - **[removal]** An old feature was removed. | ||||
| 
 | ||||
|   | ||||
| 
 | ||||
| - 2016 11 28 | ||||
|     - **[addition]** Added `etiquette_upgrader.py`. When an update causes the anatomy of the etiquette database to change, I will increment the `phototagger.DATABASE_VERSION` variable, and add a new function to this script that should automatically make all the necessary changes. Until the database is upgraded, phototagger will not start. Don't forget to make backups just in case. | ||||
| 
 | ||||
| - 2016 11 05 | ||||
|     - **[addition]** Added the ability to download an album as a `.tar` file. No compression is used. I still need to do more experiments to make sure this is working perfectly. | ||||
| 
 | ||||
|  |  | |||
|  | @ -105,7 +105,8 @@ ERROR_INVALID_ACTION = 'Invalid action' | |||
| ERROR_NO_SUCH_TAG = 'Doesn\'t exist' | ||||
| ERROR_NO_TAG_GIVEN = 'No tag name supplied' | ||||
| ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself' | ||||
| ERROR_TAG_TOO_SHORT = 'Not enough valid chars' | ||||
| ERROR_TAG_TOO_LONG = '{tag} is too long' | ||||
| ERROR_TAG_TOO_SHORT = '{tag} has too few valid chars' | ||||
| ERROR_RECURSIVE_GROUPING = 'Recursive grouping' | ||||
| WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.' | ||||
| WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.' | ||||
|  |  | |||
|  | @ -11,6 +11,7 @@ from voussoirkit import bytestring | |||
| from voussoirkit import pathclass | ||||
| from voussoirkit import spinal | ||||
| 
 | ||||
| 
 | ||||
| class ObjectBase: | ||||
|     def __eq__(self, other): | ||||
|         return ( | ||||
|  | @ -101,7 +102,8 @@ class GroupableMixin: | |||
|             # Lift children | ||||
|             parent = self.parent() | ||||
|             if parent is None: | ||||
|                 # Since this group was a root, children become roots by removing the row. | ||||
|                 # Since this group was a root, children become roots by removing | ||||
|                 # the row. | ||||
|                 cur.execute('DELETE FROM tag_group_rel WHERE parentid == ?', [self.id]) | ||||
|             else: | ||||
|                 # Since this group was a child, its parent adopts all its children. | ||||
|  | @ -109,7 +111,8 @@ class GroupableMixin: | |||
|                     'UPDATE tag_group_rel SET parentid == ? WHERE parentid == ?', | ||||
|                     [parent.id, self.id] | ||||
|                 ) | ||||
|         # Note that this part comes after the deletion of children to prevent issues of recursion. | ||||
|         # Note that this part comes after the deletion of children to prevent | ||||
|         # issues of recursion. | ||||
|         cur.execute('DELETE FROM tag_group_rel WHERE memberid == ?', [self.id]) | ||||
|         if commit: | ||||
|             self.photodb.log.debug('Committing - delete tag') | ||||
|  | @ -269,6 +272,20 @@ class Album(ObjectBase, GroupableMixin): | |||
|             self.photodb.log.debug('Committing - remove photo from album') | ||||
|             self.photodb.commit() | ||||
| 
 | ||||
|     def sum_bytes(self, recurse=True, string=False): | ||||
|         if recurse: | ||||
|             photos = self.walk_photos() | ||||
|         else: | ||||
|             photos = self.photos() | ||||
| 
 | ||||
|         total = sum(photo.bytes for photo in photos) | ||||
| 
 | ||||
|         if string: | ||||
|             return bytestring.bytestring(total) | ||||
|         else: | ||||
|             return total | ||||
| 
 | ||||
| 
 | ||||
|     def walk_photos(self): | ||||
|         yield from self.photos() | ||||
|         children = self.walk_children() | ||||
|  | @ -642,7 +659,7 @@ class Photo(ObjectBase): | |||
|             new_path = old_path.parent.with_child(new_filename) | ||||
|         else: | ||||
|             new_path = pathclass.Path(new_filename) | ||||
|         new_path.correct_case() | ||||
|         #new_path.correct_case() | ||||
| 
 | ||||
|         self.photodb.log.debug(old_path) | ||||
|         self.photodb.log.debug(new_path) | ||||
|  | @ -655,9 +672,11 @@ class Photo(ObjectBase): | |||
|         os.makedirs(new_path.parent.absolute_path, exist_ok=True) | ||||
| 
 | ||||
|         if new_path != old_path: | ||||
|             # This is different than the absolute == absolute check above, because this normalizes | ||||
|             # the paths. It's possible on case-insensitive systems to have the paths point to the | ||||
|             # same place while being differently cased, thus we couldn't make the intermediate link. | ||||
|             # This is different than the absolute == absolute check above, | ||||
|             # because this normalizes the paths. It's possible on | ||||
|             # case-insensitive systems to have the paths point to the same place | ||||
|             # while being differently cased, thus we couldn't make the | ||||
|             # intermediate link. | ||||
|             try: | ||||
|                 os.link(old_path.absolute_path, new_path.absolute_path) | ||||
|             except OSError: | ||||
|  | @ -671,7 +690,8 @@ class Photo(ObjectBase): | |||
| 
 | ||||
|         if commit: | ||||
|             if new_path == old_path: | ||||
|                 # If they are equivalent but differently cased paths, just rename. | ||||
|                 # If they are equivalent but differently cased paths, just | ||||
|                 # rename. | ||||
|                 os.rename(old_path.absolute_path, new_path.absolute_path) | ||||
|             else: | ||||
|                 # Delete the original hardlink or copy. | ||||
|  | @ -900,6 +920,7 @@ class User(ObjectBase): | |||
|         rep = 'User:{username}'.format(username=self.username) | ||||
|         return rep | ||||
| 
 | ||||
| 
 | ||||
| class WarningBag: | ||||
|     def __init__(self): | ||||
|         self.warnings = set() | ||||
|  |  | |||
|  | @ -16,6 +16,7 @@ from . import helpers | |||
| from . import objects | ||||
| from . import searchhelpers | ||||
| 
 | ||||
| from voussoirkit import expressionmatch | ||||
| from voussoirkit import pathclass | ||||
| from voussoirkit import safeprint | ||||
| from voussoirkit import spinal | ||||
|  | @ -161,76 +162,6 @@ def raise_no_such_thing(exception_class, thing_id=None, thing_name=None, comment | |||
|         message = '' | ||||
|     raise exception_class(message) | ||||
| 
 | ||||
| def searchfilter_expression(photo_tags, expression, frozen_children, token_normalizer, warning_bag=None): | ||||
|     photo_tags = set(tag.name for tag in photo_tags) | ||||
|     operator_stack = collections.deque() | ||||
|     operand_stack = collections.deque() | ||||
| 
 | ||||
|     expression = expression.replace('-', ' ') | ||||
|     expression = expression.strip() | ||||
|     if not expression: | ||||
|         return False | ||||
|     expression = expression.replace('(', ' ( ') | ||||
|     expression = expression.replace(')', ' ) ') | ||||
|     while '  ' in expression: | ||||
|         expression = expression.replace('  ', ' ') | ||||
|     tokens = [token for token in expression.split(' ') if token] | ||||
|     has_operand = False | ||||
|     can_shortcircuit = False | ||||
| 
 | ||||
|     for token in tokens: | ||||
|         #print(token, end=' ', flush=True) | ||||
|         if can_shortcircuit and token != ')': | ||||
|             continue | ||||
| 
 | ||||
|         if token not in constants.EXPRESSION_OPERATORS: | ||||
|             try: | ||||
|                 token = token_normalizer(token) | ||||
|                 value = any(option in photo_tags for option in frozen_children[token]) | ||||
|             except KeyError: | ||||
|                 if warning_bag: | ||||
|                     warning_bag.add(constants.WARNING_NO_SUCH_TAG.format(tag=token)) | ||||
|                 else: | ||||
|                     raise exceptions.NoSuchTag(token) | ||||
|                 return False | ||||
|             operand_stack.append(value) | ||||
|             if has_operand: | ||||
|                 operate(operand_stack, operator_stack) | ||||
|             has_operand = True | ||||
|             continue | ||||
| 
 | ||||
|         if token == '(': | ||||
|             has_operand = False | ||||
| 
 | ||||
|         if token == ')': | ||||
|             if not can_shortcircuit: | ||||
|                 while operator_stack[-1] != '(': | ||||
|                     operate(operand_stack, operator_stack) | ||||
|                 operator_stack.pop() | ||||
|             has_operand = True | ||||
|             continue | ||||
| 
 | ||||
|         can_shortcircuit = ( | ||||
|             has_operand and | ||||
|             ( | ||||
|                 (operand_stack[-1] == 0 and token == 'AND') or | ||||
|                 (operand_stack[-1] == 1 and token == 'OR') | ||||
|             ) | ||||
|         ) | ||||
|         if can_shortcircuit: | ||||
|             if operator_stack and operator_stack[-1] == '(': | ||||
|                 operator_stack.pop() | ||||
|             continue | ||||
| 
 | ||||
|         operator_stack.append(token) | ||||
|         #time.sleep(.3) | ||||
|     #print() | ||||
|     while len(operand_stack) > 1 or len(operator_stack) > 0: | ||||
|         operate(operand_stack, operator_stack) | ||||
|     #print(operand_stack) | ||||
|     success = operand_stack.pop() | ||||
|     return success | ||||
| 
 | ||||
| def searchfilter_must_may_forbid(photo_tags, tag_musts, tag_mays, tag_forbids, frozen_children): | ||||
|     if tag_musts and not all(any(option in photo_tags for option in frozen_children[must]) for must in tag_musts): | ||||
|         #print('Failed musts') | ||||
|  | @ -728,7 +659,16 @@ class PDBPhotoMixin: | |||
|         searchhelpers.minmax('duration', duration, minimums, maximums, warning_bag=warning_bag) | ||||
| 
 | ||||
|         orderby = searchhelpers.normalize_orderby(orderby) | ||||
|         query = searchhelpers.build_query(orderby) | ||||
|         notnulls = [] | ||||
|         if extension or mimetype: | ||||
|             notnulls.append('extension') | ||||
|         if width or height or ratio or area: | ||||
|             notnulls.append('width') | ||||
|         if bytes: | ||||
|             notnulls.append('bytes') | ||||
|         if duration: | ||||
|             notnulls.append('duration') | ||||
|         query = searchhelpers.build_query(orderby, notnulls) | ||||
|         print(query) | ||||
|         generator = helpers.select_generator(self.sql, query) | ||||
| 
 | ||||
|  | @ -774,6 +714,12 @@ class PDBPhotoMixin: | |||
|             else: | ||||
|                 frozen_children = self.export_tags(tag_export_totally_flat) | ||||
|                 self._cached_frozen_children = frozen_children | ||||
| 
 | ||||
|         if tag_expression: | ||||
|             expression_tree = expressionmatch.ExpressionTree.parse(tag_expression) | ||||
|             expression_tree.map(self.normalize_tagname) | ||||
|             expression_matcher = searchhelpers.tag_expression_matcher_builder(frozen_children, warning_bag) | ||||
| 
 | ||||
|         photos_received = 0 | ||||
| 
 | ||||
|         # LET'S GET STARTED | ||||
|  | @ -791,7 +737,7 @@ class PDBPhotoMixin: | |||
|                     (photo.extension in extension_not) | ||||
|                 ) | ||||
|             ) | ||||
|             if (ext_fail): | ||||
|             if ext_fail: | ||||
|                 #print('Failed extension_not') | ||||
|                 continue | ||||
| 
 | ||||
|  | @ -822,7 +768,7 @@ class PDBPhotoMixin: | |||
|                 continue | ||||
| 
 | ||||
|             if (has_tags is not None) or is_tagsearch: | ||||
|                 photo_tags = photo.tags() | ||||
|                 photo_tags = set(photo.tags()) | ||||
| 
 | ||||
|                 if has_tags is False and len(photo_tags) > 0: | ||||
|                     #print('Failed has_tags=False') | ||||
|  | @ -832,15 +778,11 @@ class PDBPhotoMixin: | |||
|                     #print('Failed has_tags=True') | ||||
|                     continue | ||||
| 
 | ||||
|                 photo_tags = set(photo_tags) | ||||
| 
 | ||||
|                 if tag_expression: | ||||
|                     success = searchfilter_expression( | ||||
|                         photo_tags=photo_tags, | ||||
|                         expression=tag_expression, | ||||
|                         frozen_children=frozen_children, | ||||
|                         token_normalizer=self.normalize_tagname, | ||||
|                         warning_bag=warning_bag, | ||||
|                     success = expression_tree.evaluate( | ||||
|                         photo_tags, | ||||
|                         match_function=expression_matcher, | ||||
|                     ) | ||||
|                     if not success: | ||||
|                         #print('Failed tag expression') | ||||
|  | @ -872,7 +814,7 @@ class PDBPhotoMixin: | |||
|             yield warning_bag | ||||
| 
 | ||||
|         end_time = time.time() | ||||
|         print(end_time - start_time) | ||||
|         print('Search results took:', end_time - start_time) | ||||
| 
 | ||||
| 
 | ||||
| class PDBTagMixin: | ||||
|  | @ -954,7 +896,7 @@ class PDBTagMixin: | |||
|         tag = objects.Tag(self, [tagid, tagname]) | ||||
|         return tag | ||||
| 
 | ||||
|     def normalize_tagname(self, tagname): | ||||
|     def normalize_tagname(self, tagname, warning_bag=None): | ||||
|         ''' | ||||
|         Tag names can only consist of characters defined in the config. | ||||
|         The given tagname is lowercased, gets its spaces and hyphens | ||||
|  | @ -968,10 +910,18 @@ class PDBTagMixin: | |||
|         tagname = ''.join(tagname) | ||||
| 
 | ||||
|         if len(tagname) < self.config['min_tag_name_length']: | ||||
|             if warning_bag is not None: | ||||
|                 warning_bag.add(constants.WARNING_TAG_TOO_SHORT.format(tag=tagname)) | ||||
|             else: | ||||
|                 raise exceptions.TagTooShort(tagname) | ||||
|         if len(tagname) > self.config['max_tag_name_length']: | ||||
| 
 | ||||
|         elif len(tagname) > self.config['max_tag_name_length']: | ||||
|             if warning_bag is not None: | ||||
|                 warning_bag.add(constants.WARNING_TAG_TOO_LONG.format(tag=tagname)) | ||||
|             else: | ||||
|                 raise exceptions.TagTooLong(tagname) | ||||
| 
 | ||||
|         else: | ||||
|             return tagname | ||||
| 
 | ||||
| class PDBUserMixin: | ||||
|  | @ -1200,6 +1150,8 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs | |||
|             *, | ||||
|             exclude_directories=None, | ||||
|             exclude_filenames=None, | ||||
|             make_albums=True, | ||||
|             recurse=True, | ||||
|             commit=True, | ||||
|         ): | ||||
|         ''' | ||||
|  | @ -1220,8 +1172,11 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs | |||
|             directory, | ||||
|             exclude_directories=exclude_directories, | ||||
|             exclude_filenames=exclude_filenames, | ||||
|             recurse=recurse, | ||||
|             yield_style='nested', | ||||
|         ) | ||||
| 
 | ||||
|         if make_albums: | ||||
|             try: | ||||
|                 album = self.get_album_by_path(directory.absolute_path) | ||||
|             except exceptions.NoSuchAlbum: | ||||
|  | @ -1230,9 +1185,18 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs | |||
|                     commit=False, | ||||
|                     title=directory.basename, | ||||
|                 ) | ||||
| 
 | ||||
|             albums = {directory.absolute_path: album} | ||||
| 
 | ||||
|         for (current_location, directories, files) in generator: | ||||
|             for filepath in files: | ||||
|                 try: | ||||
|                     photo = self.new_photo(filepath.absolute_path, commit=False) | ||||
|                 except exceptions.PhotoExists as e: | ||||
|                     photo = e.photo | ||||
| 
 | ||||
|             if not make_albums: | ||||
|                 continue | ||||
| 
 | ||||
|             current_album = albums.get(current_location.absolute_path, None) | ||||
|             if current_album is None: | ||||
|                 try: | ||||
|  | @ -1253,17 +1217,16 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs | |||
|                     #safeprint.safeprint('Added to %s' % parent.title) | ||||
|                 except exceptions.GroupExists: | ||||
|                     pass | ||||
|             for filepath in files: | ||||
|                 try: | ||||
|                     photo = self.new_photo(filepath.absolute_path, commit=False) | ||||
|                 except exceptions.PhotoExists as e: | ||||
|                     photo = e.photo | ||||
|                 current_album.add_photo(photo, commit=False) | ||||
| 
 | ||||
|         if commit: | ||||
|             self.log.debug('Committing - digest') | ||||
|             self.commit() | ||||
| 
 | ||||
|         if make_albums: | ||||
|             return album | ||||
|         else: | ||||
|             return None | ||||
| 
 | ||||
|     # def digest_new_files( | ||||
|     #         self, | ||||
|  |  | |||
|  | @ -5,19 +5,28 @@ from . import exceptions | |||
| from . import helpers | ||||
| from . import objects | ||||
| 
 | ||||
| def build_query(orderby): | ||||
| def build_query(orderby, notnulls): | ||||
|     query = 'SELECT * FROM photos' | ||||
| 
 | ||||
|     if orderby: | ||||
|         orderby = [o.split('-') for o in orderby] | ||||
|         orderby_columns = [column for (column, sorter) in orderby if column != 'RANDOM()'] | ||||
|     else: | ||||
|         orderby_columns = [] | ||||
| 
 | ||||
|     if notnulls: | ||||
|         notnulls.extend(orderby_columns) | ||||
|     elif orderby_columns: | ||||
|         notnulls = orderby_columns | ||||
| 
 | ||||
|     if notnulls: | ||||
|         notnulls = [x + ' IS NOT NULL' for x in notnulls] | ||||
|         notnulls = ' AND '.join(notnulls) | ||||
|         query += ' WHERE ' + notnulls | ||||
|     if not orderby: | ||||
|         query += ' ORDER BY created DESC' | ||||
|         return query | ||||
| 
 | ||||
|     orderby = [o.split('-') for o in orderby] | ||||
|     whereable_columns = [column for (column, sorter) in orderby if column != 'RANDOM()'] | ||||
|     if whereable_columns: | ||||
|         query += ' WHERE ' | ||||
|         whereable_columns = [column + ' IS NOT NULL' for column in whereable_columns] | ||||
|         query += ' AND '.join(whereable_columns) | ||||
| 
 | ||||
|     # Combine each column+sorter | ||||
|     orderby = [' '.join(o) for o in orderby] | ||||
| 
 | ||||
|  | @ -207,7 +216,6 @@ def normalize_offset(offset, warning_bag=None): | |||
| 
 | ||||
|     return offset | ||||
| 
 | ||||
| 
 | ||||
| def normalize_orderby(orderby, warning_bag=None): | ||||
|     if not orderby: | ||||
|         return None | ||||
|  | @ -309,3 +317,28 @@ def normalize_tag_mmf(tags, photodb, warning_bag=None): | |||
|         return None | ||||
| 
 | ||||
|     return tagset | ||||
| 
 | ||||
| def tag_expression_matcher_builder(frozen_children, warning_bag=None): | ||||
|     def matcher(photo_tags, tagname): | ||||
|         ''' | ||||
|         Used as the `match_function` for the ExpressionTree evaluation. | ||||
| 
 | ||||
|         photo: | ||||
|             The set of tag names owned by the photo in question. | ||||
|         tagname: | ||||
|             The tag which the ExpressionTree wants it to have. | ||||
|         ''' | ||||
|         if not photo_tags: | ||||
|             return False | ||||
| 
 | ||||
|         try: | ||||
|             options = frozen_children[tagname] | ||||
|         except KeyError: | ||||
|             if warning_bag is not None: | ||||
|                 warning_bag.add(constants.WARNING_NO_SUCH_TAG.format(tag=tagname)) | ||||
|                 return False | ||||
|             else: | ||||
|                 raise exceptions.NoSuchTag(tagname) | ||||
| 
 | ||||
|         return any(option in photo_tags for option in options) | ||||
|     return matcher | ||||
|  |  | |||
|  | @ -711,8 +711,10 @@ def post_edit_tags(): | |||
|         status = 400 | ||||
|         try: | ||||
|             response = method(tag) | ||||
|         except exceptions.TagTooLong: | ||||
|             response = {'error': constants.ERROR_TAG_TOO_LONG.format(tag=tag), 'tagname': tag} | ||||
|         except exceptions.TagTooShort: | ||||
|             response = {'error': constants.ERROR_TAG_TOO_SHORT, 'tagname': tag} | ||||
|             response = {'error': constants.ERROR_TAG_TOO_SHORT.format(tag=tag), 'tagname': tag} | ||||
|         except exceptions.CantSynonymSelf: | ||||
|             response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag} | ||||
|         except exceptions.NoSuchTag as e: | ||||
|  |  | |||
|  | @ -88,13 +88,15 @@ li | |||
|     display: block; | ||||
|     padding: 4px; | ||||
|     margin: 8px; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     justify-content: space-between; | ||||
| } | ||||
| .photo_card_grid | ||||
| { | ||||
|     vertical-align: middle; | ||||
|     position: relative; | ||||
|     display: inline-flex; | ||||
|     flex-direction: column; | ||||
|     box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25); | ||||
|     display: inline-block; | ||||
|     min-width: 150px; | ||||
|     max-width: 300px; | ||||
|     height: 200px; | ||||
|  | @ -102,6 +104,7 @@ li | |||
|     padding: 8px; | ||||
|     margin: 8px; | ||||
|     border-radius: 8px; | ||||
|     vertical-align: middle; | ||||
| } | ||||
| .photo_card_grid_thumb | ||||
| { | ||||
|  | @ -131,35 +134,32 @@ li | |||
| } | ||||
| .photo_card_grid_info | ||||
| { | ||||
|     position: absolute; | ||||
|     top: 160px; | ||||
|     bottom: 0px; | ||||
|     left: 8px; | ||||
|     right: 8px; | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
|     justify-content: space-between; | ||||
|     flex-direction: column; | ||||
|     font-size: 0.8em; | ||||
| } | ||||
| .photo_card_grid_file_metadata | ||||
| { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     right: 0; | ||||
|     display: flex; | ||||
|     justify-content: space-between; | ||||
| } | ||||
| .photo_card_grid_filename | ||||
| { | ||||
|     position: absolute; | ||||
|     /*position: absolute;*/ | ||||
|     max-height: 30px; | ||||
|     overflow: hidden; | ||||
|     align-self: flex-start; | ||||
|     word-break:break-word; | ||||
| } | ||||
| .photo_card_grid_filename:hover | ||||
| { | ||||
|     max-height: 100%; | ||||
|     overflow: visible; | ||||
| } | ||||
| .photo_card_grid_tags | ||||
| { | ||||
|     position: absolute; | ||||
|     bottom: 0; | ||||
|     left: 0; | ||||
|     align-self: flex-start; | ||||
| } | ||||
| .tag_object | ||||
| { | ||||
|  |  | |||
|  | @ -81,6 +81,7 @@ function entry_with_history_hook(box, button) | |||
|             box.entry_history_pos -= 1; | ||||
|         } | ||||
|         box.value = box.entry_history[box.entry_history_pos]; | ||||
|         setTimeout(function(){box.selectionStart = box.value.length;}, 0); | ||||
|     } | ||||
|     else if (event.keyCode == 27) | ||||
|     { | ||||
|  |  | |||
|  | @ -1,403 +0,0 @@ | |||
| /** | ||||
|  * complete.ly 1.0.0 | ||||
|  * MIT Licensing | ||||
|  * Copyright (c) 2013 Lorenzo Puccetti | ||||
|  *  | ||||
|  * This Software shall be used for doing good things, not bad things. | ||||
|  *  | ||||
| **/   | ||||
| function completely(container, config) { | ||||
|     config = config || {}; | ||||
|     config.fontSize =                       config.fontSize   || '16px'; | ||||
|     config.fontFamily =                     config.fontFamily || 'sans-serif'; | ||||
|     config.promptInnerHTML =                config.promptInnerHTML || '';  | ||||
|     config.color =                          config.color || '#333'; | ||||
|     config.hintColor =                      config.hintColor || '#aaa'; | ||||
|     config.backgroundColor =                config.backgroundColor || '#fff'; | ||||
|     config.dropDownBorderColor =            config.dropDownBorderColor || '#aaa'; | ||||
|     config.dropDownZIndex =                 config.dropDownZIndex || '100'; // to ensure we are in front of everybody
 | ||||
|     config.dropDownOnHoverBackgroundColor = config.dropDownOnHoverBackgroundColor || '#ddd'; | ||||
|      | ||||
|     var txtInput = document.createElement('input'); | ||||
|     txtInput.type ='text'; | ||||
|     txtInput.spellcheck = false;  | ||||
|     txtInput.style.fontSize =        config.fontSize; | ||||
|     txtInput.style.fontFamily =      config.fontFamily; | ||||
|     txtInput.style.color =           config.color; | ||||
|     txtInput.style.backgroundColor = config.backgroundColor; | ||||
|     txtInput.style.width = '100%'; | ||||
|     txtInput.style.outline = '0'; | ||||
|     txtInput.style.border =  '0'; | ||||
|     txtInput.style.margin =  '0'; | ||||
|     txtInput.style.padding = '0'; | ||||
|      | ||||
|     var txtHint = txtInput.cloneNode();  | ||||
|     txtHint.disabled='';         | ||||
|     txtHint.style.position = 'absolute'; | ||||
|     txtHint.style.top =  '0'; | ||||
|     txtHint.style.left = '0'; | ||||
|     txtHint.style.borderColor = 'transparent'; | ||||
|     txtHint.style.boxShadow =   'none'; | ||||
|     txtHint.style.color = config.hintColor; | ||||
|      | ||||
|     txtInput.style.backgroundColor ='transparent'; | ||||
|     txtInput.style.verticalAlign = 'top'; | ||||
|     txtInput.style.position = 'relative'; | ||||
|      | ||||
|     var wrapper = document.createElement('div'); | ||||
|     wrapper.style.position = 'relative'; | ||||
|     wrapper.style.outline = '0'; | ||||
|     wrapper.style.border =  '0'; | ||||
|     wrapper.style.margin =  '0'; | ||||
|     wrapper.style.padding = '0'; | ||||
|      | ||||
|     var prompt = document.createElement('div'); | ||||
|     prompt.style.position = 'absolute'; | ||||
|     prompt.style.outline = '0'; | ||||
|     prompt.style.margin =  '0'; | ||||
|     prompt.style.padding = '0'; | ||||
|     prompt.style.border =  '0'; | ||||
|     prompt.style.fontSize =   config.fontSize; | ||||
|     prompt.style.fontFamily = config.fontFamily; | ||||
|     prompt.style.color =           config.color; | ||||
|     prompt.style.backgroundColor = config.backgroundColor; | ||||
|     prompt.style.top = '0'; | ||||
|     prompt.style.left = '0'; | ||||
|     prompt.style.overflow = 'hidden'; | ||||
|     prompt.innerHTML = config.promptInnerHTML; | ||||
|     prompt.style.background = 'transparent'; | ||||
|     if (document.body === undefined) { | ||||
|         throw 'document.body is undefined. The library was wired up incorrectly.'; | ||||
|     } | ||||
|     document.body.appendChild(prompt);             | ||||
|     var w = prompt.getBoundingClientRect().right; // works out the width of the prompt.
 | ||||
|     wrapper.appendChild(prompt); | ||||
|     prompt.style.visibility = 'visible'; | ||||
|     prompt.style.left = '-'+w+'px'; | ||||
|     wrapper.style.marginLeft= w+'px'; | ||||
|      | ||||
|     wrapper.appendChild(txtHint); | ||||
|     wrapper.appendChild(txtInput); | ||||
|      | ||||
|     var dropDown = document.createElement('div'); | ||||
|     dropDown.style.position = 'absolute'; | ||||
|     dropDown.style.visibility = 'hidden'; | ||||
|     dropDown.style.outline = '0'; | ||||
|     dropDown.style.margin =  '0'; | ||||
|     dropDown.style.padding = '0';   | ||||
|     dropDown.style.textAlign = 'left'; | ||||
|     dropDown.style.fontSize =   config.fontSize;       | ||||
|     dropDown.style.fontFamily = config.fontFamily; | ||||
|     dropDown.style.backgroundColor = config.backgroundColor; | ||||
|     dropDown.style.zIndex = config.dropDownZIndex;  | ||||
|     dropDown.style.cursor = 'default'; | ||||
|     dropDown.style.borderStyle = 'solid'; | ||||
|     dropDown.style.borderWidth = '1px'; | ||||
|     dropDown.style.borderColor = config.dropDownBorderColor; | ||||
|     dropDown.style.overflowX= 'hidden'; | ||||
|     dropDown.style.whiteSpace = 'pre'; | ||||
|     dropDown.style.overflowY = 'scroll';  // note: this might be ugly when the scrollbar is not required. however in this way the width of the dropDown takes into account
 | ||||
|      | ||||
|      | ||||
|     var createDropDownController = function(elem) { | ||||
|         var rows = []; | ||||
|         var ix = 0; | ||||
|         var oldIndex = -1; | ||||
|          | ||||
|         var onMouseOver =  function() { this.style.outline = '1px solid #ddd'; } | ||||
|         var onMouseOut =   function() { this.style.outline = '0'; } | ||||
|         var onMouseDown =  function() { p.hide(); p.onmouseselection(this.__hint); } | ||||
|          | ||||
|         var p = { | ||||
|             hide :  function() { elem.style.visibility = 'hidden'; },  | ||||
|             refresh : function(token, array) { | ||||
|                 elem.style.visibility = 'hidden'; | ||||
|                 ix = 0; | ||||
|                 elem.innerHTML =''; | ||||
|                 var vph = (window.innerHeight || document.documentElement.clientHeight); | ||||
|                 var rect = elem.parentNode.getBoundingClientRect(); | ||||
|                 var distanceToTop = rect.top - 6;                        // heuristic give 6px 
 | ||||
|                 var distanceToBottom = vph - rect.bottom -6;  // distance from the browser border.
 | ||||
|                  | ||||
|                 rows = []; | ||||
|                 for (var i=0;i<array.length;i++) { | ||||
|                     if (array[i].indexOf(token)!==0) { continue; } | ||||
|                     var divRow =document.createElement('div'); | ||||
|                     divRow.style.color = config.color; | ||||
|                     divRow.onmouseover = onMouseOver;  | ||||
|                     divRow.onmouseout =  onMouseOut; | ||||
|                     divRow.onmousedown = onMouseDown;  | ||||
|                     divRow.__hint =    array[i]; | ||||
|                     divRow.innerHTML = token+'<b>'+array[i].substring(token.length)+'</b>'; | ||||
|                     rows.push(divRow); | ||||
|                     elem.appendChild(divRow); | ||||
|                 } | ||||
|                 if (rows.length===0) { | ||||
|                     return; // nothing to show.
 | ||||
|                 } | ||||
|                 if (rows.length===1 && token === rows[0].__hint) { | ||||
|                     return; // do not show the dropDown if it has only one element which matches what we have just displayed.
 | ||||
|                 } | ||||
|                  | ||||
|                 if (rows.length<2) return;  | ||||
|                 p.highlight(0); | ||||
|                  | ||||
|                 if (distanceToTop > distanceToBottom*3) {        // Heuristic (only when the distance to the to top is 4 times more than distance to the bottom
 | ||||
|                     elem.style.maxHeight =  distanceToTop+'px';  // we display the dropDown on the top of the input text
 | ||||
|                     elem.style.top =''; | ||||
|                     elem.style.bottom ='100%'; | ||||
|                 } else { | ||||
|                     elem.style.top = '100%';   | ||||
|                     elem.style.bottom = ''; | ||||
|                     elem.style.maxHeight =  distanceToBottom+'px'; | ||||
|                 } | ||||
|                 elem.style.visibility = 'visible'; | ||||
|             }, | ||||
|             highlight : function(index) { | ||||
|                 if (oldIndex !=-1 && rows[oldIndex]) {  | ||||
|                     rows[oldIndex].style.backgroundColor = config.backgroundColor; | ||||
|                 } | ||||
|                 rows[index].style.backgroundColor = config.dropDownOnHoverBackgroundColor; // <-- should be config
 | ||||
|                 oldIndex = index; | ||||
|             }, | ||||
|             move : function(step) { // moves the selection either up or down (unless it's not possible) step is either +1 or -1.
 | ||||
|                 if (elem.style.visibility === 'hidden')             return ''; // nothing to move if there is no dropDown. (this happens if the user hits escape and then down or up)
 | ||||
|                 if (ix+step === -1 || ix+step === rows.length) return rows[ix].__hint; // NO CIRCULAR SCROLLING. 
 | ||||
|                 ix+=step;  | ||||
|                 p.highlight(ix); | ||||
|                 return rows[ix].__hint;//txtShadow.value = uRows[uIndex].__hint ;
 | ||||
|             }, | ||||
|             onmouseselection : function() {} // it will be overwritten. 
 | ||||
|         }; | ||||
|         return p; | ||||
|     } | ||||
|      | ||||
|     var dropDownController = createDropDownController(dropDown); | ||||
|      | ||||
|     dropDownController.onmouseselection = function(text) { | ||||
|         txtInput.value = txtHint.value = leftSide+text;  | ||||
|         rs.onChange(txtInput.value); // <-- forcing it.
 | ||||
|         registerOnTextChangeOldValue = txtInput.value; // <-- ensure that mouse down will not show the dropDown now.
 | ||||
|         setTimeout(function() { txtInput.focus(); },0);  // <-- I need to do this for IE 
 | ||||
|     } | ||||
|      | ||||
|     wrapper.appendChild(dropDown); | ||||
|     container.appendChild(wrapper); | ||||
|      | ||||
|     var spacer;  | ||||
|     var leftSide; // <-- it will contain the leftSide part of the textfield (the bit that was already autocompleted)
 | ||||
|      | ||||
|      | ||||
|     function calculateWidthForText(text) { | ||||
|         if (spacer === undefined) { // on first call only.
 | ||||
|             spacer = document.createElement('span');  | ||||
|             spacer.style.visibility = 'hidden'; | ||||
|             spacer.style.position = 'fixed'; | ||||
|             spacer.style.outline = '0'; | ||||
|             spacer.style.margin =  '0'; | ||||
|             spacer.style.padding = '0'; | ||||
|             spacer.style.border =  '0'; | ||||
|             spacer.style.left = '0'; | ||||
|             spacer.style.whiteSpace = 'pre'; | ||||
|             spacer.style.fontSize =   config.fontSize; | ||||
|             spacer.style.fontFamily = config.fontFamily; | ||||
|             spacer.style.fontWeight = 'normal'; | ||||
|             document.body.appendChild(spacer);     | ||||
|         }         | ||||
|          | ||||
|         // Used to encode an HTML string into a plain text.
 | ||||
|         // taken from http://stackoverflow.com/questions/1219860/javascript-jquery-html-encoding
 | ||||
|         spacer.innerHTML = String(text).replace(/&/g, '&') | ||||
|                                        .replace(/"/g, '"') | ||||
|                                        .replace(/'/g, ''') | ||||
|                                        .replace(/</g, '<') | ||||
|                                        .replace(/>/g, '>'); | ||||
|         return spacer.getBoundingClientRect().right; | ||||
|     } | ||||
|      | ||||
|      | ||||
|     var rs = {  | ||||
|         onArrowDown : function() {},               // defaults to no action.
 | ||||
|         onArrowUp :   function() {},               // defaults to no action.
 | ||||
|         onEnter :     function() {},               // defaults to no action.
 | ||||
|         onTab :       function() {},               // defaults to no action.
 | ||||
|         onChange:     function() { rs.repaint() }, // defaults to repainting.
 | ||||
|         startFrom:    0, | ||||
|         options:      [], | ||||
|         wrapper : wrapper,      // Only to allow  easy access to the HTML elements to the final user (possibly for minor customizations)
 | ||||
|         input :  txtInput,      // Only to allow  easy access to the HTML elements to the final user (possibly for minor customizations) 
 | ||||
|         hint  :  txtHint,       // Only to allow  easy access to the HTML elements to the final user (possibly for minor customizations)
 | ||||
|         dropDown :  dropDown,         // Only to allow  easy access to the HTML elements to the final user (possibly for minor customizations)
 | ||||
|         prompt : prompt, | ||||
|         setText : function(text) { | ||||
|             txtHint.value = text; | ||||
|             txtInput.value = text;  | ||||
|         }, | ||||
|         getText : function() { | ||||
|         	return txtInput.value;  | ||||
|         }, | ||||
|         hideDropDown : function() { | ||||
|         	dropDownController.hide(); | ||||
|         }, | ||||
|         repaint : function() { | ||||
|             var text = txtInput.value; | ||||
|             var startFrom =  rs.startFrom;  | ||||
|             var options =    rs.options; | ||||
|             var optionsLength = options.length;  | ||||
|              | ||||
|             // breaking text in leftSide and token.
 | ||||
|             var token = text.substring(startFrom); | ||||
|             leftSide =  text.substring(0,startFrom); | ||||
|              | ||||
|             // updating the hint. 
 | ||||
|             txtHint.value =''; | ||||
|             for (var i=0;i<optionsLength;i++) { | ||||
|                 var opt = options[i]; | ||||
|                 if (opt.indexOf(token)===0) {         // <-- how about upperCase vs. lowercase
 | ||||
|                     txtHint.value = leftSide +opt; | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|              | ||||
|             // moving the dropDown and refreshing it.
 | ||||
|             dropDown.style.left = calculateWidthForText(leftSide)+'px'; | ||||
|             dropDownController.refresh(token, rs.options); | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|     var registerOnTextChangeOldValue; | ||||
| 
 | ||||
|     /** | ||||
|      * Register a callback function to detect changes to the content of the input-type-text. | ||||
|      * Those changes are typically followed by user's action: a key-stroke event but sometimes it might be a mouse click. | ||||
|     **/ | ||||
|     var registerOnTextChange = function(txt, callback) { | ||||
|         registerOnTextChangeOldValue = txt.value; | ||||
|         var handler = function() { | ||||
|             var value = txt.value; | ||||
|             if (registerOnTextChangeOldValue !== value) { | ||||
|                 registerOnTextChangeOldValue = value; | ||||
|                 callback(value); | ||||
|             } | ||||
|         }; | ||||
| 
 | ||||
|         //  
 | ||||
|         // For user's actions, we listen to both input events and key up events
 | ||||
|         // It appears that input events are not enough so we defensively listen to key up events too.
 | ||||
|         // source: http://help.dottoro.com/ljhxklln.php
 | ||||
|         //
 | ||||
|         // The cost of listening to three sources should be negligible as the handler will invoke callback function
 | ||||
|         // only if the text.value was effectively changed. 
 | ||||
|         //  
 | ||||
|         // 
 | ||||
|         if (txt.addEventListener) { | ||||
|             txt.addEventListener("input",  handler, false); | ||||
|             txt.addEventListener('keyup',  handler, false); | ||||
|             txt.addEventListener('change', handler, false); | ||||
|         } else { // is this a fair assumption: that attachEvent will exist ?
 | ||||
|             txt.attachEvent('oninput', handler); // IE<9
 | ||||
|             txt.attachEvent('onkeyup', handler); // IE<9
 | ||||
|             txt.attachEvent('onchange',handler); // IE<9
 | ||||
|         } | ||||
|     }; | ||||
|      | ||||
|      | ||||
|     registerOnTextChange(txtInput,function(text) { // note the function needs to be wrapped as API-users will define their onChange
 | ||||
|         rs.onChange(text); | ||||
|     }); | ||||
|      | ||||
|      | ||||
|     var keyDownHandler = function(e) { | ||||
|         e = e || window.event; | ||||
|         var keyCode = e.keyCode; | ||||
|          | ||||
|         if (keyCode == 33) { return; } // page up (do nothing)
 | ||||
|         if (keyCode == 34) { return; } // page down (do nothing);
 | ||||
|          | ||||
|         if (keyCode == 27) { //escape
 | ||||
|             dropDownController.hide(); | ||||
|             txtHint.value = txtInput.value; // ensure that no hint is left.
 | ||||
|             txtInput.focus();  | ||||
|             return;  | ||||
|         } | ||||
|          | ||||
|         if (keyCode == 39 || keyCode == 35 || keyCode == 9) { // right,  end, tab  (autocomplete triggered)
 | ||||
|         	if (keyCode == 9) { // for tabs we need to ensure that we override the default behaviour: move to the next focusable HTML-element 
 | ||||
|            	    e.preventDefault(); | ||||
|                 e.stopPropagation(); | ||||
|                 if (txtHint.value.length == 0) { | ||||
|                 	rs.onTab(); // tab was called with no action.
 | ||||
|                 	            // users might want to re-enable its default behaviour or handle the call somehow.
 | ||||
|                 } | ||||
|             } | ||||
|             if (txtHint.value.length > 0) { // if there is a hint
 | ||||
|                 dropDownController.hide(); | ||||
|                 txtInput.value = txtHint.value; | ||||
|                 var hasTextChanged = registerOnTextChangeOldValue != txtInput.value | ||||
|                 registerOnTextChangeOldValue = txtInput.value; // <-- to avoid dropDown to appear again. 
 | ||||
|                                                           // for example imagine the array contains the following words: bee, beef, beetroot
 | ||||
|                                                           // user has hit enter to get 'bee' it would be prompted with the dropDown again (as beef and beetroot also match)
 | ||||
|                 if (hasTextChanged) { | ||||
|                     rs.onChange(txtInput.value); // <-- forcing it.
 | ||||
|                 } | ||||
|             } | ||||
|             return;  | ||||
|         } | ||||
|          | ||||
|         if (keyCode == 13) {       // enter  (autocomplete triggered)
 | ||||
|             if (txtHint.value.length == 0) { // if there is a hint
 | ||||
|                 rs.onEnter(); | ||||
|             } else { | ||||
|                 var wasDropDownHidden = (dropDown.style.visibility == 'hidden'); | ||||
|                 dropDownController.hide(); | ||||
|                  | ||||
|                 if (wasDropDownHidden) { | ||||
|                     txtHint.value = txtInput.value; // ensure that no hint is left.
 | ||||
|                     txtInput.focus(); | ||||
|                     rs.onEnter();     | ||||
|                     return;  | ||||
|                 } | ||||
|                  | ||||
|                 txtInput.value = txtHint.value; | ||||
|                 var hasTextChanged = registerOnTextChangeOldValue != txtInput.value | ||||
|                 registerOnTextChangeOldValue = txtInput.value; // <-- to avoid dropDown to appear again. 
 | ||||
|                                                           // for example imagine the array contains the following words: bee, beef, beetroot
 | ||||
|                                                           // user has hit enter to get 'bee' it would be prompted with the dropDown again (as beef and beetroot also match)
 | ||||
|                 if (hasTextChanged) { | ||||
|                     rs.onChange(txtInput.value); // <-- forcing it.
 | ||||
|                 } | ||||
|                  | ||||
|             } | ||||
|             return;  | ||||
|         } | ||||
|          | ||||
|         if (keyCode == 40) {     // down
 | ||||
|             var m = dropDownController.move(+1); | ||||
|             if (m == '') { rs.onArrowDown(); } | ||||
|             txtHint.value = leftSide+m; | ||||
|             return;  | ||||
|         }  | ||||
|              | ||||
|         if (keyCode == 38 ) {    // up
 | ||||
|             var m = dropDownController.move(-1); | ||||
|             if (m == '') { rs.onArrowUp(); } | ||||
|             txtHint.value = leftSide+m; | ||||
|             e.preventDefault(); | ||||
|             e.stopPropagation(); | ||||
|             return;  | ||||
|         } | ||||
|              | ||||
|         // it's important to reset the txtHint on key down.
 | ||||
|         // think: user presses a letter (e.g. 'x') and never releases... you get (xxxxxxxxxxxxxxxxx)
 | ||||
|         // and you would see still the hint
 | ||||
|         txtHint.value =''; // resets the txtHint. (it might be updated onKeyUp)
 | ||||
|          | ||||
|     }; | ||||
|      | ||||
|     if (txtInput.addEventListener) { | ||||
|         txtInput.addEventListener("keydown",  keyDownHandler, false); | ||||
|     } else { // is this a fair assumption: that attachEvent will exist ?
 | ||||
|         txtInput.attachEvent('onkeydown', keyDownHandler); // IE<9
 | ||||
|     } | ||||
|     return rs; | ||||
| } | ||||
|  | @ -50,8 +50,16 @@ p | |||
|     {% set photos = album.photos() %} | ||||
|     <span> | ||||
|         Download: | ||||
|         {% if photos %}<a href="/album/{{album.id}}.zip?recursive=no">These files</a>{% endif %} | ||||
|         {% if sub_albums %}<a href="/album/{{album.id}}.zip?recursive=yes">Include children</a>{% endif %} | ||||
|         {% if photos %} | ||||
|             <a href="/album/{{album.id}}.zip?recursive=no"> | ||||
|                 These files ({{album.sum_bytes(recurse=False, string=True)}}) | ||||
|             </a> | ||||
|         {% endif %} | ||||
|         {% if sub_albums %} | ||||
|             <a href="/album/{{album.id}}.zip?recursive=yes"> | ||||
|                 Include children ({{album.sum_bytes(recurse=True, string=True)}}) | ||||
|             </a> | ||||
|         {% endif %} | ||||
|     </span> | ||||
|     {% if photos %} | ||||
|     <h3>Photos</h3> | ||||
|  |  | |||
|  | @ -22,7 +22,9 @@ | |||
|     flex-direction: column; | ||||
|     padding: 8px; | ||||
|     margin: 8px; | ||||
|     align-items: baseline; | ||||
|     border-radius: 8px; | ||||
|     box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25); | ||||
| } | ||||
| .bookmark_card .bookmark_url | ||||
| { | ||||
|  |  | |||
|  | @ -1,162 +0,0 @@ | |||
| <!DOCTYPE html5> | ||||
| <html> | ||||
| <style> | ||||
| #left | ||||
| { | ||||
|     display: flex; | ||||
|     flex-direction: column; | ||||
|     justify-content: flex-start; | ||||
|     background-color: rgba(0, 0, 0, 0.1); | ||||
|     width: 300px; | ||||
| } | ||||
| #right | ||||
| { | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
| } | ||||
| #editor_area | ||||
| { | ||||
|     flex: 3; | ||||
| } | ||||
| #message_area | ||||
| { | ||||
|     overflow-y: auto; | ||||
|     flex: 1; | ||||
|     background-color: rgba(0, 0, 0, 0.1); | ||||
| } | ||||
| .photo_object | ||||
| { | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
| } | ||||
| .photo_object img | ||||
| { | ||||
|     display: flex; | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     height: auto; | ||||
| } | ||||
| .photo_object audio | ||||
| { | ||||
|     width: 100%; | ||||
| } | ||||
| .photo_object video | ||||
| { | ||||
|     max-width: 100%; | ||||
|     max-height: 100%; | ||||
|     width: 100%; | ||||
| } | ||||
| </style> | ||||
| <head> | ||||
|     {% import "header.html" as header %} | ||||
|     <title>Photo</title> | ||||
|     <meta charset="UTF-8"> | ||||
|     <link rel="stylesheet" href="/static/common.css"> | ||||
|     <script src="/static/common.js"></script> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
| {{header.make_header()}} | ||||
| <div id="content_body"> | ||||
| <div id="left"> | ||||
|     <div id="editor_area"> | ||||
|         <!-- TAG INFO --> | ||||
|         <h4><a href="/tags">Tags</a></h4> | ||||
|         <ul id="this_tags"> | ||||
|             {% for tag in tags %} | ||||
|             <li> | ||||
|                 <a class="tag_object" href="/search?tag_musts={{tag.name}}">{{tag.qualified_name()}}</a> | ||||
|                 <button class="remove_tag_button" onclick="remove_photo_tag('{{photo.id}}', '{{tag.name}}', receive_callback);"></button> | ||||
|             </li> | ||||
|             {% endfor %} | ||||
|             <li> | ||||
|                 <input id="add_tag_textbox" type="text" autofocus> | ||||
|                 <button id="add_tag_button" class="add_tag_button" onclick="submit_tag(receive_callback);">add</button> | ||||
|             </li> | ||||
|         </ul> | ||||
| 
 | ||||
|         <!-- METADATA & DOWNLOAD --> | ||||
|         <h4>File info</h4> | ||||
|         <ul id="metadata"> | ||||
|         {% if photo.width %} | ||||
|             <li>{{photo.width}}x{{photo.height}} px</li> | ||||
|             <li>{{photo.ratio}} aspect ratio</li> | ||||
|             <li>{{photo.bytestring()}}</li> | ||||
|         {% endif %} | ||||
|             <li><a href="/file/{{photo.id}}.{{photo.extension}}?download=1">Download as {{photo.id}}.{{photo.extension}}</a></li> | ||||
|             <li><a href="/file/{{photo.id}}.{{photo.extension}}?download=1&original_filename=1">Download as "{{photo.basename}}"</a></li> | ||||
|         </ul> | ||||
| 
 | ||||
|         <!-- CONTAINING ALBUMS --> | ||||
|         {% set albums=photo.albums() %} | ||||
|         {% if albums %} | ||||
|         <h4>Albums containing this photo</h4> | ||||
|         <ul id="containing albums"> | ||||
|             {% for album in albums %} | ||||
|             <li><a href="/album/{{album.id}}">{{album.title}}</a></li> | ||||
|             {% endfor %} | ||||
|         {% endif %} | ||||
|         </ul> | ||||
|     </div> | ||||
|     <div id="message_area"> | ||||
|     </div> | ||||
| </div> | ||||
| 
 | ||||
| <div id="right"> | ||||
|     <div class="photo_object"> | ||||
|         {% set filename = photo.id + "." + photo.extension %} | ||||
|         {% set link = "/file/" + filename %} | ||||
|         {% set mimetype=photo.mimetype() %} | ||||
|         {% if mimetype == "image" %} | ||||
|         <img src="{{link}}"> | ||||
|         {% elif mimetype == "video" %} | ||||
|         <video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video> | ||||
|         {% elif mimetype == "audio" %} | ||||
|         <audio src="{{link}}" controls></audio> | ||||
|         {% else %} | ||||
|         <a href="{{link}}">View {{filename}}</a> | ||||
|         {% endif %} | ||||
|     </div> | ||||
| </div> | ||||
| </div> | ||||
| </body> | ||||
| </html> | ||||
| 
 | ||||
| <script type="text/javascript"> | ||||
| var box = document.getElementById('add_tag_textbox'); | ||||
| var button = document.getElementById('add_tag_button'); | ||||
| var message_area = document.getElementById('message_area'); | ||||
| bind_box_to_button(box, button); | ||||
| 
 | ||||
| function receive_callback(response) | ||||
| { | ||||
|     var tagname = response["tagname"]; | ||||
|     if ("error" in response) | ||||
|     { | ||||
|         message_positivity = "callback_message_negative"; | ||||
|         message_text = '"' + tagname + '" ' + response["error"]; | ||||
|     } | ||||
|     else if ("action" in response) | ||||
|     { | ||||
|         var action = response["action"]; | ||||
|         message_positivity = "callback_message_positive"; | ||||
|         if (action == "add_tag") | ||||
|         { | ||||
|             message_text = "Added tag " + tagname; | ||||
|         } | ||||
|         else if (action == "remove_tag") | ||||
|         { | ||||
|             message_text = "Removed tag " + tagname; | ||||
|         } | ||||
|     } | ||||
|     create_message_bubble(message_positivity, message_text, 8000); | ||||
| } | ||||
| function submit_tag(callback) | ||||
| { | ||||
|     add_photo_tag('{{photo.id}}', box.value, callback); | ||||
|     box.value=''; | ||||
| } | ||||
| </script> | ||||
|  | @ -13,9 +13,10 @@ | |||
| #content_body | ||||
| { | ||||
|     /* Override common.css */ | ||||
|     flex-direction: row; | ||||
|     flex: 1; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
|     /*height: 100%; | ||||
|     width: 100%;*/ | ||||
| } | ||||
| #left | ||||
| { | ||||
|  | @ -24,13 +25,41 @@ | |||
|     flex-direction: column; | ||||
|     justify-content: flex-start; | ||||
|     background-color: rgba(0, 0, 0, 0.1); | ||||
|     width: 300px; | ||||
|     max-width: 300px; | ||||
| } | ||||
| #right | ||||
| { | ||||
|     display: flex; | ||||
|     flex: 1; | ||||
|     height: 100%; | ||||
|     display: flex; | ||||
| } | ||||
| @media screen and (max-width: 800px) | ||||
| { | ||||
|     #content_body | ||||
|     { | ||||
|         /* | ||||
|         When flexing, it tries to contain itself entirely in the screen, | ||||
|         forcing #left and #right to squish together. | ||||
|         */ | ||||
|         flex: none; | ||||
|         flex-direction: column-reverse; | ||||
|     } | ||||
|     #left | ||||
|     { | ||||
|         /* | ||||
|         Display: None will be overridden as soon as the page detects that the | ||||
|         screen is in narrow mode and turns off the tag box's autofocus | ||||
|         */ | ||||
|         display: none; | ||||
|         flex-direction: row; | ||||
|         width: initial; | ||||
|         max-width: none; | ||||
|         margin-top: 8px; | ||||
|     } | ||||
|     #right | ||||
|     { | ||||
|         flex: none; | ||||
|         height: calc(100% - 20px); | ||||
|     } | ||||
| } | ||||
| #editor_area | ||||
| { | ||||
|  | @ -49,8 +78,8 @@ | |||
|     flex-direction: column; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     height: 100%; | ||||
|     width: 100%; | ||||
| /*    height: 100%; | ||||
|     width: 100%;*/ | ||||
| } | ||||
| .photo_viewer a | ||||
| { | ||||
|  | @ -67,7 +96,7 @@ | |||
|     align-items: center; | ||||
|     background-repeat: no-repeat; | ||||
| } | ||||
| .photo_viewer img | ||||
| #photo_img_holder img | ||||
| { | ||||
|     max-height: 100%; | ||||
|     max-width: 100%; | ||||
|  | @ -146,7 +175,6 @@ | |||
|     <div class="photo_viewer"> | ||||
|         {% if photo.mimetype == "image" %} | ||||
|         <div id="photo_img_holder"><img id="photo_img" src="{{link}}" onclick="toggle_hoverzoom()" onload="this.style.opacity=0.99"></div> | ||||
|         <!-- <img src="{{link}}"> --> | ||||
|         {% elif photo.mimetype == "video" %} | ||||
|         <video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video> | ||||
|         {% elif photo.mimetype == "audio" %} | ||||
|  | @ -161,6 +189,7 @@ | |||
| 
 | ||||
| 
 | ||||
| <script type="text/javascript"> | ||||
| var content_body = document.getElementById('content_body'); | ||||
| var add_tag_box = document.getElementById('add_tag_textbox'); | ||||
| var add_tag_button = document.getElementById('add_tag_button'); | ||||
| var message_area = document.getElementById('message_area'); | ||||
|  | @ -239,8 +268,11 @@ function disable_hoverzoom() | |||
|     div.style.backgroundImage = "none"; | ||||
|     div.onmousemove = null; | ||||
|     div.onclick = null; | ||||
|     if (getComputedStyle(content_body).flexDirection != "column-reverse") | ||||
|     { | ||||
|         add_tag_box.focus(); | ||||
|     } | ||||
| } | ||||
| function toggle_hoverzoom() | ||||
| { | ||||
|     img = document.getElementById("photo_img"); | ||||
|  | @ -299,5 +331,23 @@ function move_hoverzoom(event) | |||
|     //console.log(x); | ||||
|     photo_img_holder.style.backgroundPosition=(-x)+"px "+(-y)+"px"; | ||||
| } | ||||
| 
 | ||||
| setTimeout( | ||||
|     /* | ||||
|     When the screen is in column mode, the autofocusing of the tag box snaps the | ||||
|     screen down to it, which is annoying. By starting the #left hidden, we have | ||||
|     an opportunity to unset the autofocus before showing it. | ||||
|     */ | ||||
|     function() | ||||
|     { | ||||
|         var left = document.getElementById("left"); | ||||
|         if (getComputedStyle(content_body).flexDirection == "column-reverse") | ||||
|         { | ||||
|             add_tag_box.autofocus = false; | ||||
|         } | ||||
|         left.style.display = "flex"; | ||||
|     }, | ||||
|     0 | ||||
| ); | ||||
| </script> | ||||
| </html> | ||||
|  |  | |||
|  | @ -17,6 +17,7 @@ | |||
| {% if view == "list" %} | ||||
| <div class="photo_card_list"> | ||||
|     <a target="_blank" href="/photo/{{photo.id}}">{{photo.basename}}</a> | ||||
|     <a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a> | ||||
| </div> | ||||
| 
 | ||||
| {% else %} | ||||
|  | @ -34,20 +35,13 @@ | |||
|                 %} | ||||
|                 src="/static/basic_thumbnails/{{choice}}.png" | ||||
|             {% endif %} | ||||
|             > | ||||
|         </a> | ||||
|     </div> | ||||
|     <div class="photo_card_grid_info"> | ||||
|         <a target="_blank" class="photo_card_grid_filename" href="/photo/{{photo.id}}">{{photo.basename}}</a> | ||||
|         <span class="photo_card_grid_file_metadata"> | ||||
|         {% if photo.width %} | ||||
|             {{photo.width}}x{{photo.height}}, | ||||
|         {% endif %} | ||||
|         {% if photo.duration %} | ||||
|             {{photo.duration_string()}}, | ||||
|         {% endif %} | ||||
|         <a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a> | ||||
|         </span> | ||||
|         <span class="photo_card_grid_tags"> | ||||
|         <div class="photo_card_grid_file_metadata"> | ||||
|             <div class="photo_card_grid_tags"> | ||||
|                 {% set tags = photo.tags() %} | ||||
|                 {% set tag_names = [] %} | ||||
|                 {% for tag in tags %} | ||||
|  | @ -56,8 +50,18 @@ | |||
|                 {% if tags %} | ||||
|                     <span title="{{", ".join(tag_names)}}">T</span> | ||||
|                 {% endif %} | ||||
|             </div> | ||||
|             <span> | ||||
|             {% if photo.width %} | ||||
|                 {{photo.width}}x{{photo.height}}, | ||||
|             {% endif %} | ||||
|             {% if photo.duration %} | ||||
|                 {{photo.duration_string()}}, | ||||
|             {% endif %} | ||||
|             <a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a> | ||||
|             </span> | ||||
|         </div> | ||||
|     </div> | ||||
| </div> | ||||
| {% endif %} | ||||
| {% endmacro %} | ||||
|  |  | |||
|  | @ -39,7 +39,10 @@ form | |||
| { | ||||
|     background-color: rgba(0, 0, 0, 0.1); | ||||
|     width: 300px; | ||||
|     max-width: 300px; | ||||
|     min-width: 300px; | ||||
|     padding: 8px; | ||||
|     flex: 1; | ||||
| } | ||||
| #right | ||||
| { | ||||
|  | @ -47,6 +50,17 @@ form | |||
|     padding: 8px; | ||||
|     width: auto; | ||||
| } | ||||
| @media screen and (max-width: 800px) { | ||||
|     #content_body | ||||
|     { | ||||
|         flex-direction: column-reverse; | ||||
|     } | ||||
|     #left | ||||
|     { | ||||
|         max-width: none; | ||||
|         width: initial; | ||||
|     } | ||||
| } | ||||
| .prev_next_holder | ||||
| { | ||||
|     display: flex; | ||||
|  | @ -396,7 +410,7 @@ function submit_search() | |||
|     var expression = document.getElementsByName("tag_expression")[0].value; | ||||
|     if (expression) | ||||
|     { | ||||
|         expression = expression.replace(new RegExp(" ", 'g'), "-"); | ||||
|         //expression = expression.replace(new RegExp(" ", 'g'), "-"); | ||||
|         parameters.push("tag_expression=" + expression); | ||||
|         has_tag_params=true; | ||||
|     } | ||||
|  |  | |||
|  | @ -1,4 +0,0 @@ | |||
| <form method="POST"> | ||||
|     <input type="text" name="phone_number"> | ||||
|     <input type="submit"> | ||||
| </form> | ||||
		Loading…
	
		Reference in a new issue