Add Swipe UI.
This commit is contained in:
		
							parent
							
								
									a823036f9d
								
							
						
					
					
						commit
						56ab6636cc
					
				
					 3 changed files with 563 additions and 0 deletions
				
			
		|  | @ -549,3 +549,10 @@ def get_search_json(): | |||
|         tag.jsonify(minimal=True) for tag in search_results['total_tags'] | ||||
|     ] | ||||
|     return jsonify.make_json_response(search_results) | ||||
| 
 | ||||
| # Swipe ############################################################################################ | ||||
| 
 | ||||
| @site.route('/swipe') | ||||
| def get_swipe(): | ||||
|     response = common.render_template(request, 'swipe.html') | ||||
|     return response | ||||
|  |  | |||
|  | @ -318,6 +318,7 @@ | |||
|                 <option value="list"  {{"selected" if search_kwargs['view']=="list" else ""}}>List</option> | ||||
|             </select> | ||||
|             <button type="submit" id="search_go_button" class="green_button" value="">Search</button> | ||||
|             <button type="button" id="swipe_go_button" class="green_button" value="" onclick="return submit_swipe();">Swipe UI</button> | ||||
|         </form> | ||||
|         {% if total_tags %} | ||||
|         <h4>Tags on this page:</h4> | ||||
|  | @ -622,6 +623,19 @@ function submit_search() | |||
|     return false; | ||||
| } | ||||
| 
 | ||||
| function submit_swipe() | ||||
| { | ||||
|     const parameters = build_search_params().toString(); | ||||
|     let url = "/swipe"; | ||||
|     if (parameters !== "") | ||||
|     { | ||||
|         url += "?" + parameters.toString(); | ||||
|     } | ||||
|     console.log(url); | ||||
|     window.location.href = url; | ||||
|     return false; | ||||
| } | ||||
| 
 | ||||
| function tags_on_this_page_add_must(event, tagname) | ||||
| { | ||||
|     add_searchtag( | ||||
|  |  | |||
							
								
								
									
										542
									
								
								frontends/etiquette_flask/templates/swipe.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										542
									
								
								frontends/etiquette_flask/templates/swipe.html
									
									
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,542 @@ | |||
| <!DOCTYPE html5> | ||||
| <html> | ||||
| <head> | ||||
|     {% import "header.html" as header %} | ||||
|     {% import "clipboard_tray.html" as clipboard_tray %} | ||||
|     <title>Swipe</title> | ||||
|     <meta charset="UTF-8"> | ||||
|     <meta name="viewport" content="width=device-width, initial-scale=1.0"/> | ||||
|     <link rel="stylesheet" href="/static/css/common.css"> | ||||
|     <link rel="stylesheet" href="/static/css/etiquette.css"> | ||||
|     <link rel="stylesheet" href="/static/css/cards.css"> | ||||
|     <link rel="stylesheet" href="/static/css/clipboard_tray.css"> | ||||
|     {% if theme %}<link rel="stylesheet" href="/static/css/theme_{{theme}}.css">{% endif %} | ||||
|     <script src="/static/js/common.js"></script> | ||||
|     <script src="/static/js/api.js"></script> | ||||
|     <script src="/static/js/cards.js"></script> | ||||
|     <script src="/static/js/hotkeys.js"></script> | ||||
|     <script src="/static/js/photo_clipboard.js"></script> | ||||
|     <script src="/static/js/spinner.js"></script> | ||||
|     <script src="/static/js/tag_autocomplete.js"></script> | ||||
| 
 | ||||
| <style> | ||||
| #content_body | ||||
| { | ||||
|     display: grid; | ||||
|     grid-template: | ||||
|         "photo_viewer right" 1fr | ||||
|         "upcoming_area right" auto | ||||
|         "button_bar button_bar" auto | ||||
|         /1fr 300px; | ||||
|     /* header=18+8+8 + content_body margin-bottom=8 */ | ||||
|     height: calc(100vh - 42px); | ||||
| } | ||||
| #right | ||||
| { | ||||
|     display: grid; | ||||
|     grid-template: | ||||
|         "name_tag" auto | ||||
|         "message_area" 1fr | ||||
|         / auto; | ||||
|     grid-row-gap: 8px; | ||||
| } | ||||
| #name_tag | ||||
| { | ||||
|     grid-area: name_tag; | ||||
|     word-break: break-word; | ||||
| } | ||||
| #upcoming_area | ||||
| { | ||||
|     grid-area: upcoming_area; | ||||
|     display: flex; | ||||
|     flex-direction: row; | ||||
|     align-items: center; | ||||
|     overflow: hidden; | ||||
|     min-height: 0; | ||||
| } | ||||
| #message_area | ||||
| { | ||||
|     grid-area: message_area; | ||||
| } | ||||
| #photo_viewer | ||||
| { | ||||
|     grid-area: photo_viewer; | ||||
|     display: flex; | ||||
|     justify-content: center; | ||||
|     align-items: center; | ||||
|     min-height: 0; | ||||
| } | ||||
| #photo_viewer img | ||||
| { | ||||
|     min-height: 0; | ||||
|     max-height: 100%; | ||||
| } | ||||
| #button_bar | ||||
| { | ||||
|     grid-area: button_bar; | ||||
|     display: flex; | ||||
|     height: 100px; | ||||
| } | ||||
| #button_bar > .action_button | ||||
| { | ||||
|     flex: 1; | ||||
| } | ||||
| #button_bar select, | ||||
| #button_bar input | ||||
| { | ||||
|     background-color: white; | ||||
| } | ||||
| </style> | ||||
| </head> | ||||
| 
 | ||||
| <body> | ||||
| {{header.make_header(session=session)}} | ||||
| <div id="content_body"> | ||||
| <div id="right" class="panel"> | ||||
|     <a id="name_tag" target="_blank">Swipe!</a> | ||||
|     <div id="message_area"> | ||||
|     </div> | ||||
| </div> | ||||
| <div id="photo_viewer"> | ||||
|     <img id="photo_viewer_img" onload="return onload_img(event);" src=""/> | ||||
| </div> | ||||
| <div id="upcoming_area"> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
|     <img class="upcoming_img" src="" width="75px" height="75px"/> | ||||
| </div> | ||||
| <div id="button_bar" class="panel"> | ||||
|     <button class="action_button red_button" data-action-index="left"> | ||||
|         ← | ||||
|         <select class="action_select"> | ||||
|             <option value="do_nothing">Do nothing</option> | ||||
|             <option value="add_clipboard">Add to clipboard</option> | ||||
|             <option value="add_tag">Add tag</option> | ||||
|             <option value="remove_tag">Remove tag</option> | ||||
|         </select> | ||||
|         <input type="text" class="action_tag_input hidden entry_with_tagname_replacements" list="tag_autocomplete_datalist"/> | ||||
|     </button> | ||||
|     <button class="action_button yellow_button" data-action-index="down">Do nothing</button> | ||||
|     <button class="action_button green_button" data-action-index="right"> | ||||
|         <select class="action_select"> | ||||
|             <option value="do_nothing">Do nothing</option> | ||||
|             <option value="add_clipboard">Add to clipboard</option> | ||||
|             <option value="add_tag">Add tag</option> | ||||
|             <option value="remove_tag">Remove tag</option> | ||||
|         </select> | ||||
|         <input type="text" class="action_tag_input hidden entry_with_tagname_replacements" list="tag_autocomplete_datalist"/> | ||||
|         → | ||||
|     </button> | ||||
| </div> | ||||
| </div> | ||||
| {{clipboard_tray.clipboard_tray()}} | ||||
| </body> | ||||
| 
 | ||||
| <script type="text/javascript"> | ||||
| const original_search_params = Array.from(new URLSearchParams(window.location.search)); | ||||
| 
 | ||||
| const name_tag = document.getElementById("name_tag"); | ||||
| const message_area = document.getElementById("message_area"); | ||||
| const photo_viewer_img = document.getElementById("photo_viewer_img"); | ||||
| const upcoming_imgs = document.getElementsByClassName("upcoming_img"); | ||||
| 
 | ||||
| const action_map = {}; | ||||
| for (button of document.getElementsByClassName("action_button")) | ||||
| { | ||||
|     action_map[button.dataset.actionIndex] = { | ||||
|         "action": "do_nothing", | ||||
|         "tag": null, | ||||
|         "button": button, | ||||
|         "tag_input": button.getElementsByClassName("action_tag_input")[0], | ||||
|     }; | ||||
| } | ||||
| 
 | ||||
| const photo_queue = []; | ||||
| const rewind_queue = []; | ||||
| const REWIND_QUEUE_LENGTH = 15; | ||||
| 
 | ||||
| let current_photo = null; | ||||
| let SEARCH_LIMIT = 100; | ||||
| let current_search_offset = 0; | ||||
| let waiting_for_img = false; | ||||
| let pending_search_request = null; | ||||
| 
 | ||||
| // STATE /////////////////////////////////////////////////////////////////////////////////////////// | ||||
| 
 | ||||
| function get_more_photos() | ||||
| { | ||||
|     // Prevents multiple calls from requesting duplicate ranges. | ||||
|     if (pending_search_request !== null) | ||||
|     { | ||||
|         return; | ||||
|     } | ||||
|     function callback(response) | ||||
|     { | ||||
|         pending_search_request = null; | ||||
|         if (! response.meta.json_ok) | ||||
|         { | ||||
|             alert(JSON.stringify(response)); | ||||
|             return; | ||||
|         } | ||||
|         if (response.data.error_type) | ||||
|         { | ||||
|             alert(response.data.error_type + "\n" + response.data.error_message); | ||||
|             return; | ||||
|         } | ||||
|         const need_show_photo = photo_queue.length === 0; | ||||
|         const results = response.data.results; | ||||
|         console.log("Got " + results.length + " more photos."); | ||||
|         photo_queue.push(...results); | ||||
| 
 | ||||
|         if (results.length === 0) | ||||
|         { | ||||
|             if (current_search_offset === 0) | ||||
|             { | ||||
|                 console.log("Search results seem to be exhausted."); | ||||
|                 return; | ||||
|             } | ||||
|             current_search_offset = 0; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             current_search_offset += results.length; | ||||
|         } | ||||
|         waiting_for_more_photos = false; | ||||
| 
 | ||||
|         if (need_show_photo) | ||||
|         { | ||||
|             show_next_photo(); | ||||
|         } | ||||
|     } | ||||
|     console.log("Requesting more photos."); | ||||
|     const search_params = modify_search_params(); | ||||
|     pending_search_request = api.photos.search(search_params, callback); | ||||
| } | ||||
| 
 | ||||
| function modify_search_params() | ||||
| { | ||||
|     const search_params = new URLSearchParams(); | ||||
| 
 | ||||
|     let extra_musts = []; | ||||
|     let extra_forbids = []; | ||||
| 
 | ||||
|     for (action_index in action_map) | ||||
|     { | ||||
|         const action = action_map[action_index]["action"]; | ||||
|         const tag = action_map[action_index]["tag"]; | ||||
|         if (action === "remove_tag") | ||||
|         { | ||||
|             extra_must.push(tag); | ||||
|         } | ||||
|         if (action === "add_tag") | ||||
|         { | ||||
|             extra_forbids.push(tag); | ||||
|         } | ||||
|     } | ||||
| 
 | ||||
|     extra_musts = extra_musts.join(","); | ||||
|     extra_forbids = extra_forbids.join(","); | ||||
| 
 | ||||
|     let had_musts = false; | ||||
|     let had_forbids = false; | ||||
|     for ([key, value] of original_search_params) | ||||
|     { | ||||
|         if (key === "limit" || key === "offset" || key === "yield_albums" || key === "yield_photos") | ||||
|         { | ||||
|             continue; | ||||
|         } | ||||
|         if (key === "tag_musts") | ||||
|         { | ||||
|             value = value + "," + extra_musts; | ||||
|         } | ||||
|         if (key === "tag_forbids") | ||||
|         { | ||||
|             value = value + "," + extra_forbids; | ||||
|         } | ||||
|         search_params.set(key, value); | ||||
|     } | ||||
|     if (! had_musts && extra_musts.length > 0) | ||||
|     { | ||||
|         search_params.set("tag_musts", extra_musts); | ||||
|     } | ||||
|     if (! had_forbids && extra_forbids.length > 0) | ||||
|     { | ||||
|         search_params.set("tag_forbids", extra_forbids); | ||||
|     } | ||||
|     search_params.set("yield_albums", "no"); | ||||
|     search_params.set("yield_photos", "yes"); | ||||
|     search_params.set("limit", SEARCH_LIMIT); | ||||
|     search_params.set("offset", current_search_offset); | ||||
|     console.log("Updated search params " + search_params.toString()); | ||||
|     return search_params; | ||||
| } | ||||
| 
 | ||||
| function onload_img(event) | ||||
| { | ||||
|     waiting_for_img = false; | ||||
| } | ||||
| 
 | ||||
| function reset_swipe() | ||||
| { | ||||
|     current_photo = null; | ||||
|     photo_queue.length = 0; | ||||
|     rewind_queue.length = 0; | ||||
|     if (pending_search_request !== null) | ||||
|     { | ||||
|         pending_search_request.abort(); | ||||
|         pending_search_request = null; | ||||
|     } | ||||
|     current_search_offset = 0; | ||||
|     get_more_photos(); | ||||
| } | ||||
| 
 | ||||
| // ACTION PROCESSING /////////////////////////////////////////////////////////////////////////////// | ||||
| 
 | ||||
| function add_remove_tag_callback(response) | ||||
| { | ||||
|     if (! response.meta.json_ok) | ||||
|     { | ||||
|         alert(JSON.stringify(response)); | ||||
|         return; | ||||
|     } | ||||
|     let message_text; | ||||
|     let message_positivity; | ||||
|     let abort; | ||||
|     if ("error_type" in response.data) | ||||
|     { | ||||
|         message_positivity = "message_negative"; | ||||
|         message_text = response.data.error_message; | ||||
|         abort = true; | ||||
|     } | ||||
|     else | ||||
|     { | ||||
|         const tagname = response.data.tagname; | ||||
|         message_positivity = "message_positive"; | ||||
|         if (response.meta.request_url.includes("add_tag")) | ||||
|         { | ||||
|             message_text = "Added tag " + tagname; | ||||
|         } | ||||
|         else if (response.meta.request_url.includes("remove_tag")) | ||||
|         { | ||||
|             message_text = "Removed tag " + tagname; | ||||
|         } | ||||
|         abort = false; | ||||
|     } | ||||
|     common.create_message_bubble(message_area, message_positivity, message_text, 8000); | ||||
|     return abort; | ||||
| } | ||||
| 
 | ||||
| function process_current_photo(action, action_tag) | ||||
| { | ||||
|     if (current_photo === null) | ||||
|     { | ||||
|         return; | ||||
|     } | ||||
|     console.log("Doing " + action + " to " + current_photo.id); | ||||
|     if (action === "do_nothing") | ||||
|     { | ||||
|         ; | ||||
|     } | ||||
|     if (action === "add_clipboard") | ||||
|     { | ||||
|         photo_clipboard.clipboard.add(current_photo.id); | ||||
|         setTimeout(() => photo_clipboard.save_clipboard(), 0); | ||||
|     } | ||||
|     if (action === "add_tag") | ||||
|     { | ||||
|         if (action_tag === null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|         api.photos.add_tag(current_photo.id, action_tag, add_remove_tag_callback); | ||||
|     } | ||||
|     if (action === "remove_tag") | ||||
|     { | ||||
|         if (action_tag === null) | ||||
|         { | ||||
|             return; | ||||
|         } | ||||
|         api.photos.remove_tag(current_photo.id, action_tag, add_remove_tag_callback); | ||||
|     } | ||||
|     show_next_photo(); | ||||
| } | ||||
| 
 | ||||
| // UI ////////////////////////////////////////////////////////////////////////////////////////////// | ||||
| 
 | ||||
| function step_previous_photo() | ||||
| { | ||||
|     const rewind_photo = rewind_queue.shift(); | ||||
|     if (rewind_photo === undefined) | ||||
|     { | ||||
|         return; | ||||
|     } | ||||
|     if (current_photo !== null) | ||||
|     { | ||||
|         photo_queue.unshift(current_photo); | ||||
|     } | ||||
|     current_photo = rewind_photo; | ||||
| } | ||||
| 
 | ||||
| function step_next_photo() | ||||
| { | ||||
|     if (current_photo !== null) | ||||
|     { | ||||
|         rewind_queue.unshift(current_photo); | ||||
|         rewind_queue.length = REWIND_QUEUE_LENGTH; | ||||
|     } | ||||
|     if (photo_queue.length == 0) | ||||
|     { | ||||
|         current_photo = null; | ||||
|         get_more_photos(); | ||||
|         return; | ||||
|     } | ||||
|     current_photo = photo_queue.shift(); | ||||
|     if (photo_queue.length < 20) | ||||
|     { | ||||
|         get_more_photos(); | ||||
|     } | ||||
| } | ||||
| function show_previous_photo() | ||||
| { | ||||
|     step_previous_photo(); | ||||
|     show_current_photo(); | ||||
| } | ||||
| 
 | ||||
| function show_next_photo() | ||||
| { | ||||
|     step_next_photo(); | ||||
|     show_current_photo(); | ||||
| } | ||||
| 
 | ||||
| function show_current_photo() | ||||
| { | ||||
|     if (current_photo === null) | ||||
|     { | ||||
|         return; | ||||
|     } | ||||
| 
 | ||||
|     name_tag.innerText = current_photo.filename; | ||||
|     name_tag.href = "/photo/" + current_photo.id; | ||||
|     if (current_photo.has_thumbnail) | ||||
|     { | ||||
|         photo_viewer_img.src = "/thumbnail/" + current_photo.id + ".jpg"; | ||||
|         waiting_for_img = true; | ||||
|     } | ||||
|     else | ||||
|     { | ||||
|         photo_viewer_img.src = ""; | ||||
|     } | ||||
| 
 | ||||
|     for (let index = 0; index < upcoming_imgs.length; index += 1) | ||||
|     { | ||||
|         upcoming_photo = photo_queue[index]; | ||||
|         if (upcoming_photo !== undefined && upcoming_photo.has_thumbnail) | ||||
|         { | ||||
|             upcoming_imgs[index].src = "/thumbnail/" + upcoming_photo.id + ".jpg"; | ||||
|         } | ||||
|         else | ||||
|         { | ||||
|             upcoming_imgs[index].src = ""; | ||||
|         } | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| // UI - EVENT HANDLERS ///////////////////////////////////////////////////////////////////////////// | ||||
| 
 | ||||
| function onchange_action_select(event) | ||||
| { | ||||
|     const select = event.target; | ||||
|     const button = select.closest("button"); | ||||
|     const action = select.value; | ||||
|     const action_index = button.dataset.actionIndex; | ||||
|     action_map[action_index]["action"] = action; | ||||
| 
 | ||||
|     const input = action_map[action_index]["tag_input"]; | ||||
|     if (action === "add_tag" || action === "remove_tag") | ||||
|     { | ||||
|         input.classList.remove("hidden"); | ||||
|     } | ||||
|     else | ||||
|     { | ||||
|         action_map[action_index]["tag"] = null; | ||||
|         input.value = ""; | ||||
|         input.classList.add("hidden"); | ||||
|     } | ||||
| } | ||||
| 
 | ||||
| function onchange_action_tag(event) | ||||
| { | ||||
|     const input = event.target; | ||||
|     const button = input.closest("button"); | ||||
|     const action_index = button.dataset.actionIndex; | ||||
|     action_map[action_index]["tag"] = input.value; | ||||
| } | ||||
| 
 | ||||
| function onclick_button(event) | ||||
| { | ||||
|     if (waiting_for_img) | ||||
|     { | ||||
|         return; | ||||
|     } | ||||
|     const button = event.target; | ||||
|     const action_index = button.dataset.actionIndex; | ||||
|     const action = action_map[action_index]["action"]; | ||||
|     const tag = action_map[action_index]["tag"]; | ||||
|     process_current_photo(action, tag); | ||||
|     return false; | ||||
| } | ||||
| 
 | ||||
| 
 | ||||
| //////////////////////////////////////////////////////////////////////////////////////////////////// | ||||
| 
 | ||||
| function on_pageload() | ||||
| { | ||||
|     hotkeys.register_hotkey("arrowleft", ()=>action_map["left"]["button"].click(), "Push the left button"); | ||||
|     hotkeys.register_hotkey("arrowdown", ()=>action_map["down"]["button"].click(), "Push the middle button"); | ||||
|     hotkeys.register_hotkey("arrowright", ()=>action_map["right"]["button"].click(), "Push the right button"); | ||||
|     hotkeys.register_hotkey("arrowup", show_previous_photo, "Show the previous photo"); | ||||
|     show_next_photo(); | ||||
|     for (const button of document.getElementsByClassName("action_button")) | ||||
|     { | ||||
|         button.addEventListener("click", onclick_button); | ||||
|     } | ||||
|     for (const button of document.getElementsByClassName("action_select")) | ||||
|     { | ||||
|         button.addEventListener("change", onchange_action_select); | ||||
|         button.addEventListener("click", (event)=>{event.stopPropagation(); false}); | ||||
|     } | ||||
|     for (const button of document.getElementsByClassName("action_tag_input")) | ||||
|     { | ||||
|         button.addEventListener("change", onchange_action_tag); | ||||
|         button.addEventListener("click", (event)=>{event.stopPropagation(); false}); | ||||
|     } | ||||
| } | ||||
| document.addEventListener("DOMContentLoaded", on_pageload); | ||||
| </script> | ||||
| </html> | ||||
		Loading…
	
		Reference in a new issue