From 09e07320a6d37bf830e5a5fbf0820a9887dfda95 Mon Sep 17 00:00:00 2001 From: James Ketrenos Date: Tue, 4 Feb 2020 21:07:49 -0800 Subject: [PATCH] Update 'authenticate' email Signed-off-by: James Ketrenos --- server/app.js | 4 +- src/App.css | 122 +++++++++++++++++ src/App.js | 348 ++++++++++++++++++++++++++++++++++++++++++++++++- webpack.dev.js | 8 +- 4 files changed, 478 insertions(+), 4 deletions(-) diff --git a/server/app.js b/server/app.js index 9167f50..592034c 100755 --- a/server/app.js +++ b/server/app.js @@ -118,7 +118,7 @@ const templates = { "
{{notes}}
", "", "

To authenticate:

", - "

echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 users.db

", + "

echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 db/users.db

", "", "

Sincerely,
", "James

" @@ -130,7 +130,7 @@ const templates = { "{{notes}}", "", "To authenticate:", - "echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 users.db", + "echo 'UPDATE users SET authenticated=1 WHERE id={{id}};' | sqlite3 db/users.db", "", "Sincerely,", "James" diff --git a/src/App.css b/src/App.css index 35184de..fbef130 100755 --- a/src/App.css +++ b/src/App.css @@ -188,4 +188,126 @@ a.Link { .OnScreen { top: 0px; +} + +body { + margin: 0; + padding: 0; +} + +* { + box-sizing: border-box; +} + +.more { + cursor: pointer; +} + +#face-editor { + max-width: calc(128px * 4); +} + +#face-editor .related-faces { + display: flex; + flex-wrap: wrap; +} + +#new-identity { + padding: 0.5em; +} + +#identities { + display: flex; + flex-wrap: wrap; + border: 1px solid black; + padding: 0.25em; + margin: 0.25em; +} + +#identities > div { + border: 1px solid black; + margin: 0.25em; + padding: 0.25em; + box-sizing: border-box; +} + +.more:hover { + text-decoration: underline; +} + +.face[disabled] { + filter: grayscale(100%); +} + +.face-block { + display: flex; + flex-wrap: wrap; + margin: 0.5em; + min-width: 128px; + max-width: 128px; + min-height: 128px; + max-height: 128px; +} + +.face-block-nolimit { + min-width: 128px; + max-width: initial; + min-height: 128px; + max-height: initial; +} + +.face-block .face { + max-width: 64px; + max-height: 64px; + margin: 0; +} + +.face { + position: relative; + max-width: 128px; + max-height: 128px; + width: 128px; + height: 128px; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + display: inline-block; + border: 1px solid black; + margin: 0.5em; + cursor: pointer; +} + +.face:hover { + box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.5); +} + +.face .distance { + position: absolute; + background: black; + opacity: 0.5; + text-align: center; + left: 0; + right: 0; + bottom: 0; + color: white; +} + +.editor-row { + display: flex; + flex-direction: row; + padding: 0.25em 0.5em; +} + +.editor-row .key { + width: 10em; +} + +.Identities { + display:flex; + flex-direction:column; +} + +.Identities > div:first-child { + display:flex; + flex-direction:row; } \ No newline at end of file diff --git a/src/App.js b/src/App.js index 011d428..8a70499 100755 --- a/src/App.js +++ b/src/App.js @@ -57,6 +57,44 @@ class Footer extends React.Component { function noChange() {}; +class Identities extends React.Component { + constructor(props) { + super(props); + } + componentDidMount() { + let params = window.location.search ? window.location.search.replace(/^\?/, "").split("&") : [], + id; + + for (let i in params) { + let parts = params[i].split("="); + switch (parts[0]) { + case "id": + id = parseInt(parts[1]); + if (id != parts[1]) { + id = undefined; + } + break; + } + } + loadFace(id); + const newIdentity = document.getElementById("new-identity"); + newIdentity.appendChild(createNewIdenityEditor()); + } + + render() { + return ( +
+
+
+
+
+
+
+
+ ) + } +} + class App extends React.Component { constructor(props) { super(props); @@ -75,7 +113,7 @@ class App extends React.Component {
-
}/> + }/>
}/>
}/> @@ -88,3 +126,311 @@ class App extends React.Component { } export default withRouter(App); + +/* + +1. Query server for a face with no bound identity. +2. Query for faces related to that face +3. Put a UI that let's the user pick from existing identities. +4. Select / Deselect all faces that match +5. "Match" to bind the faces to the identity +6. Create new Identity + +*/ + +function createFace(faceId, photoId, selectable) { + var div = document.createElement("div"); + div.classList.add("face"); + div.setAttribute("photo-id", photoId); + div.setAttribute("face-id", faceId); + div.style.backgroundImage = "url(face-data/" + (faceId % 100) + "/" + faceId + "-original.png)"; + div.addEventListener("click", (event) => { + if (event.shiftKey) { /* identities */ + let faceId = parseInt(event.currentTarget.getAttribute("face-id")); + if (faceId) { + window.location.href = "identities.html?id=" + faceId; + } else { + alert("No face id mapped to face."); + } + } else if (!selectable || event.ctrlKey) { /* face-explorer */ + let photoId = parseInt(event.currentTarget.getAttribute("photo-id")); + if (photoId) { + window.open("face-explorer.html?" + photoId, "photo-" + photoId); + } else { + alert("No photo id mapped to face."); + } + } else { + if (event.currentTarget.hasAttribute("disabled")) { + event.currentTarget.removeAttribute("disabled"); + } else { + event.currentTarget.setAttribute("disabled", ""); + } + } + }); + return div; +} + +function loadFace(id) { + const faceEditorBlock = document.getElementById("face-editor"); + faceEditorBlock.innerHTML = ""; + + window.fetch("api/v1/faces" + (id ? "/" + id : "")).then(res => res.json()).then((faces) => { + if (faces.length == 0) { + return; + } + + const face = faces[0]; + + if (face.identityId) { + getIdentity(face.identityId); + } else { + getIdentities(face.id); + } + + const editor = document.createElement("div"); + editor.classList.add("editor"); + editor.appendChild(createFace(face.id, face.photoId)); + let row = document.createElement("div"), + label = document.createElement("div"), + a = document.createElement("a"); + row.classList.add("editor-row"); + label.textContent = "View photo: "; + row.appendChild(label); + a.setAttribute("target", "photo-" + face.photoId); + a.href = "face-explorer.html?" + face.photoId; + a.textContent = "photo " + face.photoId; + row.appendChild(a); + editor.appendChild(row); + row = document.createElement("div"); + row.classList.add("editor-row"); + let message = "Unassigned related faces: "; + if (face.relatedFaces.length > 0) { + message += face.relatedFaces.length + " related (tap to deselect)"; + } else { + message += "No matches found."; + } + row.textContent = message; + editor.appendChild(row); + + row = document.createElement("div"); + row.classList.add("editor-row"); + row.classList.add("related-faces"); + + editor.appendChild(row); + + let button = document.createElement("button"); + button.textContent = "show more"; + button.addEventListener("click", (event) => { + showMore(row, face.relatedFaces); + }); + + editor.appendChild(button); + + showMore(row, face.relatedFaces); + + faceEditorBlock.appendChild(editor); + }); +} + +function showMore(row, faces) { + let index = row.querySelectorAll(".face").length, + max = Math.min(faces.length - index, 9); + let more = index + max < faces.length; + for (let i = index; i < index + max; i++) { + let relation = faces[i]; + const distance = document.createElement("div"), + facePhoto = createFace(relation.faceId, relation.photoId, true); + distance.classList.add("distance"); + distance.textContent = relation.distance.toFixed(2); + facePhoto.appendChild(distance); + row.appendChild(facePhoto); + } + if (!more) { + row.parentElement.querySelector("button").style.display = "none"; + } +} + + +function getIdentity(identityId) { + const identitiesBlock = document.getElementById("identities"); + identitiesBlock.innerHTML = ""; + + window.fetch("api/v1/identities/" + identityId).then(res => res.json()).then((identities) => { + identities.forEach((identity) => { + const block = createIdentityBlock(identity, true), + button = createUseThisIdentityButton(identity); + block.insertBefore(button, block.firstChild); + identitiesBlock.appendChild(block); + }) + }); +} + +function getIdentities(faceId) { + const identitiesBlock = document.getElementById("identities"); + identitiesBlock.innerHTML = ""; + + const search = faceId ? "?withScore=" + faceId : ""; + window.fetch("api/v1/identities" + search).then(res => res.json()).then((identities) => { + identities.sort((a, b) => { + if (a.lastName == b.lastName) { + return a.firstName.localeCompare(b.firstName); + } + return a.lastName.localeCompare(b.lastName); + }); + + identities.forEach((identity) => { + const block = createIdentityBlock(identity), + button = createUseThisIdentityButton(identity); + block.insertBefore(button, block.firstChild); + identitiesBlock.appendChild(block); + }); + }); +} + +function createUseThisIdentityButton(identity) { + const button = document.createElement("button"); + button.textContent = "Use this identity"; + button.addEventListener("click", (event) => { + const object = { + faces: [] + }; + Array.prototype.forEach.call(document.body.querySelectorAll("#face-editor .face"), (face) => { + if (!face.hasAttribute("disabled")) { + object.faces.push(face.getAttribute("face-id")); + } + }); + window.fetch("api/v1/identities/faces/add/" + identity.id, { + method: "PUT", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(object) + }).then(res => res.json()).then((identities) => { + console.log("Updated identity: ", identities[0]); + const face = document.body.querySelector("#face-editor .face"); + loadFace(parseInt(face.getAttribute("face-id"))); + }); + }); + return button; +} + +function createIdentityBlock(identity, nolimit) { + const block = document.createElement("div"); + + block.classList.add("block"); + let div = document.createElement("div"); + div.textContent = identity.name; + + block.appendChild(div); + + div = document.createElement("div"); + div.textContent = "Related faces " + identity.relatedFaces.length; + block.appendChild(div); + + identity.relatedFaces.sort((a, b) => { + return a.distance - b.distance; + }); + + div = document.createElement("div"); + div.classList.add("face-block"); + if (nolimit) { + div.classList.add("face-block-nolimit"); + } + + let minDistance = { + distance: 1 + }; + + for (let i = 0; i < identity.relatedFaces.length; i++) { + if (!nolimit && i >= 4) { + break; + } + const target = identity.relatedFaces[i]; + const facePhoto = createFace(target.faceId, target.photoId), + distance = document.createElement("div"); + + if (target.distance < minDistance.distance) { + minDistance.distance = target.distance; + minDistance.photoId = target.photoId; + minDistance.faceId = target.faceId; + } + distance.classList.add("distance"); + distance.textContent = target.distance.toFixed(2); + facePhoto.appendChild(distance); + div.appendChild(facePhoto); + } + + if (minDistance.distance < 0.45) { + const bestMatch = document.getElementById("best-match"); + const distance = document.createElement("div"), + facePhoto = createFace(minDistance.faceId, minDistance.photoId); + distance.classList.add("distance"); + distance.textContent = minDistance.distance.toFixed(2); + facePhoto.appendChild(distance); + + bestMatch.innerHTML = ""; + bestMatch.appendChild(facePhoto); + } else { + const bestMatch = document.getElementById("best-match"); + bestMatch.innerHTML = "No best guess for match."; + } + + block.appendChild(div); + + return block; +} + + +function createNewIdenityEditor() { + const block = document.createElement("div"); + block.classList.add("block"); + const editor = document.createElement("div"); + editor.classList.add("editor"); + + const button = document.createElement("button"); + button.textContent = "New Identity"; + editor.appendChild(button); + + [ "lastName", "firstName", "middleName", "name" ].forEach((key) => { + const row = document.createElement("div"), + left = document.createElement("div"), + right = document.createElement("input"); + row.classList.add("editor-row"); + left.classList.add("key"); + left.textContent = key; + row.appendChild(left); + row.appendChild(right); + editor.appendChild(row); + }); + + button.addEventListener("click", (event) => { + const rows = event.currentTarget.parentElement.querySelectorAll(".editor-row"), + object = {}; + Array.prototype.forEach.call(rows, (row) => { + object[row.firstChild.textContent] = row.lastChild.value; + }); + object.faces = []; + Array.prototype.forEach.call(document.body.querySelectorAll("#face-editor .face"), (face) => { + if (!face.hasAttribute("disabled")) { + object.faces.push(face.getAttribute("face-id")); + } + }); + console.log(object); + window.fetch("api/v1/identities", { + method: "POST", + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json' + }, + body: JSON.stringify(object) + }).then(res => res.json()).then((identities) => { + console.log("New identity: ", identities[0]); + const face = document.body.querySelector("#face-editor .face"); + loadFace(parseInt(face.getAttribute("face-id"))); + }); + }); + block.appendChild(editor); + + return block; +} \ No newline at end of file diff --git a/webpack.dev.js b/webpack.dev.js index fc08e2e..967dcf7 100644 --- a/webpack.dev.js +++ b/webpack.dev.js @@ -11,7 +11,13 @@ module.exports = merge(common, { publicPath: "http://localhost:8765/dist/", hotOnly: true, disableHostCheck: true, - historyApiFallback: true + historyApiFallback: true, + proxy: { + "/api": "http://localhost:8123/photos", + "/photos/*": { + target: "http://localhost:8123" + } + }, }, plugins: [new webpack.HotModuleReplacementPlugin()] });