365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
// This function was generated by a machine learning model by OpenAI (ChatGPT),
|
|
// in a sort of "pair programming" session on December 3, 2022. It took a few
|
|
// iterations where I pointed out bugs, but the bot fixed all the mistakes as
|
|
// I pointed them out. It actually generated the SVG <path> tags as strings
|
|
// but I refactored it to only return the angles, which I then apply to tags.
|
|
function clockArms(hour, minute) {
|
|
const hourAngle = ((hour + minute/60) / 12) * 360;
|
|
const minuteAngle = (minute / 60) * 360;
|
|
return {hourAngle, minuteAngle};
|
|
}
|
|
|
|
|
|
async function itemPageMain() {
|
|
const {repoID, rowID} = parseURIPath()
|
|
|
|
const results = await app.SearchItems({
|
|
repo: repoID,
|
|
row_id: [ rowID ],
|
|
related: 1,
|
|
with_size: true,
|
|
limit: 1
|
|
});
|
|
|
|
console.log("RESULT:", results);
|
|
|
|
const item = results.items[0];
|
|
const dt = DateTime.fromISO(item.timestamp, { setZone: true });
|
|
|
|
const {hourAngle, minuteAngle} = clockArms(dt.hour, dt.minute);
|
|
$('#time .hour-hand').setAttribute('transform', `rotate(${hourAngle} 12 12)`);
|
|
$('#time .minute-hand').setAttribute('transform', `rotate(${minuteAngle} 12 12)`);
|
|
|
|
$('#item-id').innerText = item.id;
|
|
|
|
// download button
|
|
// server may need to create a synthetic file for download if it's not a data file already
|
|
$('#download-item').href = `/repo/${repoID}/dl/${item.id}`;
|
|
$('#download-item').download = item.filename || ""; // crucial to avoid in-page nav
|
|
|
|
let itemContentEl = itemContentElement(item, { autoplay: true });
|
|
if (itemContentEl) {
|
|
if (itemContentEl.dataset.contentType == "text") {
|
|
// set font size relative to length of text, with bounds (20-100px) (the slope is somewhat arbitrary)
|
|
// TODO: consider viewport size too (if approx. text space is length * fontSize, maybe a ratio to screen width * height?)
|
|
// TODO: then re-compute on window resize
|
|
itemContentEl.classList.add('item-content-text');
|
|
const size = item.size || item.data_text?.length;
|
|
const fontSize = Math.min(3500 * (1/size) + 20, 100);
|
|
itemContentEl.style.fontSize = `${fontSize}px`;
|
|
|
|
const card = document.createElement('div');
|
|
card.classList.add('card');
|
|
card.appendChild(itemContentEl);
|
|
itemContentEl = card;
|
|
}
|
|
|
|
$('#item-content').append(itemContentEl);
|
|
}
|
|
|
|
if (item.entity) {
|
|
$('#primary-entities').classList.remove('d-none');
|
|
|
|
const display = entityDisplayNameAndAttr(item.entity);
|
|
$('#item-owner-name').innerText = display.name;
|
|
$('#item-owner-picture').innerHTML = avatar(true, item.entity);
|
|
$('#item-owner-attribute').innerText = display.attribute;
|
|
$('#entity-link').href = `/entities/${repoID}/${item.entity.id}`;
|
|
}
|
|
|
|
const tsDisplay = itemTimestampDisplay(item);
|
|
if (tsDisplay.dateWithWeekday) {
|
|
$('#item-date').innerText = tsDisplay.dateWithWeekday;
|
|
$('#item-relative-time').innerText = dt.toRelative();
|
|
} else {
|
|
$('#item-date').parentElement.remove();
|
|
$('#item-relative-time').remove();
|
|
}
|
|
if (tsDisplay.time) {
|
|
$('#item-time').innerText = tsDisplay.timeWithZone;
|
|
} else {
|
|
$('#item-time').parentElement.remove();
|
|
}
|
|
|
|
const itemClass = tlz.itemClassIconAndLabel(item);
|
|
$('#item-class-icon').innerHTML = itemClass.icon;
|
|
$('#item-class-label').innerText = itemClass.label;
|
|
|
|
if (item.data_type?.startsWith("image/")) {
|
|
$('#item-type-label').innerText = "picture";
|
|
} else if (item.data_type?.startsWith("video/")) {
|
|
$('#item-type-label').innerText = "video";
|
|
} else if (item.data_type?.startsWith("audio/")) {
|
|
$('#item-type-label').innerText = "recording";
|
|
} else if (item.data_type?.startsWith("text/")) {
|
|
$('#item-type-label').innerText = "document";
|
|
}
|
|
|
|
if (item.original_path) {
|
|
$('#item-original-path').innerText = item.original_path;
|
|
} else if (item.filename) {
|
|
$('#item-original-path').innerText = item.filename;
|
|
} else {
|
|
$('#item-original-path').parentElement.remove();
|
|
}
|
|
if (item.size) {
|
|
$('#item-size').innerText = humanizeBytes(item.size);
|
|
} else {
|
|
$('#item-size').parentElement.remove();
|
|
}
|
|
if (item.data_type) {
|
|
$('#item-media-type').innerText = item.data_type;
|
|
} else {
|
|
$('#item-media-type').parentElement.remove();
|
|
}
|
|
if (item.original_id) {
|
|
$('#item-original-id').innerText = item.original_id;
|
|
$('#item-original-id').parentNode.classList.remove('d-none');
|
|
}
|
|
if (item.original_location) {
|
|
$('#item-original-location').innerText = item.original_location;
|
|
} else {
|
|
$('#item-original-location').parentElement.remove();
|
|
}
|
|
if (item.intermediate_location) {
|
|
$('#item-intermediate-location').innerText = item.intermediate_location;
|
|
} else {
|
|
$('#item-intermediate-location').parentElement.remove();
|
|
}
|
|
if (item.latitude || item.longitude || item.altitude) {
|
|
let coord = "";
|
|
if (item.latitude) {
|
|
coord = item.latitude.toFixed(5);
|
|
}
|
|
if (item.longitude) {
|
|
if (coord) {
|
|
coord += ", "
|
|
}
|
|
coord += item.longitude.toFixed(5);
|
|
}
|
|
$('#item-coordinate').innerText = coord;
|
|
} else {
|
|
$('#item-coordinate').parentElement.remove();
|
|
}
|
|
if (item.coordinate_uncertainty) {
|
|
$('#item-coordinate-uncertainty').innerText = item.coordinate_uncertainty;
|
|
} else {
|
|
$('#item-coordinate-uncertainty').parentElement.remove();
|
|
}
|
|
if (item.altitude) {
|
|
$('#item-altitude').innerText = Math.round(item.altitude * 3.28084) + " ft";
|
|
} else {
|
|
$('#item-altitude').parentElement.remove();
|
|
}
|
|
$('#item-stored').innerText = DateTime.fromISO(item.stored, { setZone: true }).toLocaleString(DateTime.DATETIME_MED_WITH_SECONDS);
|
|
|
|
$('#data-source-icon').style.backgroundImage = `url('/ds-image/${item.data_source_name}')`;
|
|
$('#data-source-title').innerText = item.data_source_title;
|
|
|
|
// only show minimap if big map is not displayed
|
|
if (!$('.map-container') && item.latitude && item.longitude) {
|
|
const mapElem = cloneTemplate('#map-container');
|
|
mapElem.classList.add('ratio-4x3');
|
|
|
|
const minimapRenderFn = function() {
|
|
const marker = new mapboxgl.Marker().setLngLat([item.longitude, item.latitude]);
|
|
tlz.map.dragRotate.disable();
|
|
tlz.map.tl_addMarker(marker);
|
|
tlz.map.flyTo({
|
|
center: [item.longitude, item.latitude],
|
|
zoom: 13
|
|
});
|
|
};
|
|
|
|
tlz.map.tl_containers.set(mapElem, minimapRenderFn);
|
|
|
|
$('#minimap-container').classList.remove('d-none');
|
|
$('#minimap-container').append(mapElem);
|
|
}
|
|
|
|
|
|
|
|
// TODO: see similar code in items.js -- make this standard somehow? (refactor)
|
|
if (item.related) {
|
|
for (let rel of item.related) {
|
|
if (rel.label == 'attachment' && rel.to_item) {
|
|
const attachTpl = cloneTemplate('#tpl-attachment');
|
|
|
|
const attachmentElem = itemContentElement(rel.to_item, { thumbnail: true });
|
|
$('.card-img-bottom', attachTpl).appendChild(attachmentElem);
|
|
|
|
$('.card-body', attachTpl).innerHTML = `
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-paperclip" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
|
|
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
|
|
<path d="M15 7l-6.5 6.5a1.5 1.5 0 0 0 3 3l6.5 -6.5a3 3 0 0 0 -6 -6l-6.5 6.5a4.5 4.5 0 0 0 9 9l6.5 -6.5"></path>
|
|
</svg>
|
|
Attached`;
|
|
|
|
$('a.card-link', attachTpl).href = `/items/${repoID}/${rel.to_item.id}`;
|
|
|
|
$('#related-items').append(attachTpl);
|
|
$('#related-items-container').classList.remove('d-none');
|
|
|
|
} else if ((rel.label == 'sent' || rel.label == 'cc') && rel.to_entity) {
|
|
$('#primary-entities').classList.remove('d-none');
|
|
$('#related-entities').classList.remove('d-none');
|
|
$('#related-entities-sent-to').classList.remove('d-none');
|
|
|
|
const entTpl = cloneTemplate('#tpl-related-entity');
|
|
|
|
$('.entity-picture', entTpl).innerHTML = avatar(true, rel.to_entity, 'avatar-xs');
|
|
|
|
const entityDisplay = entityDisplayNameAndAttr(rel.to_entity);
|
|
$('.entity-name', entTpl).innerText = entityDisplay.name;
|
|
$('.entity-attr', entTpl).innerText = entityDisplay.attribute;
|
|
entTpl.href = `/entities/${repoID}/${rel.to_entity.id}`;
|
|
|
|
$('#related-entities-included').before(entTpl);
|
|
|
|
} else if (rel.label == 'includes' && rel.to_entity) {
|
|
// show the entity in the list on the sidebar
|
|
$('#related-entities').classList.remove('d-none');
|
|
$('#related-entities-included').classList.remove('d-none');
|
|
const entTpl = cloneTemplate('#tpl-included-entity');
|
|
$('.entity-picture', entTpl).innerHTML = avatar(true, rel.to_entity, 'avatar-xs');
|
|
const entityDisplay = entityDisplayNameAndAttr(rel.to_entity);
|
|
$('.entity-name', entTpl).innerText = entityDisplay.name;
|
|
$('.entity-attr', entTpl).innerText = entityDisplay.attribute;
|
|
// TODO: instead of going to the entity page, open a modal with more relationship details
|
|
entTpl.href = `/entities/${repoID}/${rel.to_entity.id}`;
|
|
|
|
function addCircle(centerX, centerY, sizeX, sizeY) {
|
|
const boxEl = document.createElement('div');
|
|
boxEl.id = `face-circle-rel-${rel.relationship_id}`;
|
|
boxEl.classList.add('face-circle', 'd-none');
|
|
boxEl.style.width = `${sizeX*100}%`;
|
|
boxEl.style.height = `${sizeY*100}%`;
|
|
boxEl.style.left = `${(centerX-sizeX/2)*100}%`
|
|
boxEl.style.bottom = `${(centerY-sizeY/2)*100}%`; // NOTE: Apple's y-coord is from the bottom, not top!!
|
|
boxEl.innerText = rel.to_entity.name || "unknown";
|
|
$('#item-content').append(boxEl);
|
|
|
|
// show the face circle when the name is hovered in the sidebar
|
|
entTpl.onmouseover = function() {
|
|
boxEl.classList.remove('d-none');
|
|
};
|
|
entTpl.onmouseout = function() {
|
|
boxEl.classList.add('d-none');
|
|
};
|
|
}
|
|
|
|
// then, if we have face detection info, show it on hover of their name
|
|
// (if not face directly, "body detection" will work also)
|
|
if (rel.metadata?.["Center X"] && rel.metadata?.["Center Y"] && rel.metadata?.["Size"]) {
|
|
// small faces/outlines are harder to see, so we want those to appear a little larger,
|
|
// whereas larger ones don't need to be scaled up as much
|
|
const sizeScaled = rel.metadata["Size"] * (rel.metadata["Size"] > 0.05 ? 2 : 4);
|
|
addCircle(rel.metadata["Center X"], rel.metadata["Center Y"], sizeScaled, sizeScaled);
|
|
} else if (rel.metadata?.["Body center X"] && rel.metadata?.["Body center Y"] && rel.metadata?.["Body width"] && rel.metadata?.["Body height"]) {
|
|
addCircle(rel.metadata["Body center X"], rel.metadata["Body center Y"], rel.metadata?.["Body width"], rel.metadata?.["Body height"])
|
|
}
|
|
|
|
$('#related-entities').append(entTpl);
|
|
$('#primary-entities').classList.remove('d-none');
|
|
if (!item.owner) {
|
|
$('#related-entities').classList.add('border-top-0');
|
|
$('#entity-link').classList.add('d-none');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const key in item.metadata) {
|
|
const tpl = cloneTemplate('#tpl-metadata');
|
|
$('.datagrid-title', tpl).innerText = key;
|
|
$('.datagrid-content', tpl).innerText = item.metadata[key];
|
|
$('#item-metadata').append(tpl);
|
|
}
|
|
|
|
// For images, see if there is a motion photo associated with it
|
|
if (item.data_type?.startsWith("image/")) {
|
|
|
|
function renderMotionPhoto(videoSrc) {
|
|
const video = document.createElement('video');
|
|
video.classList.add('invisible','position-absolute'); // prevent empty space that gets shifted around during load
|
|
|
|
// apparently canplay/canplaythrough fire multiple times if the video loops or we change the currentTime property
|
|
let canPlay = false;
|
|
video.addEventListener('canplay', e => {
|
|
if (canPlay) return;
|
|
canPlay = true;
|
|
|
|
$('#item-content .thumbhash-container')?.classList?.add('d-none');
|
|
$$('#item-content .content').forEach(elem => elem.classList.add('d-none')); // multiple .content elems? yes, in case a thumbhash is used with an image
|
|
video.classList.remove('invisible', 'position-absolute');
|
|
video.classList.add('fade-in');
|
|
setTimeout(function() {
|
|
video.classList.remove('fade-in');
|
|
}, 1000);
|
|
const tpl = cloneTemplate('#tpl-motionpicture');
|
|
$('#item-content').append(tpl);
|
|
video.muted = true; // for some reason, Chrome ignores the "muted" attribute, might be a bug: https://stackoverflow.com/a/51189390
|
|
video.play();
|
|
});
|
|
video.addEventListener('error', (event, err) => {
|
|
console.error("loading video:", event, err);
|
|
video.remove();
|
|
});
|
|
|
|
video.setAttribute("controls", "");
|
|
video.setAttribute("loop", "");
|
|
video.setAttribute("autoplay", "");
|
|
video.setAttribute("muted", "");
|
|
video.src = videoSrc;
|
|
|
|
$('#item-content').prepend(video);
|
|
}
|
|
|
|
// we can avoid another DB lookup if we just give back some info we already have
|
|
const query = new URLSearchParams({
|
|
data_file: item.data_file || ""
|
|
});
|
|
|
|
// look for a related item labeled 'motion' that might already be known;
|
|
// giving the server this hint could save a DB query
|
|
if (item.related) {
|
|
for (const rel of item.related) {
|
|
if (rel.label != 'motion') {
|
|
continue;
|
|
}
|
|
query.set("hint", rel?.to_item?.data_file);
|
|
const mpSrc = `/repo/${repoID}/motion-photo/${item.id}?${query.toString()}`;
|
|
renderMotionPhoto(mpSrc);
|
|
return;
|
|
}
|
|
}
|
|
|
|
renderMotionPhoto(`/repo/${repoID}/motion-photo/${item.id}?${query.toString()}`);
|
|
}
|
|
}
|
|
|
|
// TODO: The still photo of Samsung Live Photos is apparently the last frame of the embedded video.
|
|
// See: https://github.com/photoprism/photoprism/issues/439#issuecomment-1691477054
|
|
// This can make for a nice transition effect like what Google Photos does.
|
|
|
|
// toggle motion picture or still photo
|
|
on('click', '.motionpic-controls .icon-tabler-player-pause', e => {
|
|
const pauseButton = e.target.closest('a');
|
|
const playButton = $('.icon-tabler-player-play', pauseButton.parentElement);
|
|
pauseButton.classList.add('d-none');
|
|
playButton.classList.remove('d-none');
|
|
$('#item-content video').classList.add('d-none');
|
|
$$('#item-content .content').forEach(elem => elem.classList.remove('d-none')); // multiple? yes, in case a thumbhash is used with an image
|
|
$('#item-content .thumbhash-container')?.classList?.remove('d-none');
|
|
$('#item-content video').pause();
|
|
});
|
|
on('click', '.motionpic-controls .icon-tabler-player-play', e => {
|
|
const playButton = e.target.closest('a');
|
|
const pauseButton = $('.icon-tabler-player-pause', playButton.parentElement);
|
|
playButton.classList.add('d-none');
|
|
pauseButton.classList.remove('d-none');
|
|
$('#item-content video').play();
|
|
$('#item-content video').classList.remove('d-none');
|
|
$$('#item-content .content').forEach(elem => elem.classList.add('d-none'));
|
|
$('#item-content .thumbhash-container')?.classList?.add('d-none');
|
|
});
|