// 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 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 = ` 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'); });