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
master
voussoir 2017-02-24 22:07:59 -08:00
parent 80cb66b825
commit c80e2003ff
16 changed files with 266 additions and 743 deletions

View File

@ -4,8 +4,8 @@ Etiquette
This is the readme file. This is the readme file.
### To do list ### 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. - 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. - 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. - 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. - 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. - **[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.

View File

@ -105,7 +105,8 @@ ERROR_INVALID_ACTION = 'Invalid action'
ERROR_NO_SUCH_TAG = 'Doesn\'t exist' ERROR_NO_SUCH_TAG = 'Doesn\'t exist'
ERROR_NO_TAG_GIVEN = 'No tag name supplied' ERROR_NO_TAG_GIVEN = 'No tag name supplied'
ERROR_SYNONYM_ITSELF = 'Cant apply synonym to itself' 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' ERROR_RECURSIVE_GROUPING = 'Recursive grouping'
WARNING_MINMAX_INVALID = 'Field "{field}": "{value}" is not a valid request. Ignored.' 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.' WARNING_MINMAX_OOO = 'Field "{field}": minimum "{min}" maximum "{max}" are out of order. Ignored.'

View File

@ -11,6 +11,7 @@ from voussoirkit import bytestring
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import spinal from voussoirkit import spinal
class ObjectBase: class ObjectBase:
def __eq__(self, other): def __eq__(self, other):
return ( return (
@ -101,7 +102,8 @@ class GroupableMixin:
# Lift children # Lift children
parent = self.parent() parent = self.parent()
if parent is None: 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]) cur.execute('DELETE FROM tag_group_rel WHERE parentid == ?', [self.id])
else: else:
# Since this group was a child, its parent adopts all its children. # 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 == ?', 'UPDATE tag_group_rel SET parentid == ? WHERE parentid == ?',
[parent.id, self.id] [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]) cur.execute('DELETE FROM tag_group_rel WHERE memberid == ?', [self.id])
if commit: if commit:
self.photodb.log.debug('Committing - delete tag') 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.log.debug('Committing - remove photo from album')
self.photodb.commit() 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): def walk_photos(self):
yield from self.photos() yield from self.photos()
children = self.walk_children() children = self.walk_children()
@ -642,7 +659,7 @@ class Photo(ObjectBase):
new_path = old_path.parent.with_child(new_filename) new_path = old_path.parent.with_child(new_filename)
else: else:
new_path = pathclass.Path(new_filename) new_path = pathclass.Path(new_filename)
new_path.correct_case() #new_path.correct_case()
self.photodb.log.debug(old_path) self.photodb.log.debug(old_path)
self.photodb.log.debug(new_path) self.photodb.log.debug(new_path)
@ -655,9 +672,11 @@ class Photo(ObjectBase):
os.makedirs(new_path.parent.absolute_path, exist_ok=True) os.makedirs(new_path.parent.absolute_path, exist_ok=True)
if new_path != old_path: if new_path != old_path:
# This is different than the absolute == absolute check above, because this normalizes # This is different than the absolute == absolute check above,
# the paths. It's possible on case-insensitive systems to have the paths point to the # because this normalizes the paths. It's possible on
# same place while being differently cased, thus we couldn't make the intermediate link. # 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: try:
os.link(old_path.absolute_path, new_path.absolute_path) os.link(old_path.absolute_path, new_path.absolute_path)
except OSError: except OSError:
@ -671,7 +690,8 @@ class Photo(ObjectBase):
if commit: if commit:
if new_path == old_path: 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) os.rename(old_path.absolute_path, new_path.absolute_path)
else: else:
# Delete the original hardlink or copy. # Delete the original hardlink or copy.
@ -900,6 +920,7 @@ class User(ObjectBase):
rep = 'User:{username}'.format(username=self.username) rep = 'User:{username}'.format(username=self.username)
return rep return rep
class WarningBag: class WarningBag:
def __init__(self): def __init__(self):
self.warnings = set() self.warnings = set()

View File

@ -16,6 +16,7 @@ from . import helpers
from . import objects from . import objects
from . import searchhelpers from . import searchhelpers
from voussoirkit import expressionmatch
from voussoirkit import pathclass from voussoirkit import pathclass
from voussoirkit import safeprint from voussoirkit import safeprint
from voussoirkit import spinal from voussoirkit import spinal
@ -161,76 +162,6 @@ def raise_no_such_thing(exception_class, thing_id=None, thing_name=None, comment
message = '' message = ''
raise exception_class(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): 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): 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') #print('Failed musts')
@ -728,7 +659,16 @@ class PDBPhotoMixin:
searchhelpers.minmax('duration', duration, minimums, maximums, warning_bag=warning_bag) searchhelpers.minmax('duration', duration, minimums, maximums, warning_bag=warning_bag)
orderby = searchhelpers.normalize_orderby(orderby) 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) print(query)
generator = helpers.select_generator(self.sql, query) generator = helpers.select_generator(self.sql, query)
@ -774,6 +714,12 @@ class PDBPhotoMixin:
else: else:
frozen_children = self.export_tags(tag_export_totally_flat) frozen_children = self.export_tags(tag_export_totally_flat)
self._cached_frozen_children = frozen_children 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 photos_received = 0
# LET'S GET STARTED # LET'S GET STARTED
@ -791,7 +737,7 @@ class PDBPhotoMixin:
(photo.extension in extension_not) (photo.extension in extension_not)
) )
) )
if (ext_fail): if ext_fail:
#print('Failed extension_not') #print('Failed extension_not')
continue continue
@ -822,7 +768,7 @@ class PDBPhotoMixin:
continue continue
if (has_tags is not None) or is_tagsearch: 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: if has_tags is False and len(photo_tags) > 0:
#print('Failed has_tags=False') #print('Failed has_tags=False')
@ -832,15 +778,11 @@ class PDBPhotoMixin:
#print('Failed has_tags=True') #print('Failed has_tags=True')
continue continue
photo_tags = set(photo_tags)
if tag_expression: if tag_expression:
success = searchfilter_expression( success = expression_tree.evaluate(
photo_tags=photo_tags, photo_tags,
expression=tag_expression, match_function=expression_matcher,
frozen_children=frozen_children,
token_normalizer=self.normalize_tagname,
warning_bag=warning_bag,
) )
if not success: if not success:
#print('Failed tag expression') #print('Failed tag expression')
@ -872,7 +814,7 @@ class PDBPhotoMixin:
yield warning_bag yield warning_bag
end_time = time.time() end_time = time.time()
print(end_time - start_time) print('Search results took:', end_time - start_time)
class PDBTagMixin: class PDBTagMixin:
@ -954,7 +896,7 @@ class PDBTagMixin:
tag = objects.Tag(self, [tagid, tagname]) tag = objects.Tag(self, [tagid, tagname])
return tag 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. Tag names can only consist of characters defined in the config.
The given tagname is lowercased, gets its spaces and hyphens The given tagname is lowercased, gets its spaces and hyphens
@ -968,10 +910,18 @@ class PDBTagMixin:
tagname = ''.join(tagname) tagname = ''.join(tagname)
if len(tagname) < self.config['min_tag_name_length']: 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) 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) raise exceptions.TagTooLong(tagname)
else:
return tagname return tagname
class PDBUserMixin: class PDBUserMixin:
@ -1200,6 +1150,8 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
*, *,
exclude_directories=None, exclude_directories=None,
exclude_filenames=None, exclude_filenames=None,
make_albums=True,
recurse=True,
commit=True, commit=True,
): ):
''' '''
@ -1220,8 +1172,11 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
directory, directory,
exclude_directories=exclude_directories, exclude_directories=exclude_directories,
exclude_filenames=exclude_filenames, exclude_filenames=exclude_filenames,
recurse=recurse,
yield_style='nested', yield_style='nested',
) )
if make_albums:
try: try:
album = self.get_album_by_path(directory.absolute_path) album = self.get_album_by_path(directory.absolute_path)
except exceptions.NoSuchAlbum: except exceptions.NoSuchAlbum:
@ -1230,9 +1185,18 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
commit=False, commit=False,
title=directory.basename, title=directory.basename,
) )
albums = {directory.absolute_path: album} albums = {directory.absolute_path: album}
for (current_location, directories, files) in generator: 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) current_album = albums.get(current_location.absolute_path, None)
if current_album is None: if current_album is None:
try: try:
@ -1253,17 +1217,16 @@ class PhotoDB(PDBAlbumMixin, PDBBookmarkMixin, PDBPhotoMixin, PDBTagMixin, PDBUs
#safeprint.safeprint('Added to %s' % parent.title) #safeprint.safeprint('Added to %s' % parent.title)
except exceptions.GroupExists: except exceptions.GroupExists:
pass 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) current_album.add_photo(photo, commit=False)
if commit: if commit:
self.log.debug('Committing - digest') self.log.debug('Committing - digest')
self.commit() self.commit()
if make_albums:
return album return album
else:
return None
# def digest_new_files( # def digest_new_files(
# self, # self,

View File

@ -5,19 +5,28 @@ from . import exceptions
from . import helpers from . import helpers
from . import objects from . import objects
def build_query(orderby): def build_query(orderby, notnulls):
query = 'SELECT * FROM photos' 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: if not orderby:
query += ' ORDER BY created DESC' query += ' ORDER BY created DESC'
return query 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 # Combine each column+sorter
orderby = [' '.join(o) for o in orderby] orderby = [' '.join(o) for o in orderby]
@ -207,7 +216,6 @@ def normalize_offset(offset, warning_bag=None):
return offset return offset
def normalize_orderby(orderby, warning_bag=None): def normalize_orderby(orderby, warning_bag=None):
if not orderby: if not orderby:
return None return None
@ -309,3 +317,28 @@ def normalize_tag_mmf(tags, photodb, warning_bag=None):
return None return None
return tagset 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

View File

@ -711,8 +711,10 @@ def post_edit_tags():
status = 400 status = 400
try: try:
response = method(tag) response = method(tag)
except exceptions.TagTooLong:
response = {'error': constants.ERROR_TAG_TOO_LONG.format(tag=tag), 'tagname': tag}
except exceptions.TagTooShort: 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: except exceptions.CantSynonymSelf:
response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag} response = {'error': constants.ERROR_SYNONYM_ITSELF, 'tagname': tag}
except exceptions.NoSuchTag as e: except exceptions.NoSuchTag as e:

View File

@ -88,13 +88,15 @@ li
display: block; display: block;
padding: 4px; padding: 4px;
margin: 8px; margin: 8px;
display: flex;
flex-direction: row;
justify-content: space-between;
} }
.photo_card_grid .photo_card_grid
{ {
vertical-align: middle; display: inline-flex;
position: relative; flex-direction: column;
box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25); box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25);
display: inline-block;
min-width: 150px; min-width: 150px;
max-width: 300px; max-width: 300px;
height: 200px; height: 200px;
@ -102,6 +104,7 @@ li
padding: 8px; padding: 8px;
margin: 8px; margin: 8px;
border-radius: 8px; border-radius: 8px;
vertical-align: middle;
} }
.photo_card_grid_thumb .photo_card_grid_thumb
{ {
@ -131,35 +134,32 @@ li
} }
.photo_card_grid_info .photo_card_grid_info
{ {
position: absolute; display: flex;
top: 160px; flex: 1;
bottom: 0px; justify-content: space-between;
left: 8px; flex-direction: column;
right: 8px;
font-size: 0.8em; font-size: 0.8em;
} }
.photo_card_grid_file_metadata .photo_card_grid_file_metadata
{ {
position: absolute; display: flex;
bottom: 0; justify-content: space-between;
right: 0;
} }
.photo_card_grid_filename .photo_card_grid_filename
{ {
position: absolute; /*position: absolute;*/
max-height: 30px; max-height: 30px;
overflow: hidden; overflow: hidden;
align-self: flex-start;
word-break:break-word; word-break:break-word;
} }
.photo_card_grid_filename:hover .photo_card_grid_filename:hover
{ {
max-height: 100%; overflow: visible;
} }
.photo_card_grid_tags .photo_card_grid_tags
{ {
position: absolute; align-self: flex-start;
bottom: 0;
left: 0;
} }
.tag_object .tag_object
{ {

View File

@ -81,6 +81,7 @@ function entry_with_history_hook(box, button)
box.entry_history_pos -= 1; box.entry_history_pos -= 1;
} }
box.value = box.entry_history[box.entry_history_pos]; box.value = box.entry_history[box.entry_history_pos];
setTimeout(function(){box.selectionStart = box.value.length;}, 0);
} }
else if (event.keyCode == 27) else if (event.keyCode == 27)
{ {

View File

@ -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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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;
}

View File

@ -50,8 +50,16 @@ p
{% set photos = album.photos() %} {% set photos = album.photos() %}
<span> <span>
Download: Download:
{% if photos %}<a href="/album/{{album.id}}.zip?recursive=no">These files</a>{% endif %} {% if photos %}
{% if sub_albums %}<a href="/album/{{album.id}}.zip?recursive=yes">Include children</a>{% endif %} <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> </span>
{% if photos %} {% if photos %}
<h3>Photos</h3> <h3>Photos</h3>

View File

@ -22,7 +22,9 @@
flex-direction: column; flex-direction: column;
padding: 8px; padding: 8px;
margin: 8px; margin: 8px;
align-items: baseline;
border-radius: 8px; border-radius: 8px;
box-shadow: 2px 2px 5px 0px rgba(0,0,0,0.25);
} }
.bookmark_card .bookmark_url .bookmark_card .bookmark_url
{ {

View File

@ -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>

View File

@ -13,9 +13,10 @@
#content_body #content_body
{ {
/* Override common.css */ /* Override common.css */
flex-direction: row;
flex: 1; flex: 1;
height: 100%; /*height: 100%;
width: 100%; width: 100%;*/
} }
#left #left
{ {
@ -24,13 +25,41 @@
flex-direction: column; flex-direction: column;
justify-content: flex-start; justify-content: flex-start;
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
width: 300px; max-width: 300px;
} }
#right #right
{ {
display: flex;
flex: 1; 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 #editor_area
{ {
@ -49,8 +78,8 @@
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
height: 100%; /* height: 100%;
width: 100%; width: 100%;*/
} }
.photo_viewer a .photo_viewer a
{ {
@ -67,7 +96,7 @@
align-items: center; align-items: center;
background-repeat: no-repeat; background-repeat: no-repeat;
} }
.photo_viewer img #photo_img_holder img
{ {
max-height: 100%; max-height: 100%;
max-width: 100%; max-width: 100%;
@ -146,7 +175,6 @@
<div class="photo_viewer"> <div class="photo_viewer">
{% if photo.mimetype == "image" %} {% 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> <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" %} {% elif photo.mimetype == "video" %}
<video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video> <video src="{{link}}" controls preload=none {%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}></video>
{% elif photo.mimetype == "audio" %} {% elif photo.mimetype == "audio" %}
@ -161,6 +189,7 @@
<script type="text/javascript"> <script type="text/javascript">
var content_body = document.getElementById('content_body');
var add_tag_box = document.getElementById('add_tag_textbox'); var add_tag_box = document.getElementById('add_tag_textbox');
var add_tag_button = document.getElementById('add_tag_button'); var add_tag_button = document.getElementById('add_tag_button');
var message_area = document.getElementById('message_area'); var message_area = document.getElementById('message_area');
@ -239,7 +268,10 @@ function disable_hoverzoom()
div.style.backgroundImage = "none"; div.style.backgroundImage = "none";
div.onmousemove = null; div.onmousemove = null;
div.onclick = null; div.onclick = null;
if (getComputedStyle(content_body).flexDirection != "column-reverse")
{
add_tag_box.focus(); add_tag_box.focus();
}
} }
function toggle_hoverzoom() function toggle_hoverzoom()
{ {
@ -299,5 +331,23 @@ function move_hoverzoom(event)
//console.log(x); //console.log(x);
photo_img_holder.style.backgroundPosition=(-x)+"px "+(-y)+"px"; 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> </script>
</html> </html>

View File

@ -17,6 +17,7 @@
{% if view == "list" %} {% if view == "list" %}
<div class="photo_card_list"> <div class="photo_card_list">
<a target="_blank" href="/photo/{{photo.id}}">{{photo.basename}}</a> <a target="_blank" href="/photo/{{photo.id}}">{{photo.basename}}</a>
<a target="_blank" href="/file/{{photo.id}}.{{photo.extension}}">{{photo.bytestring()}}</a>
</div> </div>
{% else %} {% else %}
@ -34,20 +35,13 @@
%} %}
src="/static/basic_thumbnails/{{choice}}.png" src="/static/basic_thumbnails/{{choice}}.png"
{% endif %} {% endif %}
>
</a> </a>
</div> </div>
<div class="photo_card_grid_info"> <div class="photo_card_grid_info">
<a target="_blank" class="photo_card_grid_filename" href="/photo/{{photo.id}}">{{photo.basename}}</a> <a target="_blank" class="photo_card_grid_filename" href="/photo/{{photo.id}}">{{photo.basename}}</a>
<span class="photo_card_grid_file_metadata"> <div class="photo_card_grid_file_metadata">
{% if photo.width %} <div class="photo_card_grid_tags">
{{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">
{% set tags = photo.tags() %} {% set tags = photo.tags() %}
{% set tag_names = [] %} {% set tag_names = [] %}
{% for tag in tags %} {% for tag in tags %}
@ -56,8 +50,18 @@
{% if tags %} {% if tags %}
<span title="{{", ".join(tag_names)}}">T</span> <span title="{{", ".join(tag_names)}}">T</span>
{% endif %} {% 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> </span>
</div> </div>
</div>
</div> </div>
{% endif %} {% endif %}
{% endmacro %} {% endmacro %}

View File

@ -39,7 +39,10 @@ form
{ {
background-color: rgba(0, 0, 0, 0.1); background-color: rgba(0, 0, 0, 0.1);
width: 300px; width: 300px;
max-width: 300px;
min-width: 300px;
padding: 8px; padding: 8px;
flex: 1;
} }
#right #right
{ {
@ -47,6 +50,17 @@ form
padding: 8px; padding: 8px;
width: auto; width: auto;
} }
@media screen and (max-width: 800px) {
#content_body
{
flex-direction: column-reverse;
}
#left
{
max-width: none;
width: initial;
}
}
.prev_next_holder .prev_next_holder
{ {
display: flex; display: flex;
@ -396,7 +410,7 @@ function submit_search()
var expression = document.getElementsByName("tag_expression")[0].value; var expression = document.getElementsByName("tag_expression")[0].value;
if (expression) if (expression)
{ {
expression = expression.replace(new RegExp(" ", 'g'), "-"); //expression = expression.replace(new RegExp(" ", 'g'), "-");
parameters.push("tag_expression=" + expression); parameters.push("tag_expression=" + expression);
has_tag_params=true; has_tag_params=true;
} }

View File

@ -1,4 +0,0 @@
<form method="POST">
<input type="text" name="phone_number">
<input type="submit">
</form>