Ethan Dalool
9a29048ccf
This makes the role of each css definition more clear, and could allow for cases where the side is sticky in wide mode but not sticky in narrow mode.
582 lines
16 KiB
HTML
582 lines
16 KiB
HTML
<!DOCTYPE html5>
|
|
<html>
|
|
<head>
|
|
{% import "header.html" as header %}
|
|
{% import "tag_object.html" as tag_object %}
|
|
<title>{{photo.basename}} | Photos</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">
|
|
{% 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/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
|
|
{
|
|
--narrow: 0;
|
|
flex: 1;
|
|
grid-template:
|
|
"left right" 1fr
|
|
/ 310px 1fr;
|
|
}
|
|
#left
|
|
{
|
|
grid-area: left;
|
|
|
|
display: grid;
|
|
grid-template:
|
|
"editor_area" auto
|
|
"message_area" 1fr
|
|
/1fr;
|
|
|
|
min-height: min-content;
|
|
|
|
background-color: var(--color_transparency);
|
|
}
|
|
#right
|
|
{
|
|
grid-area: right;
|
|
|
|
display: grid;
|
|
position: relative;
|
|
grid-template: "viewer" 1fr / 1fr;
|
|
}
|
|
#editor_area
|
|
{
|
|
grid-area: editor_area;
|
|
padding: 8px;
|
|
word-wrap: break-word;
|
|
}
|
|
#before_after_links
|
|
{
|
|
width: max-content;
|
|
margin: auto;
|
|
}
|
|
#message_area
|
|
{
|
|
grid-area: message_area;
|
|
min-height: 30px;
|
|
max-height: none;
|
|
margin: 8px;
|
|
}
|
|
#photo_viewer
|
|
{
|
|
grid-area: viewer;
|
|
display: grid;
|
|
}
|
|
.photo_viewer_audio,
|
|
.photo_viewer_video,
|
|
.photo_viewer_application,
|
|
.photo_viewer_text
|
|
{
|
|
justify-items: center;
|
|
align-items: center;
|
|
}
|
|
#photo_viewer audio,
|
|
#photo_viewer video
|
|
{
|
|
width: 100%;
|
|
max-width: 100%;
|
|
max-height: 100%;
|
|
}
|
|
.photo_viewer_image
|
|
{
|
|
display: grid;
|
|
justify-items: center;
|
|
align-items: center;
|
|
|
|
max-height: 100%;
|
|
background-repeat: no-repeat;
|
|
}
|
|
.photo_viewer_image img
|
|
{
|
|
max-height: 100%;
|
|
max-width: 100%;
|
|
}
|
|
#photo_viewer a
|
|
{
|
|
margin: auto;
|
|
}
|
|
|
|
#hovering_tools
|
|
{
|
|
position: absolute;
|
|
right: 8px;
|
|
top: 8px;
|
|
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
@media screen and (max-width: 800px)
|
|
{
|
|
#content_body
|
|
{
|
|
--narrow: 1;
|
|
grid-template:
|
|
"right" 100%
|
|
"left" max-content
|
|
/ 1fr;
|
|
}
|
|
#left
|
|
{
|
|
width: initial;
|
|
max-width: none;
|
|
}
|
|
#message_area
|
|
{
|
|
flex: 2;
|
|
height: initial;
|
|
max-height: none;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
|
|
<body>
|
|
{{header.make_header(session=session)}}
|
|
<div id="content_body">
|
|
<div id="left">
|
|
<div id="editor_area">
|
|
<h3>{{photo.basename}}</h3>
|
|
|
|
<!-- TAG INFO -->
|
|
<h4>Tags</h4>
|
|
<ul id="this_tags">
|
|
<li>
|
|
<input type="text" id="add_tag_textbox" class="entry_with_history entry_with_tagname_replacements" list="tag_autocomplete_datalist">
|
|
<button id="add_tag_button" class="green_button" onclick="return add_photo_tag_form();">add</button>
|
|
</li>
|
|
{% set tags = photo.get_tags()|sort_tags %}
|
|
{% for tag in tags %}
|
|
<li>
|
|
{{tag_object.tag_object(tag, with_alt_description=True)}}<!--
|
|
--><button
|
|
class="remove_tag_button red_button"
|
|
onclick="return remove_photo_tag_form('{{photo.id}}', '{{tag.name}}');">
|
|
</button>
|
|
</li>
|
|
{% endfor %}
|
|
</ul>
|
|
|
|
<!-- METADATA & DOWNLOAD -->
|
|
<h4>
|
|
File info
|
|
</h4>
|
|
<ul id="metadata">
|
|
{% set author = photo.get_author() %}
|
|
{% if author is not none %}
|
|
<li>Author: <a href="/user/{{author.username}}">{{author.display_name}}</a></li>
|
|
{% endif %}
|
|
{% if photo.width %}
|
|
<li title="{{photo.area}} px">Dimensions: {{photo.width}}x{{photo.height}} px</li>
|
|
<li>Aspect ratio: {{photo.ratio}}</li>
|
|
{% endif %}
|
|
<li>Size: {{photo.bytes|bytestring}}</li>
|
|
{% if photo.duration %}
|
|
<li>Duration: {{photo.duration_string}}</li>
|
|
<li>Overall Bitrate: {{photo.bitrate|int}} kbps</li>
|
|
{% endif %}
|
|
<li><a href="{{photo|file_link}}?download=true&original_filename=true">Download as original filename</a></li>
|
|
<li><a href="{{photo|file_link}}?download=true">Download as {{photo.id}}.{{photo.extension}}</a></li>
|
|
<li><button id="refresh_metadata_button" class="green_button button_with_spinner" onclick="return refresh_metadata_form();">refresh</button></li>
|
|
<li>
|
|
<label>
|
|
<input type="checkbox" {%if photo.searchhidden%}checked{%endif%} onchange="return set_searchhidden_form(event);"
|
|
/>Hidden from search
|
|
</label>
|
|
</li>
|
|
<li>
|
|
<label>
|
|
<input type="checkbox" class="photo_card_selector_checkbox" data-photo-id="{{photo.id}}" onchange="return photo_clipboard.on_photo_select(event);"
|
|
/>Clipboard
|
|
</label>
|
|
</li>
|
|
</ul>
|
|
|
|
|
|
<!-- CONTAINING ALBUMS -->
|
|
{% set albums = photo.get_containing_albums() %}
|
|
{% if albums %}
|
|
<h4>Albums containing this photo</h4>
|
|
<ul id="containing albums">
|
|
{% for album in albums %}
|
|
<li><a href="/album/{{album.id}}">{{album.display_name}}</a></li>
|
|
{% endfor %}
|
|
</ul>
|
|
{% endif %}
|
|
|
|
<!-- BEFORE & AFTER SEARCH LINKS -->
|
|
<div id="before_after_links">
|
|
<a href="/search?created=-{{photo.created}}">←Before</a>
|
|
<span> | </span>
|
|
<a href="/search?created={{photo.created}}-&orderby=created-asc">After→</a>
|
|
</div>
|
|
|
|
</div>
|
|
<div id="message_area"></div>
|
|
</div>
|
|
|
|
<div id="right">
|
|
<!-- THE PHOTO ITSELF -->
|
|
<div id="photo_viewer" class="photo_viewer_{{photo.simple_mimetype}}" {%if photo.simple_mimetype == "image"%}onclick="return toggle_hoverzoom(event);"{%endif%}>
|
|
{% if photo.simple_mimetype == "image" %}
|
|
<img src="{{photo|file_link}}" alt="{{photo.basename}}" onload="this.style.opacity=0.99">
|
|
|
|
{% elif photo.simple_mimetype == "video" %}
|
|
<video
|
|
src="{{photo|file_link}}"
|
|
controls
|
|
preload=none
|
|
{%if photo.thumbnail%}poster="/thumbnail/{{photo.id}}.jpg"{%endif%}
|
|
></video>
|
|
|
|
{% elif photo.simple_mimetype == "audio" %}
|
|
<audio src="{{photo|file_link}}" controls></audio>
|
|
|
|
{% else %}
|
|
<a href="{{photo|file_link}}">View {{photo.basename}}</a>
|
|
|
|
{% endif %}
|
|
</div>
|
|
<div id="hovering_tools">
|
|
{% if photo.simple_mimetype == "video" %}
|
|
<button id="generate_thumbnail_button" class="green_button button_with_spinner" onclick="return generate_thumbnail_for_video_form(event);">Capture thumbnail</button>
|
|
{% endif %}
|
|
<button
|
|
class="red_button button_with_confirm"
|
|
data-onclick="return delete_photo_form();"
|
|
data-prompt="Delete photo, keep file?"
|
|
data-cancel-class="gray_button"
|
|
>
|
|
Remove
|
|
</button>
|
|
|
|
<button
|
|
class="red_button button_with_confirm"
|
|
data-onclick="return delete_photo_from_disk_form();"
|
|
data-prompt="Delete file on disk?"
|
|
data-cancel-class="gray_button"
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</body>
|
|
|
|
|
|
<script type="text/javascript">
|
|
const PHOTO_ID = "{{photo.id}}";
|
|
|
|
const add_tag_box = document.getElementById('add_tag_textbox');
|
|
const add_tag_button = document.getElementById('add_tag_button');
|
|
common.bind_box_to_button(add_tag_box, add_tag_button, false);
|
|
|
|
const message_area = document.getElementById('message_area');
|
|
|
|
function add_photo_tag_form()
|
|
{
|
|
const tagname = document.getElementById("add_tag_textbox").value;
|
|
if (tagname == "")
|
|
{
|
|
return;
|
|
}
|
|
api.photos.add_tag(PHOTO_ID, tagname, add_photo_tag_callback);
|
|
add_tag_box.value = "";
|
|
}
|
|
function add_photo_tag_callback(response)
|
|
{
|
|
add_remove_photo_tag_callback(response);
|
|
if (response.meta.status !== 200)
|
|
{
|
|
return;
|
|
}
|
|
const this_tags = document.getElementById("this_tags");
|
|
const tag_objects = this_tags.getElementsByClassName("tag_object");
|
|
for (const tag_object of tag_objects)
|
|
{
|
|
if (tag_object.innerText === response.data.tagname)
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
const li = document.createElement("li");
|
|
const tag_object = document.createElement("a");
|
|
tag_object.className = "tag_object"
|
|
tag_object.href = "/search?tag_musts=" + response.data.tagname;
|
|
tag_object.innerText = response.data.tagname;
|
|
const remove_button = document.createElement("button");
|
|
remove_button.className = "remove_tag_button red_button"
|
|
remove_button.onclick = () => remove_photo_tag_form(PHOTO_ID, response.data.tagname);
|
|
li.appendChild(tag_object);
|
|
li.appendChild(remove_button);
|
|
this_tags.appendChild(li);
|
|
sort_tag_objects();
|
|
}
|
|
|
|
function remove_photo_tag_form(photo_id, tagname)
|
|
{
|
|
api.photos.remove_tag(photo_id, tagname, remove_photo_tag_callback);
|
|
add_tag_box.focus();
|
|
}
|
|
function remove_photo_tag_callback(response)
|
|
{
|
|
add_remove_photo_tag_callback(response);
|
|
if (response.meta.status !== 200)
|
|
{
|
|
return;
|
|
}
|
|
const tag_objects = document.getElementById("this_tags").getElementsByClassName("tag_object");
|
|
for (const tag_object of tag_objects)
|
|
{
|
|
if (tag_object.innerText === response.data.tagname)
|
|
{
|
|
const li = tag_object.parentElement;
|
|
li.parentElement.removeChild(li);
|
|
}
|
|
}
|
|
}
|
|
|
|
function add_remove_photo_tag_callback(response)
|
|
{
|
|
let message_text;
|
|
let message_positivity;
|
|
if ("error_type" in response.data)
|
|
{
|
|
message_positivity = "message_negative";
|
|
message_text = response.data.error_message;
|
|
}
|
|
else
|
|
{
|
|
const tagname = response.data.tagname;
|
|
let action;
|
|
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;
|
|
}
|
|
else
|
|
{
|
|
return;
|
|
}
|
|
}
|
|
common.create_message_bubble(message_area, message_positivity, message_text, 8000);
|
|
}
|
|
|
|
function delete_photo_form()
|
|
{
|
|
api.photos.delete(PHOTO_ID, false, api.photos.callback_go_to_search);
|
|
}
|
|
|
|
function delete_photo_from_disk_form()
|
|
{
|
|
api.photos.delete(PHOTO_ID, true, api.photos.callback_go_to_search);
|
|
}
|
|
|
|
function sort_tag_objects()
|
|
{
|
|
const tag_list = document.getElementById("this_tags");
|
|
const lis = Array.from(tag_list.children).filter(el => el.getElementsByClassName("tag_object").length);
|
|
function compare(li1, li2)
|
|
{
|
|
const tag1 = li1.querySelector(".tag_object:last-of-type").innerText;
|
|
const tag2 = li2.querySelector(".tag_object:last-of-type").innerText;
|
|
return tag1 < tag2 ? -1 : 1;
|
|
}
|
|
lis.sort(compare);
|
|
for (const li of lis)
|
|
{
|
|
tag_list.appendChild(li);
|
|
}
|
|
}
|
|
|
|
function generate_thumbnail_callback(response)
|
|
{
|
|
if (response.meta.status == 200)
|
|
{
|
|
common.create_message_bubble(message_area, "message_positive", "Thumbnail captured", 8000);
|
|
}
|
|
else
|
|
{
|
|
common.create_message_bubble(message_area, "message_negative", response.data.error_message, 8000);
|
|
}
|
|
const generate_thumbnail_button = document.getElementById("generate_thumbnail_button");
|
|
window[generate_thumbnail_button.dataset.spinnerCloser]();
|
|
}
|
|
|
|
function generate_thumbnail_for_video_form(event)
|
|
{
|
|
const timestamp = document.querySelector("#right video").currentTime;
|
|
const special = {"timestamp": timestamp};
|
|
api.photos.generate_thumbnail(PHOTO_ID, special, generate_thumbnail_callback)
|
|
}
|
|
|
|
function refresh_metadata_form()
|
|
{
|
|
api.photos.refresh_metadata(PHOTO_ID, common.refresh);
|
|
}
|
|
|
|
function set_searchhidden_form(event)
|
|
{
|
|
const checkbox = event.target;
|
|
if (checkbox.checked)
|
|
{
|
|
api.photos.set_searchhidden(PHOTO_ID);
|
|
}
|
|
else
|
|
{
|
|
api.photos.unset_searchhidden(PHOTO_ID);
|
|
}
|
|
}
|
|
|
|
const ZOOM_BG_URL = "url('{{photo|file_link}}')";
|
|
function enable_hoverzoom(event)
|
|
{
|
|
//console.log("enable zoom");
|
|
const photo_viewer = document.getElementById("photo_viewer");
|
|
const photo_img = photo_viewer.children[0];
|
|
if (
|
|
photo_img.naturalWidth < photo_viewer.offsetWidth &&
|
|
photo_img.naturalHeight < photo_viewer.offsetHeight
|
|
)
|
|
{
|
|
return;
|
|
}
|
|
photo_img.style.opacity = "0";
|
|
photo_img.style.display = "none";
|
|
photo_viewer.style.cursor = "zoom-out";
|
|
photo_viewer.style.backgroundImage = ZOOM_BG_URL;
|
|
photo_viewer.onmousemove = move_hoverzoom;
|
|
move_hoverzoom(event)
|
|
return true;
|
|
}
|
|
function disable_hoverzoom()
|
|
{
|
|
//console.log("disable zoom");
|
|
const photo_viewer = document.getElementById("photo_viewer");
|
|
const photo_img = photo_viewer.children[0];
|
|
|
|
photo_img.style.opacity = "100";
|
|
photo_viewer.style.cursor = "";
|
|
photo_img.style.display = "";
|
|
photo_viewer.style.backgroundImage = "none";
|
|
photo_viewer.onmousemove = null;
|
|
}
|
|
function toggle_hoverzoom(event)
|
|
{
|
|
const photo_img = document.getElementById("photo_viewer").children[0];
|
|
if (photo_img.style.opacity === "0")
|
|
{
|
|
disable_hoverzoom();
|
|
}
|
|
else
|
|
{
|
|
enable_hoverzoom(event);
|
|
}
|
|
const content_body = document.getElementById('content_body');
|
|
if (getComputedStyle(content_body).getPropertyValue("--narrow") == 0)
|
|
{
|
|
add_tag_box.focus();
|
|
}
|
|
}
|
|
|
|
function move_hoverzoom(event)
|
|
{
|
|
const photo_viewer = document.getElementById("photo_viewer");
|
|
const photo_img = photo_viewer.children[0];
|
|
let x;
|
|
let y;
|
|
|
|
/*
|
|
When clicking on the image, the event handler takes the image as the event
|
|
target even though the handler was assigned to the holder. The coordinates
|
|
for the zoom need to be based on the holder, so when this happens we need
|
|
to adjust the numbers.
|
|
I'm not sure why the offset is the holder's offsetLeft. It seems that when
|
|
the event triggers on the holder, the event X is based on its bounding box,
|
|
but when it triggers on the image it's based on the viewport.
|
|
*/
|
|
let mouse_x = event.offsetX;
|
|
let mouse_y = event.offsetY;
|
|
if (event.target !== photo_viewer)
|
|
{
|
|
mouse_x -= photo_viewer.offsetLeft;
|
|
mouse_y -= photo_viewer.offsetTop;
|
|
}
|
|
|
|
/*
|
|
Adding 5% to perceived position gives us a bit of padding around the image,
|
|
so you don't need to navigate a 1px line to see the edge.
|
|
We first subtract half of the image dimensions so that the 5% is applied
|
|
to both left and right. Otherwise 105% of 0 is still 0 which doesn't
|
|
apply padding on the left.
|
|
*/
|
|
mouse_x -= (photo_viewer.offsetWidth / 2);
|
|
mouse_x *= 1.05;
|
|
mouse_x += (photo_viewer.offsetWidth / 2);
|
|
|
|
mouse_y -= (photo_viewer.offsetHeight / 2);
|
|
mouse_y *= 1.05;
|
|
mouse_y += (photo_viewer.offsetHeight / 2);
|
|
|
|
if (photo_img.naturalWidth < photo_viewer.offsetWidth)
|
|
{
|
|
// If the image is smaller than the frame, just center it
|
|
x = (photo_img.naturalWidth - photo_viewer.offsetWidth) / 2;
|
|
}
|
|
else
|
|
{
|
|
// Take the amount of movement necessary (frame width - image width)
|
|
// times our distance across the image as a percentage.
|
|
x = (photo_img.naturalWidth - photo_viewer.offsetWidth) * (mouse_x / photo_viewer.offsetWidth);
|
|
}
|
|
|
|
if (photo_img.naturalHeight < photo_viewer.offsetHeight)
|
|
{
|
|
y = (photo_img.naturalHeight - photo_viewer.offsetHeight) / 2;
|
|
}
|
|
else
|
|
{
|
|
y = (photo_img.naturalHeight - photo_viewer.offsetHeight) * (mouse_y / photo_viewer.offsetHeight);
|
|
}
|
|
//console.log(x);
|
|
photo_viewer.style.backgroundPosition=(-x)+"px "+(-y)+"px";
|
|
}
|
|
|
|
tag_autocomplete.init_datalist();
|
|
|
|
function autofocus_add_tag_box()
|
|
{
|
|
/*
|
|
If the add_tag_box has autofocus set by the HTML, then when the screen is
|
|
in narrow mode, the autofocusing of the tag box snaps the screen down to it,
|
|
which is annoying. So, this function focuses the box manually as long as
|
|
we're not narrow.
|
|
*/
|
|
const content_body = document.getElementById("content_body");
|
|
if (getComputedStyle(content_body).getPropertyValue("--narrow") == 0)
|
|
{
|
|
add_tag_box.focus();
|
|
}
|
|
}
|
|
|
|
function on_pageload()
|
|
{
|
|
autofocus_add_tag_box();
|
|
}
|
|
document.addEventListener("DOMContentLoaded", on_pageload);
|
|
</script>
|
|
</html>
|