diff --git a/.prettierignore b/.prettierignore
new file mode 100644
index 00000000..36c817ea
--- /dev/null
+++ b/.prettierignore
@@ -0,0 +1 @@
+/app/assets/javascripts/lib
diff --git a/app/assets/javascripts/ajax_auth.js b/app/assets/javascripts/ajax_auth.js
index 27f81bbb..81fd79d5 100644
--- a/app/assets/javascripts/ajax_auth.js
+++ b/app/assets/javascripts/ajax_auth.js
@@ -1,20 +1,20 @@
(function () {
- var CSRFProtection;
- var token = $('meta[name="csrf-token"]').attr("content");
- if (token) {
- CSRFProtection = function (xhr, settings) {
- var sendToken =
- typeof settings.useCSRFProtection === "undefined" || // default to true
- settings.useCSRFProtection;
- if (sendToken) {
- xhr.setRequestHeader("X-CSRF-Token", token);
- }
- };
- } else {
- CSRFProtection = $.noop;
- }
+ var CSRFProtection;
+ var token = $('meta[name="csrf-token"]').attr("content");
+ if (token) {
+ CSRFProtection = function (xhr, settings) {
+ var sendToken =
+ typeof settings.useCSRFProtection === "undefined" || // default to true
+ settings.useCSRFProtection;
+ if (sendToken) {
+ xhr.setRequestHeader("X-CSRF-Token", token);
+ }
+ };
+ } else {
+ CSRFProtection = $.noop;
+ }
- $.ajaxSetup({
- beforeSend: CSRFProtection,
- });
+ $.ajaxSetup({
+ beforeSend: CSRFProtection,
+ });
})();
diff --git a/app/assets/javascripts/closet_hangers/index.js b/app/assets/javascripts/closet_hangers/index.js
index c78019b0..429a5c43 100644
--- a/app/assets/javascripts/closet_hangers/index.js
+++ b/app/assets/javascripts/closet_hangers/index.js
@@ -1,842 +1,842 @@
(function () {
- var hangersInitCallbacks = [];
+ var hangersInitCallbacks = [];
- function onHangersInit(callback) {
- hangersInitCallbacks[hangersInitCallbacks.length] = callback;
- }
+ function onHangersInit(callback) {
+ hangersInitCallbacks[hangersInitCallbacks.length] = callback;
+ }
- function hangersInit() {
- for (var i = 0; i < hangersInitCallbacks.length; i++) {
- try {
- hangersInitCallbacks[i]();
- } catch (error) {
- console.error(error);
- }
- }
- }
+ function hangersInit() {
+ for (var i = 0; i < hangersInitCallbacks.length; i++) {
+ try {
+ hangersInitCallbacks[i]();
+ } catch (error) {
+ console.error(error);
+ }
+ }
+ }
- /*
+ /*
Hanger groups
*/
- var hangerGroups = [];
+ var hangerGroups = [];
- $(".closet-hangers-group").each(function () {
- var el = $(this);
- var lists = [];
+ $(".closet-hangers-group").each(function () {
+ var el = $(this);
+ var lists = [];
- el.find("div.closet-list").each(function () {
- var el = $(this);
- var id = el.attr("data-id");
- if (id) {
- lists[lists.length] = {
- id: parseInt(id, 10),
- label: el.find("h4").text(),
- };
- }
- });
+ el.find("div.closet-list").each(function () {
+ var el = $(this);
+ var id = el.attr("data-id");
+ if (id) {
+ lists[lists.length] = {
+ id: parseInt(id, 10),
+ label: el.find("h4").text(),
+ };
+ }
+ });
- hangerGroups[hangerGroups.length] = {
- label: el.find("h3").text(),
- lists: lists,
- owned: el.attr("data-owned") == "true",
- };
- });
+ hangerGroups[hangerGroups.length] = {
+ label: el.find("h3").text(),
+ lists: lists,
+ owned: el.attr("data-owned") == "true",
+ };
+ });
- $(".closet-hangers-group span.toggle").live("click", function () {
- $(this).closest(".closet-hangers-group").toggleClass("hidden");
- });
+ $(".closet-hangers-group span.toggle").live("click", function () {
+ $(this).closest(".closet-hangers-group").toggleClass("hidden");
+ });
- var hangersElQuery = "#closet-hangers";
- var hangersEl = $(hangersElQuery);
+ var hangersElQuery = "#closet-hangers";
+ var hangersEl = $(hangersElQuery);
- /*
+ /*
Compare with Your Items
*/
- $("#toggle-compare").click(function () {
- hangersEl.toggleClass("comparing");
- });
+ $("#toggle-compare").click(function () {
+ hangersEl.toggleClass("comparing");
+ });
- // Read the item IDs of trade matches from the meta tags.
- const ownedIds =
- document
- .querySelector("meta[name=trade-matches-owns]")
- ?.getAttribute("value")
- ?.split(",") ?? [];
- const wantedIds =
- document
- .querySelector("meta[name=trade-matches-wants]")
- ?.getAttribute("value")
- ?.split(",") ?? [];
+ // Read the item IDs of trade matches from the meta tags.
+ const ownedIds =
+ document
+ .querySelector("meta[name=trade-matches-owns]")
+ ?.getAttribute("value")
+ ?.split(",") ?? [];
+ const wantedIds =
+ document
+ .querySelector("meta[name=trade-matches-wants]")
+ ?.getAttribute("value")
+ ?.split(",") ?? [];
- // Apply the `user-owns` and `user-wants` classes to the relevant entries.
- // This both provides immediate visual feedback, and sets up "Compare with
- // Your Items" to toggle to just them!
- //
- // NOTE: The motivation here is caching: this allows us to share a cache of
- // the closet list contents across all users, without `user-owns` or
- // `user-wants` classes for one specific user getting cached and reused.
- const hangerEls = document.querySelectorAll("#closet-hangers .object");
- for (const hangerEl of hangerEls) {
- const itemId = hangerEl.getAttribute("data-item-id");
- if (ownedIds.includes(itemId)) {
- hangerEl.classList.add("user-owns");
- }
- if (wantedIds.includes(itemId)) {
- hangerEl.classList.add("user-wants");
- }
- }
+ // Apply the `user-owns` and `user-wants` classes to the relevant entries.
+ // This both provides immediate visual feedback, and sets up "Compare with
+ // Your Items" to toggle to just them!
+ //
+ // NOTE: The motivation here is caching: this allows us to share a cache of
+ // the closet list contents across all users, without `user-owns` or
+ // `user-wants` classes for one specific user getting cached and reused.
+ const hangerEls = document.querySelectorAll("#closet-hangers .object");
+ for (const hangerEl of hangerEls) {
+ const itemId = hangerEl.getAttribute("data-item-id");
+ if (ownedIds.includes(itemId)) {
+ hangerEl.classList.add("user-owns");
+ }
+ if (wantedIds.includes(itemId)) {
+ hangerEl.classList.add("user-wants");
+ }
+ }
- /*
+ /*
Hanger forms
*/
- var body = $(document.body).addClass("js");
- if (!body.hasClass("current-user")) return false;
+ var body = $(document.body).addClass("js");
+ if (!body.hasClass("current-user")) return false;
- // When we get hangers HTML, add the controls. We do this in JS rather than
- // in the HTML for caching, since otherwise the requests can take forever.
- // If there were another way to add hangers, then we'd have to worry about
- // that, but, right now, the only way to create a new hanger from this page
- // is through the autocompleter, which reinitializes anyway. Geez, this thing
- // is begging for a rewrite, but today we're here for performance.
- $("#closet-hanger-update-tmpl").template("updateFormTmpl");
- $("#closet-hanger-destroy-tmpl").template("destroyFormTmpl");
- onHangersInit(function () {
- // Super-lame hack to get the user ID from where it already is :/
- var currentUserId = itemsSearchForm.data("current-user-id");
- $("#closet-hangers .closet-hangers-group").each(function () {
- var groupEl = $(this);
- var owned = groupEl.data("owned");
+ // When we get hangers HTML, add the controls. We do this in JS rather than
+ // in the HTML for caching, since otherwise the requests can take forever.
+ // If there were another way to add hangers, then we'd have to worry about
+ // that, but, right now, the only way to create a new hanger from this page
+ // is through the autocompleter, which reinitializes anyway. Geez, this thing
+ // is begging for a rewrite, but today we're here for performance.
+ $("#closet-hanger-update-tmpl").template("updateFormTmpl");
+ $("#closet-hanger-destroy-tmpl").template("destroyFormTmpl");
+ onHangersInit(function () {
+ // Super-lame hack to get the user ID from where it already is :/
+ var currentUserId = itemsSearchForm.data("current-user-id");
+ $("#closet-hangers .closet-hangers-group").each(function () {
+ var groupEl = $(this);
+ var owned = groupEl.data("owned");
- groupEl.find("div.closet-list").each(function () {
- var listEl = $(this);
- var listId = listEl.data("id");
+ groupEl.find("div.closet-list").each(function () {
+ var listEl = $(this);
+ var listId = listEl.data("id");
- listEl.find("div.object").each(function () {
- var hangerEl = $(this);
- var hangerId = hangerEl.data("id");
- var quantityEl = hangerEl.find("div.quantity");
- var quantity = hangerEl.data("quantity");
+ listEl.find("div.object").each(function () {
+ var hangerEl = $(this);
+ var hangerId = hangerEl.data("id");
+ var quantityEl = hangerEl.find("div.quantity");
+ var quantity = hangerEl.data("quantity");
- // Ooh, this part is weird. We only want the name to be linked, so
- // lift everything else out.
- var checkboxId = "hanger-selected-" + hangerId;
- var label = $("", { for: checkboxId });
- var link = hangerEl.children("a");
- link.children(":not(.name)").detach().appendTo(label);
- link.detach().appendTo(label);
- var checkbox = $("", {
- type: "checkbox",
- id: checkboxId,
- }).appendTo(hangerEl);
- label.appendTo(hangerEl);
+ // Ooh, this part is weird. We only want the name to be linked, so
+ // lift everything else out.
+ var checkboxId = "hanger-selected-" + hangerId;
+ var label = $("", { for: checkboxId });
+ var link = hangerEl.children("a");
+ link.children(":not(.name)").detach().appendTo(label);
+ link.detach().appendTo(label);
+ var checkbox = $("", {
+ type: "checkbox",
+ id: checkboxId,
+ }).appendTo(hangerEl);
+ label.appendTo(hangerEl);
- // I don't usually like to _blank things, but it's too easy to click
- // the text when you didn't mean to and lose your selection work.
- link.attr("target", "_blank");
+ // I don't usually like to _blank things, but it's too easy to click
+ // the text when you didn't mean to and lose your selection work.
+ link.attr("target", "_blank");
- $.tmpl("updateFormTmpl", {
- user_id: currentUserId,
- closet_hanger_id: hangerId,
- quantity: quantity,
- list_id: listId,
- owned: owned,
- }).appendTo(quantityEl);
+ $.tmpl("updateFormTmpl", {
+ user_id: currentUserId,
+ closet_hanger_id: hangerId,
+ quantity: quantity,
+ list_id: listId,
+ owned: owned,
+ }).appendTo(quantityEl);
- $.tmpl("destroyFormTmpl", {
- user_id: currentUserId,
- closet_hanger_id: hangerId,
- }).appendTo(hangerEl);
- });
- });
- });
- });
+ $.tmpl("destroyFormTmpl", {
+ user_id: currentUserId,
+ closet_hanger_id: hangerId,
+ }).appendTo(hangerEl);
+ });
+ });
+ });
+ });
- $.fn.liveDraggable = function (opts) {
- this.live("mouseover", function () {
- if (!$(this).data("init")) {
- $(this).data("init", true).draggable(opts);
- }
- });
- };
+ $.fn.liveDraggable = function (opts) {
+ this.live("mouseover", function () {
+ if (!$(this).data("init")) {
+ $(this).data("init", true).draggable(opts);
+ }
+ });
+ };
- $.fn.disableForms = function () {
- return this.data("formsDisabled", true)
- .find("input")
- .attr("disabled", "disabled")
- .end();
- };
+ $.fn.disableForms = function () {
+ return this.data("formsDisabled", true)
+ .find("input")
+ .attr("disabled", "disabled")
+ .end();
+ };
- $.fn.enableForms = function () {
- return this.data("formsDisabled", false)
- .find("input")
- .removeAttr("disabled")
- .end();
- };
+ $.fn.enableForms = function () {
+ return this.data("formsDisabled", false)
+ .find("input")
+ .removeAttr("disabled")
+ .end();
+ };
- $.fn.hasChanged = function () {
- return this.attr("data-previous-value") != this.val();
- };
+ $.fn.hasChanged = function () {
+ return this.attr("data-previous-value") != this.val();
+ };
- $.fn.revertValue = function () {
- return this.each(function () {
- var el = $(this);
- el.val(el.attr("data-previous-value"));
- });
- };
+ $.fn.revertValue = function () {
+ return this.each(function () {
+ var el = $(this);
+ el.val(el.attr("data-previous-value"));
+ });
+ };
- $.fn.storeValue = function () {
- return this.each(function () {
- var el = $(this);
- el.attr("data-previous-value", el.val());
- });
- };
+ $.fn.storeValue = function () {
+ return this.each(function () {
+ var el = $(this);
+ el.attr("data-previous-value", el.val());
+ });
+ };
- $.fn.insertIntoSortedList = function (list, compare) {
- var newChild = this,
- inserted = false;
- list.children().each(function () {
- if (compare(newChild, $(this)) < 1) {
- newChild.insertBefore(this);
- inserted = true;
- return false;
- }
- });
- if (!inserted) newChild.appendTo(list);
- return this;
- };
+ $.fn.insertIntoSortedList = function (list, compare) {
+ var newChild = this,
+ inserted = false;
+ list.children().each(function () {
+ if (compare(newChild, $(this)) < 1) {
+ newChild.insertBefore(this);
+ inserted = true;
+ return false;
+ }
+ });
+ if (!inserted) newChild.appendTo(list);
+ return this;
+ };
- function handleSaveError(xhr, action) {
- try {
- var data = $.parseJSON(xhr.responseText);
- } catch (e) {
- var data = {};
- }
+ function handleSaveError(xhr, action) {
+ try {
+ var data = $.parseJSON(xhr.responseText);
+ } catch (e) {
+ var data = {};
+ }
- if (typeof data.errors != "undefined") {
- $.jGrowl("Error " + action + ": " + data.errors.join(", "));
- } else {
- $.jGrowl("We had trouble " + action + " just now. Try again?");
- }
- }
+ if (typeof data.errors != "undefined") {
+ $.jGrowl("Error " + action + ": " + data.errors.join(", "));
+ } else {
+ $.jGrowl("We had trouble " + action + " just now. Try again?");
+ }
+ }
- function objectRemoved(objectWrapper) {
- objectWrapper.hide(250, function () {
- objectWrapper.remove();
- updateBulkActions();
- });
- }
+ function objectRemoved(objectWrapper) {
+ objectWrapper.hide(250, function () {
+ objectWrapper.remove();
+ updateBulkActions();
+ });
+ }
- function compareItemsByName(a, b) {
- return a.find("span.name").text().localeCompare(b.find("span.name").text());
- }
+ function compareItemsByName(a, b) {
+ return a.find("span.name").text().localeCompare(b.find("span.name").text());
+ }
- function findList(owned, id, item) {
- if (id) {
- return $("#closet-list-" + id);
- } else {
- return $(
- ".closet-hangers-group[data-owned=" +
- owned +
- "] div.closet-list.unlisted",
- );
- }
- }
+ function findList(owned, id, item) {
+ if (id) {
+ return $("#closet-list-" + id);
+ } else {
+ return $(
+ ".closet-hangers-group[data-owned=" +
+ owned +
+ "] div.closet-list.unlisted",
+ );
+ }
+ }
- function updateListHangersCount(el) {
- el.attr("data-hangers-count", el.find("div.object").length);
- }
+ function updateListHangersCount(el) {
+ el.attr("data-hangers-count", el.find("div.object").length);
+ }
- function moveItemToList(item, owned, listId) {
- var newList = findList(owned, listId, item);
- var oldList = item.closest("div.closet-list");
- var hangersWrapper = newList.find("div.closet-list-hangers");
- item.insertIntoSortedList(hangersWrapper, compareItemsByName);
- updateListHangersCount(oldList);
- updateListHangersCount(newList);
- }
+ function moveItemToList(item, owned, listId) {
+ var newList = findList(owned, listId, item);
+ var oldList = item.closest("div.closet-list");
+ var hangersWrapper = newList.find("div.closet-list-hangers");
+ item.insertIntoSortedList(hangersWrapper, compareItemsByName);
+ updateListHangersCount(oldList);
+ updateListHangersCount(newList);
+ }
- function submitUpdateForm(form) {
- if (form.data("loading")) return false;
- var quantityEl = form.children("input[name=closet_hanger[quantity]]");
- var ownedEl = form.children("input[name=closet_hanger[owned]]");
- var listEl = form.children("input[name=closet_hanger[list_id]]");
- var listChanged = ownedEl.hasChanged() || listEl.hasChanged();
- if (listChanged || quantityEl.hasChanged()) {
- var objectWrapper = form.closest(".object").addClass("loading");
- var newQuantity = quantityEl.val();
- var quantitySpan = objectWrapper.find(".quantity span").text(newQuantity);
- objectWrapper.attr("data-quantity", newQuantity);
- var data = form.serialize(); // get data before disabling inputs
- objectWrapper.disableForms();
- form.data("loading", true);
- if (listChanged)
- moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
- $.ajax({
- url: form.attr("action") + ".json",
- type: "post",
- data: data,
- dataType: "json",
- complete: function (data) {
- if (quantityEl.val() == 0) {
- objectRemoved(objectWrapper);
- } else {
- objectWrapper.removeClass("loading").enableForms();
- }
- form.data("loading", false);
- },
- success: function () {
- // Now that the move was successful, let's merge it with any
- // conflicting hangers
- var id = objectWrapper.attr("data-item-id");
- var conflictingHanger = findList(
- ownedEl.val(),
- listEl.val(),
- objectWrapper,
- )
- .find("div[data-item-id=" + id + "]")
- .not(objectWrapper);
- if (conflictingHanger.length) {
- var conflictingQuantity = parseInt(
- conflictingHanger.attr("data-quantity"),
- 10,
- );
+ function submitUpdateForm(form) {
+ if (form.data("loading")) return false;
+ var quantityEl = form.children("input[name=closet_hanger[quantity]]");
+ var ownedEl = form.children("input[name=closet_hanger[owned]]");
+ var listEl = form.children("input[name=closet_hanger[list_id]]");
+ var listChanged = ownedEl.hasChanged() || listEl.hasChanged();
+ if (listChanged || quantityEl.hasChanged()) {
+ var objectWrapper = form.closest(".object").addClass("loading");
+ var newQuantity = quantityEl.val();
+ var quantitySpan = objectWrapper.find(".quantity span").text(newQuantity);
+ objectWrapper.attr("data-quantity", newQuantity);
+ var data = form.serialize(); // get data before disabling inputs
+ objectWrapper.disableForms();
+ form.data("loading", true);
+ if (listChanged)
+ moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
+ $.ajax({
+ url: form.attr("action") + ".json",
+ type: "post",
+ data: data,
+ dataType: "json",
+ complete: function (data) {
+ if (quantityEl.val() == 0) {
+ objectRemoved(objectWrapper);
+ } else {
+ objectWrapper.removeClass("loading").enableForms();
+ }
+ form.data("loading", false);
+ },
+ success: function () {
+ // Now that the move was successful, let's merge it with any
+ // conflicting hangers
+ var id = objectWrapper.attr("data-item-id");
+ var conflictingHanger = findList(
+ ownedEl.val(),
+ listEl.val(),
+ objectWrapper,
+ )
+ .find("div[data-item-id=" + id + "]")
+ .not(objectWrapper);
+ if (conflictingHanger.length) {
+ var conflictingQuantity = parseInt(
+ conflictingHanger.attr("data-quantity"),
+ 10,
+ );
- var currentQuantity = parseInt(newQuantity, 10);
+ var currentQuantity = parseInt(newQuantity, 10);
- var mergedQuantity = conflictingQuantity + currentQuantity;
+ var mergedQuantity = conflictingQuantity + currentQuantity;
- quantitySpan.text(mergedQuantity);
- quantityEl.val(mergedQuantity);
- objectWrapper.attr("data-quantity", mergedQuantity);
+ quantitySpan.text(mergedQuantity);
+ quantityEl.val(mergedQuantity);
+ objectWrapper.attr("data-quantity", mergedQuantity);
- conflictingHanger.remove();
- }
+ conflictingHanger.remove();
+ }
- quantityEl.storeValue();
- ownedEl.storeValue();
- listEl.storeValue();
+ quantityEl.storeValue();
+ ownedEl.storeValue();
+ listEl.storeValue();
- updateBulkActions();
- },
- error: function (xhr) {
- quantityEl.revertValue();
- ownedEl.revertValue();
- listEl.revertValue();
- if (listChanged)
- moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
- quantitySpan.text(quantityEl.val());
+ updateBulkActions();
+ },
+ error: function (xhr) {
+ quantityEl.revertValue();
+ ownedEl.revertValue();
+ listEl.revertValue();
+ if (listChanged)
+ moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
+ quantitySpan.text(quantityEl.val());
- handleSaveError(xhr, "updating the quantity");
- },
- });
- }
- }
+ handleSaveError(xhr, "updating the quantity");
+ },
+ });
+ }
+ }
- $(hangersElQuery + " form.closet-hanger-update").live("submit", function (e) {
- e.preventDefault();
- submitUpdateForm($(this));
- });
+ $(hangersElQuery + " form.closet-hanger-update").live("submit", function (e) {
+ e.preventDefault();
+ submitUpdateForm($(this));
+ });
- function editableInputs() {
- return $(hangersElQuery).find(
- "input[name=closet_hanger[quantity]], " +
- "input[name=closet_hanger[owned]], " +
- "input[name=closet_hanger[list_id]]",
- );
- }
+ function editableInputs() {
+ return $(hangersElQuery).find(
+ "input[name=closet_hanger[quantity]], " +
+ "input[name=closet_hanger[owned]], " +
+ "input[name=closet_hanger[list_id]]",
+ );
+ }
- $(hangersElQuery + "input[name=closet_hanger[quantity]]")
- .live("change", function () {
- submitUpdateForm($(this).parent());
- })
- .storeValue();
+ $(hangersElQuery + "input[name=closet_hanger[quantity]]")
+ .live("change", function () {
+ submitUpdateForm($(this).parent());
+ })
+ .storeValue();
- onHangersInit(function () {
- editableInputs().storeValue();
- });
+ onHangersInit(function () {
+ editableInputs().storeValue();
+ });
- $(hangersElQuery + " div.object")
- .live("mouseleave", function () {
- submitUpdateForm($(this).find("form.closet-hanger-update"));
- })
- .liveDraggable({
- appendTo: "#closet-hangers",
- distance: 20,
- helper: "clone",
- revert: "invalid",
- });
+ $(hangersElQuery + " div.object")
+ .live("mouseleave", function () {
+ submitUpdateForm($(this).find("form.closet-hanger-update"));
+ })
+ .liveDraggable({
+ appendTo: "#closet-hangers",
+ distance: 20,
+ helper: "clone",
+ revert: "invalid",
+ });
- $(hangersElQuery + " form.closet-hanger-destroy").live(
- "submit",
- function (e) {
- e.preventDefault();
- var form = $(this);
- var button = form.children("input[type=submit]").val("Removing…");
- var objectWrapper = form.closest(".object").addClass("loading");
- var data = form.serialize(); // get data before disabling inputs
- objectWrapper.addClass("loading").disableForms();
- $.ajax({
- url: form.attr("action") + ".json",
- type: "post",
- data: data,
- dataType: "json",
- complete: function () {
- button.val("Remove");
- },
- success: function () {
- objectRemoved(objectWrapper);
- },
- error: function () {
- objectWrapper.removeClass("loading").enableForms();
- $.jGrowl("Error removing item. Try again?");
- },
- });
- },
- );
+ $(hangersElQuery + " form.closet-hanger-destroy").live(
+ "submit",
+ function (e) {
+ e.preventDefault();
+ var form = $(this);
+ var button = form.children("input[type=submit]").val("Removing…");
+ var objectWrapper = form.closest(".object").addClass("loading");
+ var data = form.serialize(); // get data before disabling inputs
+ objectWrapper.addClass("loading").disableForms();
+ $.ajax({
+ url: form.attr("action") + ".json",
+ type: "post",
+ data: data,
+ dataType: "json",
+ complete: function () {
+ button.val("Remove");
+ },
+ success: function () {
+ objectRemoved(objectWrapper);
+ },
+ error: function () {
+ objectWrapper.removeClass("loading").enableForms();
+ $.jGrowl("Error removing item. Try again?");
+ },
+ });
+ },
+ );
- $(hangersElQuery + " .select-all").live("click", function (e) {
- var checkboxes = $(this)
- .closest(".closet-list")
- .find(".object input[type=checkbox]");
+ $(hangersElQuery + " .select-all").live("click", function (e) {
+ var checkboxes = $(this)
+ .closest(".closet-list")
+ .find(".object input[type=checkbox]");
- var allChecked = true;
- checkboxes.each(function () {
- if (!this.checked) {
- allChecked = false;
- return false;
- }
- });
+ var allChecked = true;
+ checkboxes.each(function () {
+ if (!this.checked) {
+ allChecked = false;
+ return false;
+ }
+ });
- checkboxes.attr("checked", !allChecked);
+ checkboxes.attr("checked", !allChecked);
- updateBulkActions(); // setting the checked prop doesn't fire change events
- });
+ updateBulkActions(); // setting the checked prop doesn't fire change events
+ });
- function getCheckboxes() {
- return $(hangersElQuery + " input[type=checkbox]");
- }
+ function getCheckboxes() {
+ return $(hangersElQuery + " input[type=checkbox]");
+ }
- function getCheckedIds() {
- var checkedIds = [];
- getCheckboxes()
- .filter(":checked")
- .each(function () {
- if (this.checked) checkedIds.push(this.id);
- });
- return checkedIds;
- }
+ function getCheckedIds() {
+ var checkedIds = [];
+ getCheckboxes()
+ .filter(":checked")
+ .each(function () {
+ if (this.checked) checkedIds.push(this.id);
+ });
+ return checkedIds;
+ }
- getCheckboxes().live("change", updateBulkActions);
+ getCheckboxes().live("change", updateBulkActions);
- function updateBulkActions() {
- var checkedCount = getCheckboxes().filter(":checked").length;
- $(".bulk-actions").attr("data-target-count", checkedCount);
- $(".bulk-actions-target-count").text(checkedCount);
- }
+ function updateBulkActions() {
+ var checkedCount = getCheckboxes().filter(":checked").length;
+ $(".bulk-actions").attr("data-target-count", checkedCount);
+ $(".bulk-actions-target-count").text(checkedCount);
+ }
- $(".bulk-actions-move-all").bind("submit", function (e) {
- // TODO: DRY
- e.preventDefault();
- var form = $(this);
- var data = form.serializeArray();
- data.push({
- name: "return_to",
- value: window.location.pathname + window.location.search,
- });
+ $(".bulk-actions-move-all").bind("submit", function (e) {
+ // TODO: DRY
+ e.preventDefault();
+ var form = $(this);
+ var data = form.serializeArray();
+ data.push({
+ name: "return_to",
+ value: window.location.pathname + window.location.search,
+ });
- var checkedBoxes = getCheckboxes().filter(":checked");
- checkedBoxes.each(function () {
- data.push({
- name: "ids[]",
- value: $(this).closest(".object").attr("data-id"),
- });
- });
+ var checkedBoxes = getCheckboxes().filter(":checked");
+ checkedBoxes.each(function () {
+ data.push({
+ name: "ids[]",
+ value: $(this).closest(".object").attr("data-id"),
+ });
+ });
- $.ajax({
- url: form.attr("action"),
- type: form.attr("method"),
- data: data,
- success: function (html) {
- var doc = $(html);
- hangersEl.html(doc.find("#closet-hangers").html());
- hangersInit();
- updateBulkActions(); // don't want to maintain checked; deselect em all
- doc
- .find(".flash")
- .hide()
- .insertBefore(hangersEl)
- .show(500)
- .delay(5000)
- .hide(250);
- itemsSearchField.val("");
- },
- error: function (xhr) {
- handleSaveError(xhr, "moving these items");
- },
- });
- });
+ $.ajax({
+ url: form.attr("action"),
+ type: form.attr("method"),
+ data: data,
+ success: function (html) {
+ var doc = $(html);
+ hangersEl.html(doc.find("#closet-hangers").html());
+ hangersInit();
+ updateBulkActions(); // don't want to maintain checked; deselect em all
+ doc
+ .find(".flash")
+ .hide()
+ .insertBefore(hangersEl)
+ .show(500)
+ .delay(5000)
+ .hide(250);
+ itemsSearchField.val("");
+ },
+ error: function (xhr) {
+ handleSaveError(xhr, "moving these items");
+ },
+ });
+ });
- $(".bulk-actions-remove-all").bind("submit", function (e) {
- e.preventDefault();
- var form = $(this);
- var hangerIds = [];
- var checkedBoxes = getCheckboxes().filter(":checked");
- var hangerEls = $();
- checkedBoxes.each(function () {
- hangerEls = hangerEls.add($(this).closest(".object"));
- });
- hangerEls.each(function () {
- hangerIds.push($(this).attr("data-id"));
- });
- $.ajax({
- url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
- type: "delete",
- dataType: "json",
- success: function () {
- objectRemoved(hangerEls);
- },
- error: function () {
- $.jGrowl("Error removing items. Try again?");
- },
- });
- });
+ $(".bulk-actions-remove-all").bind("submit", function (e) {
+ e.preventDefault();
+ var form = $(this);
+ var hangerIds = [];
+ var checkedBoxes = getCheckboxes().filter(":checked");
+ var hangerEls = $();
+ checkedBoxes.each(function () {
+ hangerEls = hangerEls.add($(this).closest(".object"));
+ });
+ hangerEls.each(function () {
+ hangerIds.push($(this).attr("data-id"));
+ });
+ $.ajax({
+ url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
+ type: "delete",
+ dataType: "json",
+ success: function () {
+ objectRemoved(hangerEls);
+ },
+ error: function () {
+ $.jGrowl("Error removing items. Try again?");
+ },
+ });
+ });
- $(".bulk-actions-deselect-all").bind("click", function (e) {
- getCheckboxes().filter(":checked").attr("checked", false);
- updateBulkActions();
- });
+ $(".bulk-actions-deselect-all").bind("click", function (e) {
+ getCheckboxes().filter(":checked").attr("checked", false);
+ updateBulkActions();
+ });
- function maintainCheckboxes(fn) {
- var checkedIds = getCheckedIds();
+ function maintainCheckboxes(fn) {
+ var checkedIds = getCheckedIds();
- fn();
+ fn();
- checkedIds.forEach(function (id) {
- document.getElementById(id).checked = true;
- });
- updateBulkActions();
- }
+ checkedIds.forEach(function (id) {
+ document.getElementById(id).checked = true;
+ });
+ updateBulkActions();
+ }
- /*
+ /*
Search, autocomplete
*/
- var itemsSearchForm = $("#closet-hangers-items-search[data-current-user-id]");
- var itemsSearchField = itemsSearchForm.children("input[name=q]");
+ var itemsSearchForm = $("#closet-hangers-items-search[data-current-user-id]");
+ var itemsSearchField = itemsSearchForm.children("input[name=q]");
- itemsSearchField.autocomplete({
- select: function (e, ui) {
- if (ui.item.is_item) {
- // Let the autocompleter finish up this search before starting a new one
- setTimeout(function () {
- itemsSearchField.autocomplete("search", ui.item);
- }, 0);
- } else {
- var item = ui.item.item;
- var group = ui.item.group;
+ itemsSearchField.autocomplete({
+ select: function (e, ui) {
+ if (ui.item.is_item) {
+ // Let the autocompleter finish up this search before starting a new one
+ setTimeout(function () {
+ itemsSearchField.autocomplete("search", ui.item);
+ }, 0);
+ } else {
+ var item = ui.item.item;
+ var group = ui.item.group;
- itemsSearchField.addClass("loading");
+ itemsSearchField.addClass("loading");
- var closetHanger = {
- owned: group.owned,
- list_id: ui.item.list ? ui.item.list.id : "",
- };
+ var closetHanger = {
+ owned: group.owned,
+ list_id: ui.item.list ? ui.item.list.id : "",
+ };
- if (!item.hasHanger) closetHanger.quantity = 1;
+ if (!item.hasHanger) closetHanger.quantity = 1;
- $.ajax({
- url:
- "/user/" +
- itemsSearchForm.data("current-user-id") +
- "/items/" +
- item.id +
- "/closet_hangers",
- type: "post",
- data: {
- closet_hanger: closetHanger,
- return_to: window.location.pathname + window.location.search,
- },
- complete: function () {
- itemsSearchField.removeClass("loading");
- },
- success: function (html) {
- var doc = $(html);
- maintainCheckboxes(function () {
- hangersEl.html(doc.find("#closet-hangers").html());
- hangersInit();
- });
- doc
- .find(".flash")
- .hide()
- .insertBefore(hangersEl)
- .show(500)
- .delay(5000)
- .hide(250);
- itemsSearchField.val("");
- },
- error: function (xhr) {
- handleSaveError(xhr, "adding the item");
- },
- });
- }
- },
- source: function (input, callback) {
- if (typeof input.term == "string") {
- // user-typed query
- $.getJSON("/items.json?q=" + input.term, function (data) {
- var output = [];
- var items = data.items;
- for (var i in items) {
- items[i].label = items[i].name;
- items[i].is_item = true;
- output[output.length] = items[i];
- }
- callback(output);
- });
- } else {
- // item was chosen, now choose a group to insert
- var groupInserts = [],
- group;
- var item = input.term,
- itemEl,
- occupiedGroups,
- hasHanger;
- for (var i in hangerGroups) {
- group = hangerGroups[i];
- itemEl = $(
- ".closet-hangers-group[data-owned=" +
- group.owned +
- "] div.object[data-item-id=" +
- item.id +
- "]",
- );
- occupiedGroups = itemEl.closest(".closet-list");
- hasHanger = occupiedGroups.filter(".unlisted").length > 0;
+ $.ajax({
+ url:
+ "/user/" +
+ itemsSearchForm.data("current-user-id") +
+ "/items/" +
+ item.id +
+ "/closet_hangers",
+ type: "post",
+ data: {
+ closet_hanger: closetHanger,
+ return_to: window.location.pathname + window.location.search,
+ },
+ complete: function () {
+ itemsSearchField.removeClass("loading");
+ },
+ success: function (html) {
+ var doc = $(html);
+ maintainCheckboxes(function () {
+ hangersEl.html(doc.find("#closet-hangers").html());
+ hangersInit();
+ });
+ doc
+ .find(".flash")
+ .hide()
+ .insertBefore(hangersEl)
+ .show(500)
+ .delay(5000)
+ .hide(250);
+ itemsSearchField.val("");
+ },
+ error: function (xhr) {
+ handleSaveError(xhr, "adding the item");
+ },
+ });
+ }
+ },
+ source: function (input, callback) {
+ if (typeof input.term == "string") {
+ // user-typed query
+ $.getJSON("/items.json?q=" + input.term, function (data) {
+ var output = [];
+ var items = data.items;
+ for (var i in items) {
+ items[i].label = items[i].name;
+ items[i].is_item = true;
+ output[output.length] = items[i];
+ }
+ callback(output);
+ });
+ } else {
+ // item was chosen, now choose a group to insert
+ var groupInserts = [],
+ group;
+ var item = input.term,
+ itemEl,
+ occupiedGroups,
+ hasHanger;
+ for (var i in hangerGroups) {
+ group = hangerGroups[i];
+ itemEl = $(
+ ".closet-hangers-group[data-owned=" +
+ group.owned +
+ "] div.object[data-item-id=" +
+ item.id +
+ "]",
+ );
+ occupiedGroups = itemEl.closest(".closet-list");
+ hasHanger = occupiedGroups.filter(".unlisted").length > 0;
- groupInserts[groupInserts.length] = {
- group: group,
- item: item,
- label: item.label,
- hasHanger: hasHanger,
- };
+ groupInserts[groupInserts.length] = {
+ group: group,
+ item: item,
+ label: item.label,
+ hasHanger: hasHanger,
+ };
- for (var i = 0; i < group.lists.length; i++) {
- hasHanger =
- occupiedGroups.filter("[data-id=" + group.lists[i].id + "]")
- .length > 0;
- groupInserts[groupInserts.length] = {
- group: group,
- item: item,
- label: item.label,
- list: group.lists[i],
- hasHanger: hasHanger,
- };
- }
- }
- callback(groupInserts);
- }
- },
- });
+ for (var i = 0; i < group.lists.length; i++) {
+ hasHanger =
+ occupiedGroups.filter("[data-id=" + group.lists[i].id + "]")
+ .length > 0;
+ groupInserts[groupInserts.length] = {
+ group: group,
+ item: item,
+ label: item.label,
+ list: group.lists[i],
+ hasHanger: hasHanger,
+ };
+ }
+ }
+ callback(groupInserts);
+ }
+ },
+ });
- var autocompleter = itemsSearchField.data("autocomplete");
+ var autocompleter = itemsSearchField.data("autocomplete");
- autocompleter._renderItem = function (ul, item) {
- var li = $("
").data("item.autocomplete", item);
- if (item.is_item) {
- // these are items from the server
- $("#autocomplete-item-tmpl").tmpl({ item_name: item.label }).appendTo(li);
- } else if (item.list) {
- // these are list inserts
- var listName = item.list.label;
- if (item.hasHanger) {
- $("#autocomplete-already-in-collection-tmpl")
- .tmpl({ collection_name: listName })
- .appendTo(li);
- } else {
- $("#autocomplete-add-to-list-tmpl")
- .tmpl({ list_name: listName })
- .appendTo(li);
- }
- li.addClass("closet-list-autocomplete-item");
- } else {
- // these are group inserts
- var groupName = item.group.label;
- if (!item.hasHanger) {
- $("#autocomplete-add-to-group-tmpl")
- .tmpl({ group_name: groupName.replace(/\s+$/, "") })
- .appendTo(li);
- } else {
- $("#autocomplete-already-in-collection-tmpl")
- .tmpl({ collection_name: groupName })
- .appendTo(li);
- }
- li.addClass("closet-hangers-group-autocomplete-item");
- }
- return li.appendTo(ul);
- };
+ autocompleter._renderItem = function (ul, item) {
+ var li = $("").data("item.autocomplete", item);
+ if (item.is_item) {
+ // these are items from the server
+ $("#autocomplete-item-tmpl").tmpl({ item_name: item.label }).appendTo(li);
+ } else if (item.list) {
+ // these are list inserts
+ var listName = item.list.label;
+ if (item.hasHanger) {
+ $("#autocomplete-already-in-collection-tmpl")
+ .tmpl({ collection_name: listName })
+ .appendTo(li);
+ } else {
+ $("#autocomplete-add-to-list-tmpl")
+ .tmpl({ list_name: listName })
+ .appendTo(li);
+ }
+ li.addClass("closet-list-autocomplete-item");
+ } else {
+ // these are group inserts
+ var groupName = item.group.label;
+ if (!item.hasHanger) {
+ $("#autocomplete-add-to-group-tmpl")
+ .tmpl({ group_name: groupName.replace(/\s+$/, "") })
+ .appendTo(li);
+ } else {
+ $("#autocomplete-already-in-collection-tmpl")
+ .tmpl({ collection_name: groupName })
+ .appendTo(li);
+ }
+ li.addClass("closet-hangers-group-autocomplete-item");
+ }
+ return li.appendTo(ul);
+ };
- /*
+ /*
Contact Neopets username form
*/
- var contactEl = $("#closet-hangers-contact");
- var contactForm = contactEl.children("form");
- var contactField = contactForm.children("select");
+ var contactEl = $("#closet-hangers-contact");
+ var contactForm = contactEl.children("form");
+ var contactField = contactForm.children("select");
- var contactAddOption = $("", {
- text: contactField.attr("data-new-text"),
- value: -1,
- });
- contactAddOption.appendTo(contactField);
- var currentUserId = $("meta[name=current-user-id]").attr("content");
+ var contactAddOption = $("", {
+ text: contactField.attr("data-new-text"),
+ value: -1,
+ });
+ contactAddOption.appendTo(contactField);
+ var currentUserId = $("meta[name=current-user-id]").attr("content");
- function submitContactForm() {
- var data = contactForm.serialize();
- contactForm.disableForms();
- $.ajax({
- url: contactForm.attr("action") + ".json",
- type: "post",
- data: data,
- dataType: "json",
- complete: function () {
- contactForm.enableForms();
- },
- error: function (xhr) {
- handleSaveError(xhr, "saving Neopets username");
- },
- });
- }
+ function submitContactForm() {
+ var data = contactForm.serialize();
+ contactForm.disableForms();
+ $.ajax({
+ url: contactForm.attr("action") + ".json",
+ type: "post",
+ data: data,
+ dataType: "json",
+ complete: function () {
+ contactForm.enableForms();
+ },
+ error: function (xhr) {
+ handleSaveError(xhr, "saving Neopets username");
+ },
+ });
+ }
- contactField.change(function (e) {
- if (contactField.val() < 0) {
- var newUsername = $.trim(
- prompt(contactField.attr("data-new-prompt"), ""),
- );
- if (newUsername) {
- $.ajax({
- url: "/user/" + currentUserId + "/neopets-connections",
- type: "POST",
- data: { neopets_connection: { neopets_username: newUsername } },
- dataType: "json",
- success: function (connection) {
- var newOption = $("", {
- text: newUsername,
- value: connection.id,
- });
- newOption.insertBefore(contactAddOption);
- contactField.val(connection.id);
- submitContactForm();
- },
- });
- }
- } else {
- submitContactForm();
- }
- });
+ contactField.change(function (e) {
+ if (contactField.val() < 0) {
+ var newUsername = $.trim(
+ prompt(contactField.attr("data-new-prompt"), ""),
+ );
+ if (newUsername) {
+ $.ajax({
+ url: "/user/" + currentUserId + "/neopets-connections",
+ type: "POST",
+ data: { neopets_connection: { neopets_username: newUsername } },
+ dataType: "json",
+ success: function (connection) {
+ var newOption = $("", {
+ text: newUsername,
+ value: connection.id,
+ });
+ newOption.insertBefore(contactAddOption);
+ contactField.val(connection.id);
+ submitContactForm();
+ },
+ });
+ }
+ } else {
+ submitContactForm();
+ }
+ });
- /*
+ /*
Closet list droppable
*/
- onHangersInit(function () {
- $("div.closet-list").droppable({
- accept: "div.object",
- activate: function () {
- $(this)
- .find(".closet-list-content")
- .animate({ opacity: 0, height: 100 }, 250);
- },
- activeClass: "droppable-active",
- deactivate: function () {
- $(this)
- .find(".closet-list-content")
- .css("height", "auto")
- .animate({ opacity: 1 }, 250);
- },
- drop: function (e, ui) {
- var form = ui.draggable.find("form.closet-hanger-update");
- form
- .find("input[name=closet_hanger[list_id]]")
- .val(this.getAttribute("data-id"));
- form
- .find("input[name=closet_hanger[owned]]")
- .val($(this).closest(".closet-hangers-group").attr("data-owned"));
- submitUpdateForm(form);
- },
- });
- });
+ onHangersInit(function () {
+ $("div.closet-list").droppable({
+ accept: "div.object",
+ activate: function () {
+ $(this)
+ .find(".closet-list-content")
+ .animate({ opacity: 0, height: 100 }, 250);
+ },
+ activeClass: "droppable-active",
+ deactivate: function () {
+ $(this)
+ .find(".closet-list-content")
+ .css("height", "auto")
+ .animate({ opacity: 1 }, 250);
+ },
+ drop: function (e, ui) {
+ var form = ui.draggable.find("form.closet-hanger-update");
+ form
+ .find("input[name=closet_hanger[list_id]]")
+ .val(this.getAttribute("data-id"));
+ form
+ .find("input[name=closet_hanger[owned]]")
+ .val($(this).closest(".closet-hangers-group").attr("data-owned"));
+ submitUpdateForm(form);
+ },
+ });
+ });
- /*
+ /*
Visibility Descriptions
*/
- function updateVisibilityDescription() {
- var descriptions = $(this)
- .closest(".visibility-form")
- .find("ul.visibility-descriptions");
+ function updateVisibilityDescription() {
+ var descriptions = $(this)
+ .closest(".visibility-form")
+ .find("ul.visibility-descriptions");
- descriptions.children("li.current").removeClass("current");
- descriptions
- .children("li[data-id=" + $(this).val() + "]")
- .addClass("current");
- }
+ descriptions.children("li.current").removeClass("current");
+ descriptions
+ .children("li[data-id=" + $(this).val() + "]")
+ .addClass("current");
+ }
- function visibilitySelects() {
- return $("form.visibility-form select");
- }
+ function visibilitySelects() {
+ return $("form.visibility-form select");
+ }
- visibilitySelects().live("change", updateVisibilityDescription);
+ visibilitySelects().live("change", updateVisibilityDescription);
- onHangersInit(function () {
- visibilitySelects().each(updateVisibilityDescription);
- });
+ onHangersInit(function () {
+ visibilitySelects().each(updateVisibilityDescription);
+ });
- /*
+ /*
Help
*/
- $("#toggle-help").click(function () {
- $("#closet-hangers-help").toggleClass("hidden");
- });
+ $("#toggle-help").click(function () {
+ $("#closet-hangers-help").toggleClass("hidden");
+ });
- /*
+ /*
Share URL
*/
- $("#closet-hangers-share-box")
- .mouseover(function () {
- $(this).focus();
- })
- .mouseout(function () {
- $(this).blur();
- });
+ $("#closet-hangers-share-box")
+ .mouseover(function () {
+ $(this).focus();
+ })
+ .mouseout(function () {
+ $(this).blur();
+ });
- /*
+ /*
Initialize
*/
- hangersInit();
+ hangersInit();
})();
diff --git a/app/assets/javascripts/closet_hangers/petpage.js b/app/assets/javascripts/closet_hangers/petpage.js
index 595f6bc7..6d0e754f 100644
--- a/app/assets/javascripts/closet_hangers/petpage.js
+++ b/app/assets/javascripts/closet_hangers/petpage.js
@@ -1,8 +1,8 @@
(function () {
- function setChecked() {
- var el = $(this);
- el.closest("li").toggleClass("checked", el.is(":checked"));
- }
+ function setChecked() {
+ var el = $(this);
+ el.closest("li").toggleClass("checked", el.is(":checked"));
+ }
- $("#petpage-closet-lists input").click(setChecked).each(setChecked);
+ $("#petpage-closet-lists input").click(setChecked).each(setChecked);
})();
diff --git a/app/assets/javascripts/closet_lists/form.js b/app/assets/javascripts/closet_lists/form.js
index 39e80cf5..60703df0 100644
--- a/app/assets/javascripts/closet_lists/form.js
+++ b/app/assets/javascripts/closet_lists/form.js
@@ -1,7 +1,5 @@
document.addEventListener("change", ({ target }) => {
if (target.matches('select[name="closet_list[visibility]"]')) {
- target
- .closest("form")
- .setAttribute("data-list-visibility", target.value);
+ target.closest("form").setAttribute("data-list-visibility", target.value);
}
});
diff --git a/app/assets/javascripts/fundraising/donations/show.js b/app/assets/javascripts/fundraising/donations/show.js
index 67371c68..37850017 100644
--- a/app/assets/javascripts/fundraising/donations/show.js
+++ b/app/assets/javascripts/fundraising/donations/show.js
@@ -1,6 +1,6 @@
-(function() {
- $('span.choose-outfit select').change(function(e) {
- var select = $(this);
- select.closest('li').find('input[type=text]').val(select.val());
- });
+(function () {
+ $("span.choose-outfit select").change(function (e) {
+ var select = $(this);
+ select.closest("li").find("input[type=text]").val(select.val());
+ });
})();
diff --git a/app/assets/javascripts/items/show.js b/app/assets/javascripts/items/show.js
index 43f88160..69589a82 100644
--- a/app/assets/javascripts/items/show.js
+++ b/app/assets/javascripts/items/show.js
@@ -1,102 +1,100 @@
// When the species face picker changes, update and submit the main picker form.
document.addEventListener("change", (e) => {
- if (!e.target.matches("species-face-picker")) return;
+ if (!e.target.matches("species-face-picker")) return;
- try {
- const mainPickerForm = document.querySelector(
- "#item-preview species-color-picker form",
- );
- const mainSpeciesField = mainPickerForm.querySelector(
- "[name='preview[species_id]']",
- );
- mainSpeciesField.value = e.target.value;
- mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
- } catch (error) {
- console.error("Couldn't update species picker: ", error);
- }
+ try {
+ const mainPickerForm = document.querySelector(
+ "#item-preview species-color-picker form",
+ );
+ const mainSpeciesField = mainPickerForm.querySelector(
+ "[name='preview[species_id]']",
+ );
+ mainSpeciesField.value = e.target.value;
+ mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
+ } catch (error) {
+ console.error("Couldn't update species picker: ", error);
+ }
});
// If the preview frame fails to load, try a full pageload.
document.addEventListener("turbo:frame-missing", (e) => {
- if (!e.target.matches("#item-preview")) return;
+ if (!e.target.matches("#item-preview")) return;
- e.detail.visit(e.detail.response.url);
- e.preventDefault();
+ e.detail.visit(e.detail.response.url);
+ e.preventDefault();
});
class SpeciesColorPicker extends HTMLElement {
- #internals;
+ #internals;
- constructor() {
- super();
- this.#internals = this.attachInternals();
- }
+ constructor() {
+ super();
+ this.#internals = this.attachInternals();
+ }
- connectedCallback() {
- // Listen for changes to auto-submit the form, then tell CSS about it!
- this.addEventListener("change", this.#handleChange);
- this.#internals.states.add("auto-loading");
- }
+ connectedCallback() {
+ // Listen for changes to auto-submit the form, then tell CSS about it!
+ this.addEventListener("change", this.#handleChange);
+ this.#internals.states.add("auto-loading");
+ }
- #handleChange(e) {
- this.querySelector("form").requestSubmit();
- }
+ #handleChange(e) {
+ this.querySelector("form").requestSubmit();
+ }
}
class SpeciesFacePicker extends HTMLElement {
- connectedCallback() {
- this.addEventListener("click", this.#handleClick);
- }
+ connectedCallback() {
+ this.addEventListener("click", this.#handleClick);
+ }
- get value() {
- return this.querySelector("input[type=radio]:checked")?.value;
- }
+ get value() {
+ return this.querySelector("input[type=radio]:checked")?.value;
+ }
- #handleClick(e) {
- if (e.target.matches("input[type=radio]")) {
- this.dispatchEvent(new Event("change", { bubbles: true }));
- }
- }
+ #handleClick(e) {
+ if (e.target.matches("input[type=radio]")) {
+ this.dispatchEvent(new Event("change", { bubbles: true }));
+ }
+ }
}
class SpeciesFacePickerOptions extends HTMLElement {
- static observedAttributes = ["inert", "aria-hidden"];
+ static observedAttributes = ["inert", "aria-hidden"];
- connectedCallback() {
- // Once this component is loaded, we stop being inert and aria-hidden. We're ready!
- this.#activate();
- }
+ connectedCallback() {
+ // Once this component is loaded, we stop being inert and aria-hidden. We're ready!
+ this.#activate();
+ }
- attributeChangedCallback() {
- // If a Turbo Frame tries to morph us into being inert again, activate again!
- // (It's important that the server's HTML always return `inert`, for progressive
- // enhancement; and it's important to morph this element, so radio focus state
- // is preserved. To thread that needle, we have to monitor and remove!)
- this.#activate();
- }
+ attributeChangedCallback() {
+ // If a Turbo Frame tries to morph us into being inert again, activate again!
+ // (It's important that the server's HTML always return `inert`, for progressive
+ // enhancement; and it's important to morph this element, so radio focus state
+ // is preserved. To thread that needle, we have to monitor and remove!)
+ this.#activate();
+ }
- #activate() {
- this.removeAttribute("inert");
- this.removeAttribute("aria-hidden");
- }
+ #activate() {
+ this.removeAttribute("inert");
+ this.removeAttribute("aria-hidden");
+ }
}
class MeasuredContent extends HTMLElement {
- connectedCallback() {
- setTimeout(() => this.#measure(), 0);
- }
+ connectedCallback() {
+ setTimeout(() => this.#measure(), 0);
+ }
- #measure() {
- // Find our `` parent, and set our natural width
- // as `var(--natural-width)` in the context of its CSS styles.
- const container = this.closest("measured-container");
- if (container == null) {
- throw new Error(
- ` must be in a `,
- );
- }
- container.style.setProperty("--natural-width", this.offsetWidth + "px");
- }
+ #measure() {
+ // Find our `` parent, and set our natural width
+ // as `var(--natural-width)` in the context of its CSS styles.
+ const container = this.closest("measured-container");
+ if (container == null) {
+ throw new Error(` must be in a `);
+ }
+ container.style.setProperty("--natural-width", this.offsetWidth + "px");
+ }
}
customElements.define("species-color-picker", SpeciesColorPicker);
diff --git a/app/assets/javascripts/outfit-viewer.js b/app/assets/javascripts/outfit-viewer.js
index c8e4fec5..3f597b1b 100644
--- a/app/assets/javascripts/outfit-viewer.js
+++ b/app/assets/javascripts/outfit-viewer.js
@@ -108,9 +108,7 @@ class OutfitLayer extends HTMLElement {
this.#setStatus("loading");
this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m));
- this.iframe.addEventListener("error", () =>
- this.#setStatus("error"),
- );
+ this.iframe.addEventListener("error", () => this.#setStatus("error"));
} else {
console.warn(` contained no image or iframe: `, this);
}
@@ -137,8 +135,7 @@ class OutfitLayer extends HTMLElement {
}
} else {
throw new Error(
- ` got unexpected message: ` +
- JSON.stringify(data),
+ ` got unexpected message: ` + JSON.stringify(data),
);
}
}
diff --git a/app/assets/javascripts/outfits/new.js b/app/assets/javascripts/outfits/new.js
index 50dc4cf3..ff8bd205 100644
--- a/app/assets/javascripts/outfits/new.js
+++ b/app/assets/javascripts/outfits/new.js
@@ -1,272 +1,272 @@
(function () {
- function petImage(id, size) {
- return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
- }
+ function petImage(id, size) {
+ return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
+ }
- var PetQuery = {},
- query_string = document.location.hash || document.location.search;
+ var PetQuery = {},
+ query_string = document.location.hash || document.location.search;
- $.each(query_string.substr(1).split("&"), function () {
- var split_piece = this.split("=");
- if (split_piece.length == 2) {
- PetQuery[split_piece[0]] = split_piece[1];
- }
- });
+ $.each(query_string.substr(1).split("&"), function () {
+ var split_piece = this.split("=");
+ if (split_piece.length == 2) {
+ PetQuery[split_piece[0]] = split_piece[1];
+ }
+ });
- if (PetQuery.name) {
- if (PetQuery.species && PetQuery.color) {
- $("#pet-query-notice-template")
- .tmpl({
- pet_name: PetQuery.name,
- pet_image_url: petImage("cpn/" + PetQuery.name, 1),
- })
- .prependTo("#container");
- }
- }
+ if (PetQuery.name) {
+ if (PetQuery.species && PetQuery.color) {
+ $("#pet-query-notice-template")
+ .tmpl({
+ pet_name: PetQuery.name,
+ pet_image_url: petImage("cpn/" + PetQuery.name, 1),
+ })
+ .prependTo("#container");
+ }
+ }
- var preview_el = $("#pet-preview"),
- img_el = preview_el.find("img"),
- response_el = preview_el.find("span");
+ var preview_el = $("#pet-preview"),
+ img_el = preview_el.find("img"),
+ response_el = preview_el.find("span");
- var defaultPreviewUrl = img_el.attr("src");
+ var defaultPreviewUrl = img_el.attr("src");
- preview_el.click(function () {
- Preview.Job.current.visit();
- });
+ preview_el.click(function () {
+ Preview.Job.current.visit();
+ });
- var Preview = {
- clear: function () {
- if (typeof Preview.Job.fallback != "undefined")
- Preview.Job.fallback.setAsCurrent();
- },
- displayLoading: function () {
- preview_el.addClass("loading");
- response_el.text("Loading...");
- },
- failed: function () {
- preview_el.addClass("hidden");
- },
- notFound: function (key, options) {
- Preview.failed();
- response_el.empty();
- $("#preview-" + key + "-template")
- .tmpl(options)
- .appendTo(response_el);
- },
- updateWithName: function (name_el) {
- var name = name_el.val(),
- job;
- if (name) {
- currentName = name;
- if (!Preview.Job.current || name != Preview.Job.current.name) {
- job = new Preview.Job.Name(name);
- job.setAsCurrent();
- Preview.displayLoading();
- }
- } else {
- Preview.clear();
- }
- },
- };
+ var Preview = {
+ clear: function () {
+ if (typeof Preview.Job.fallback != "undefined")
+ Preview.Job.fallback.setAsCurrent();
+ },
+ displayLoading: function () {
+ preview_el.addClass("loading");
+ response_el.text("Loading...");
+ },
+ failed: function () {
+ preview_el.addClass("hidden");
+ },
+ notFound: function (key, options) {
+ Preview.failed();
+ response_el.empty();
+ $("#preview-" + key + "-template")
+ .tmpl(options)
+ .appendTo(response_el);
+ },
+ updateWithName: function (name_el) {
+ var name = name_el.val(),
+ job;
+ if (name) {
+ currentName = name;
+ if (!Preview.Job.current || name != Preview.Job.current.name) {
+ job = new Preview.Job.Name(name);
+ job.setAsCurrent();
+ Preview.displayLoading();
+ }
+ } else {
+ Preview.clear();
+ }
+ },
+ };
- function loadNotable() {
- // TODO: add HTTPS to notables
- // $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
- // var notables = response.notables;
- // var i = Math.floor(Math.random() * notables.length);
- // Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
- // if(!Preview.Job.current) {
- // Preview.Job.fallback.setAsCurrent();
- // }
- // });
- if (!Preview.Job.current) {
- Preview.Job.fallback.setAsCurrent();
- }
- }
+ function loadNotable() {
+ // TODO: add HTTPS to notables
+ // $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
+ // var notables = response.notables;
+ // var i = Math.floor(Math.random() * notables.length);
+ // Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
+ // if(!Preview.Job.current) {
+ // Preview.Job.fallback.setAsCurrent();
+ // }
+ // });
+ if (!Preview.Job.current) {
+ Preview.Job.fallback.setAsCurrent();
+ }
+ }
- function loadFeature() {
- $.getJSON("/donations/features", function (features) {
- if (features.length > 0) {
- var feature = features[Math.floor(Math.random() * features.length)];
- Preview.Job.fallback = new Preview.Job.Feature(feature);
- if (!Preview.Job.current) {
- Preview.Job.fallback.setAsCurrent();
- }
- } else {
- loadNotable();
- }
- });
- }
+ function loadFeature() {
+ $.getJSON("/donations/features", function (features) {
+ if (features.length > 0) {
+ var feature = features[Math.floor(Math.random() * features.length)];
+ Preview.Job.fallback = new Preview.Job.Feature(feature);
+ if (!Preview.Job.current) {
+ Preview.Job.fallback.setAsCurrent();
+ }
+ } else {
+ loadNotable();
+ }
+ });
+ }
- loadFeature();
+ loadFeature();
- Preview.Job = function (key, base) {
- var job = this,
- quality = 2;
- job.loading = false;
+ Preview.Job = function (key, base) {
+ var job = this,
+ quality = 2;
+ job.loading = false;
- function getImageSrc() {
- if (key.substr(0, 3) === "a:-") {
- // lol lazy code for prank image :P
- // TODO: HTTPS?
- return (
- "https://swfimages.impress.openneo.net" +
- "/biology/000/000/0-2/" +
- key.substr(2) +
- "/300x300.png"
- );
- } else if (base === "cp" || base === "cpn") {
- return petImage(base + "/" + key, quality);
- } else if (base === "url") {
- return key;
- } else {
- throw new Error("unrecognized image base " + base);
- }
- }
+ function getImageSrc() {
+ if (key.substr(0, 3) === "a:-") {
+ // lol lazy code for prank image :P
+ // TODO: HTTPS?
+ return (
+ "https://swfimages.impress.openneo.net" +
+ "/biology/000/000/0-2/" +
+ key.substr(2) +
+ "/300x300.png"
+ );
+ } else if (base === "cp" || base === "cpn") {
+ return petImage(base + "/" + key, quality);
+ } else if (base === "url") {
+ return key;
+ } else {
+ throw new Error("unrecognized image base " + base);
+ }
+ }
- function load() {
- job.loading = true;
- img_el.attr("src", getImageSrc());
- }
+ function load() {
+ job.loading = true;
+ img_el.attr("src", getImageSrc());
+ }
- this.increaseQualityIfPossible = function () {
- if (quality == 2) {
- quality = 4;
- load();
- }
- };
+ this.increaseQualityIfPossible = function () {
+ if (quality == 2) {
+ quality = 4;
+ load();
+ }
+ };
- this.setAsCurrent = function () {
- Preview.Job.current = job;
- load();
- };
+ this.setAsCurrent = function () {
+ Preview.Job.current = job;
+ load();
+ };
- this.notFound = function () {
- Preview.notFound("pet-not-found");
- };
- };
+ this.notFound = function () {
+ Preview.notFound("pet-not-found");
+ };
+ };
- Preview.Job.Name = function (name) {
- this.name = name;
- Preview.Job.apply(this, [name, "cpn"]);
+ Preview.Job.Name = function (name) {
+ this.name = name;
+ Preview.Job.apply(this, [name, "cpn"]);
- this.visit = function () {
- $(".main-pet-name").val(this.name).closest("form").submit();
- };
- };
+ this.visit = function () {
+ $(".main-pet-name").val(this.name).closest("form").submit();
+ };
+ };
- Preview.Job.Hash = function (hash, form) {
- Preview.Job.apply(this, [hash, "cp"]);
+ Preview.Job.Hash = function (hash, form) {
+ Preview.Job.apply(this, [hash, "cp"]);
- this.visit = function () {
- window.location =
- "/wardrobe?color=" +
- form.find(".color").val() +
- "&species=" +
- form.find(".species").val();
- };
- };
+ this.visit = function () {
+ window.location =
+ "/wardrobe?color=" +
+ form.find(".color").val() +
+ "&species=" +
+ form.find(".species").val();
+ };
+ };
- Preview.Job.Feature = function (feature) {
- Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
- this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
+ Preview.Job.Feature = function (feature) {
+ Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
+ this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
- this.visit = function () {
- window.location = "/donate";
- };
+ this.visit = function () {
+ window.location = "/donate";
+ };
- this.notFound = function () {
- // The outfit thumbnail hasn't generated or is missing or something.
- // Let's fall back to a boring image for now.
- var boring = new Preview.Job.Feature({
- donor_name: feature.donor_name,
- outfit_image_url: defaultPreviewUrl,
- });
- boring.setAsCurrent();
- };
- };
+ this.notFound = function () {
+ // The outfit thumbnail hasn't generated or is missing or something.
+ // Let's fall back to a boring image for now.
+ var boring = new Preview.Job.Feature({
+ donor_name: feature.donor_name,
+ outfit_image_url: defaultPreviewUrl,
+ });
+ boring.setAsCurrent();
+ };
+ };
- $(function () {
- var previewWithNameTimeout;
+ $(function () {
+ var previewWithNameTimeout;
- var name_el = $(".main-pet-name");
- name_el.val(PetQuery.name);
- Preview.updateWithName(name_el);
+ var name_el = $(".main-pet-name");
+ name_el.val(PetQuery.name);
+ Preview.updateWithName(name_el);
- name_el.keyup(function () {
- if (previewWithNameTimeout && Preview.Job.current) {
- clearTimeout(previewWithNameTimeout);
- Preview.Job.current.loading = false;
- }
- var name_el = $(this);
- previewWithNameTimeout = setTimeout(function () {
- Preview.updateWithName(name_el);
- }, 250);
- });
+ name_el.keyup(function () {
+ if (previewWithNameTimeout && Preview.Job.current) {
+ clearTimeout(previewWithNameTimeout);
+ Preview.Job.current.loading = false;
+ }
+ var name_el = $(this);
+ previewWithNameTimeout = setTimeout(function () {
+ Preview.updateWithName(name_el);
+ }, 250);
+ });
- img_el
- .load(function () {
- if (Preview.Job.current.loading) {
- Preview.Job.loading = false;
- Preview.Job.current.increaseQualityIfPossible();
- preview_el
- .removeClass("loading")
- .removeClass("hidden")
- .addClass("loaded");
- response_el.text(Preview.Job.current.name);
- }
- })
- .error(function () {
- if (Preview.Job.current.loading) {
- Preview.Job.loading = false;
- Preview.Job.current.notFound();
- }
- });
+ img_el
+ .load(function () {
+ if (Preview.Job.current.loading) {
+ Preview.Job.loading = false;
+ Preview.Job.current.increaseQualityIfPossible();
+ preview_el
+ .removeClass("loading")
+ .removeClass("hidden")
+ .addClass("loaded");
+ response_el.text(Preview.Job.current.name);
+ }
+ })
+ .error(function () {
+ if (Preview.Job.current.loading) {
+ Preview.Job.loading = false;
+ Preview.Job.current.notFound();
+ }
+ });
- $(".species, .color").change(function () {
- var type = {},
- nameComponents = {};
- var form = $(this).closest("form");
- form.find("select").each(function () {
- var el = $(this),
- selectedEl = el.children(":selected"),
- key = el.attr("name");
- type[key] = selectedEl.val();
- nameComponents[key] = selectedEl.text();
- });
- name = nameComponents.color + " " + nameComponents.species;
- Preview.displayLoading();
- $.ajax({
- url:
- "/species/" +
- type.species +
- "/colors/" +
- type.color +
- "/pet_type.json",
- dataType: "json",
- success: function (data) {
- var job;
- if (data) {
- job = new Preview.Job.Hash(data.image_hash, form);
- job.name = name;
- job.setAsCurrent();
- } else {
- Preview.notFound("pet-type-not-found", {
- color_name: nameComponents.color,
- species_name: nameComponents.species,
- });
- }
- },
- });
- });
+ $(".species, .color").change(function () {
+ var type = {},
+ nameComponents = {};
+ var form = $(this).closest("form");
+ form.find("select").each(function () {
+ var el = $(this),
+ selectedEl = el.children(":selected"),
+ key = el.attr("name");
+ type[key] = selectedEl.val();
+ nameComponents[key] = selectedEl.text();
+ });
+ name = nameComponents.color + " " + nameComponents.species;
+ Preview.displayLoading();
+ $.ajax({
+ url:
+ "/species/" +
+ type.species +
+ "/colors/" +
+ type.color +
+ "/pet_type.json",
+ dataType: "json",
+ success: function (data) {
+ var job;
+ if (data) {
+ job = new Preview.Job.Hash(data.image_hash, form);
+ job.name = name;
+ job.setAsCurrent();
+ } else {
+ Preview.notFound("pet-type-not-found", {
+ color_name: nameComponents.color,
+ species_name: nameComponents.species,
+ });
+ }
+ },
+ });
+ });
- $(".load-pet-to-wardrobe").submit(function (e) {
- if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
- e.preventDefault();
- Preview.Job.current.visit();
- }
- });
- });
+ $(".load-pet-to-wardrobe").submit(function (e) {
+ if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
+ e.preventDefault();
+ Preview.Job.current.visit();
+ }
+ });
+ });
- $("#latest-contribution-created-at").timeago();
+ $("#latest-contribution-created-at").timeago();
})();
diff --git a/app/assets/javascripts/pets/bulk.js b/app/assets/javascripts/pets/bulk.js
index 265ab232..82cd0044 100644
--- a/app/assets/javascripts/pets/bulk.js
+++ b/app/assets/javascripts/pets/bulk.js
@@ -1,208 +1,208 @@
var DEBUG = document.location.search.substr(0, 6) == "?debug";
function petThumbnailUrl(pet_name) {
- // if first character is "@", use the hash url
- if (pet_name[0] == "@") {
- return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
- }
+ // if first character is "@", use the hash url
+ if (pet_name[0] == "@") {
+ return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
+ }
- return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
+ return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
/* Needed items form */
(function () {
- var UI = {};
- UI.form = $("#needed-items-form");
- UI.alert = $("#needed-items-alert");
- UI.pet_name_field = $("#needed-items-pet-name-field");
- UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
- UI.pet_header = $("#needed-items-pet-header");
- UI.reload = $("#needed-items-reload");
- UI.pet_items = $("#needed-items-pet-items");
- UI.item_template = $("#item-template");
+ var UI = {};
+ UI.form = $("#needed-items-form");
+ UI.alert = $("#needed-items-alert");
+ UI.pet_name_field = $("#needed-items-pet-name-field");
+ UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
+ UI.pet_header = $("#needed-items-pet-header");
+ UI.reload = $("#needed-items-reload");
+ UI.pet_items = $("#needed-items-pet-items");
+ UI.item_template = $("#item-template");
- var current_request = { abort: function () {} };
- function sendRequest(options) {
- current_request = $.ajax(options);
- }
+ var current_request = { abort: function () {} };
+ function sendRequest(options) {
+ current_request = $.ajax(options);
+ }
- function cancelRequest() {
- if (DEBUG) console.log("Canceling request", current_request);
- current_request.abort();
- }
+ function cancelRequest() {
+ if (DEBUG) console.log("Canceling request", current_request);
+ current_request.abort();
+ }
- /* Pet */
+ /* Pet */
- var last_successful_pet_name = null;
+ var last_successful_pet_name = null;
- function loadPet(pet_name) {
- // If there is a request in progress, kill it. Our new pet request takes
- // priority, and, if I submit a name while the previous name is loading, I
- // don't want to process both responses.
- cancelRequest();
+ function loadPet(pet_name) {
+ // If there is a request in progress, kill it. Our new pet request takes
+ // priority, and, if I submit a name while the previous name is loading, I
+ // don't want to process both responses.
+ cancelRequest();
- sendRequest({
- url: UI.form.attr("action") + ".json",
- dataType: "json",
- data: { name: pet_name },
- error: petError,
- success: function (data) {
- petSuccess(data, pet_name);
- },
- complete: petComplete,
- });
+ sendRequest({
+ url: UI.form.attr("action") + ".json",
+ dataType: "json",
+ data: { name: pet_name },
+ error: petError,
+ success: function (data) {
+ petSuccess(data, pet_name);
+ },
+ complete: petComplete,
+ });
- UI.form.removeClass("failed").addClass("loading-pet");
- }
+ UI.form.removeClass("failed").addClass("loading-pet");
+ }
- function petComplete() {
- UI.form.removeClass("loading-pet");
- }
+ function petComplete() {
+ UI.form.removeClass("loading-pet");
+ }
- function petError(xhr) {
- UI.alert.text(xhr.responseText);
- UI.form.addClass("failed");
- }
+ function petError(xhr) {
+ UI.alert.text(xhr.responseText);
+ UI.form.addClass("failed");
+ }
- function petSuccess(data, pet_name) {
- last_successful_pet_name = pet_name;
- UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
- UI.pet_header.empty();
- $("#needed-items-pet-header-template")
- .tmpl({ pet_name: pet_name })
- .appendTo(UI.pet_header);
- loadItems(data.query);
- }
+ function petSuccess(data, pet_name) {
+ last_successful_pet_name = pet_name;
+ UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
+ UI.pet_header.empty();
+ $("#needed-items-pet-header-template")
+ .tmpl({ pet_name: pet_name })
+ .appendTo(UI.pet_header);
+ loadItems(data.query);
+ }
- /* Items */
+ /* Items */
- function loadItems(query) {
- UI.form.addClass("loading-items");
- sendRequest({
- url: "/items/needed.json",
- dataType: "json",
- data: query,
- success: itemsSuccess,
- });
- }
+ function loadItems(query) {
+ UI.form.addClass("loading-items");
+ sendRequest({
+ url: "/items/needed.json",
+ dataType: "json",
+ data: query,
+ success: itemsSuccess,
+ });
+ }
- function itemsSuccess(items) {
- if (DEBUG) {
- // The dev server is missing lots of data, so sends me 2000+ needed
- // items. We don't need that many for styling, so limit it to 100 to make
- // my browser happier.
- items = items.slice(0, 100);
- }
+ function itemsSuccess(items) {
+ if (DEBUG) {
+ // The dev server is missing lots of data, so sends me 2000+ needed
+ // items. We don't need that many for styling, so limit it to 100 to make
+ // my browser happier.
+ items = items.slice(0, 100);
+ }
- UI.pet_items.empty();
- UI.item_template.tmpl(items).appendTo(UI.pet_items);
+ UI.pet_items.empty();
+ UI.item_template.tmpl(items).appendTo(UI.pet_items);
- UI.form.removeClass("loading-items").addClass("loaded");
- }
+ UI.form.removeClass("loading-items").addClass("loaded");
+ }
- UI.form.submit(function (e) {
- e.preventDefault();
- loadPet(UI.pet_name_field.val());
- });
+ UI.form.submit(function (e) {
+ e.preventDefault();
+ loadPet(UI.pet_name_field.val());
+ });
- UI.reload.click(function (e) {
- e.preventDefault();
- loadPet(last_successful_pet_name);
- });
+ UI.reload.click(function (e) {
+ e.preventDefault();
+ loadPet(last_successful_pet_name);
+ });
})();
/* Bulk pets form */
(function () {
- var form = $("#bulk-pets-form"),
- queue_el = form.find("ul"),
- names_el = form.find("textarea"),
- add_el = $("#bulk-pets-form-add"),
- clear_el = $("#bulk-pets-form-clear"),
- bulk_load_queue;
+ var form = $("#bulk-pets-form"),
+ queue_el = form.find("ul"),
+ names_el = form.find("textarea"),
+ add_el = $("#bulk-pets-form-add"),
+ clear_el = $("#bulk-pets-form-clear"),
+ bulk_load_queue;
- $(document.body).addClass("js");
+ $(document.body).addClass("js");
- bulk_load_queue = new (function BulkLoadQueue() {
- var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
- var RECENTLY_SENT_MAX = 3;
- var pets = [],
- url = form.attr("action") + ".json",
- recently_sent_count = 0,
- loading = false;
+ bulk_load_queue = new (function BulkLoadQueue() {
+ var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
+ var RECENTLY_SENT_MAX = 3;
+ var pets = [],
+ url = form.attr("action") + ".json",
+ recently_sent_count = 0,
+ loading = false;
- function Pet(name) {
- var el = $("#bulk-pets-submission-template")
- .tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
- .appendTo(queue_el);
+ function Pet(name) {
+ var el = $("#bulk-pets-submission-template")
+ .tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
+ .appendTo(queue_el);
- this.load = function () {
- el.removeClass("waiting").addClass("loading");
- var response_el = el.find("span.response");
- pets.shift();
- loading = true;
- $.ajax({
- complete: function (data) {
- loading = false;
- loadNextIfReady();
- },
- data: { name: name },
- dataType: "json",
- error: function (xhr) {
- el.removeClass("loading").addClass("failed");
- response_el.text(xhr.responseText);
- },
- success: function (data) {
- var points = data.points;
- el.removeClass("loading").addClass("loaded");
- $("#bulk-pets-submission-success-template")
- .tmpl({ points: points })
- .appendTo(response_el);
- },
- type: "post",
- url: url,
- });
+ this.load = function () {
+ el.removeClass("waiting").addClass("loading");
+ var response_el = el.find("span.response");
+ pets.shift();
+ loading = true;
+ $.ajax({
+ complete: function (data) {
+ loading = false;
+ loadNextIfReady();
+ },
+ data: { name: name },
+ dataType: "json",
+ error: function (xhr) {
+ el.removeClass("loading").addClass("failed");
+ response_el.text(xhr.responseText);
+ },
+ success: function (data) {
+ var points = data.points;
+ el.removeClass("loading").addClass("loaded");
+ $("#bulk-pets-submission-success-template")
+ .tmpl({ points: points })
+ .appendTo(response_el);
+ },
+ type: "post",
+ url: url,
+ });
- recently_sent_count++;
- setTimeout(function () {
- recently_sent_count--;
- loadNextIfReady();
- }, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
- };
- }
+ recently_sent_count++;
+ setTimeout(function () {
+ recently_sent_count--;
+ loadNextIfReady();
+ }, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
+ };
+ }
- this.add = function (name) {
- name = name.replace(/^\s+|\s+$/g, "");
- if (name.length) {
- var pet = new Pet(name);
- pets.push(pet);
- if (pets.length == 1) loadNextIfReady();
- }
- };
+ this.add = function (name) {
+ name = name.replace(/^\s+|\s+$/g, "");
+ if (name.length) {
+ var pet = new Pet(name);
+ pets.push(pet);
+ if (pets.length == 1) loadNextIfReady();
+ }
+ };
- function loadNextIfReady() {
- if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
- pets[0].load();
- }
- }
- })();
+ function loadNextIfReady() {
+ if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
+ pets[0].load();
+ }
+ }
+ })();
- names_el.keyup(function () {
- var names = this.value.split("\n"),
- x = names.length - 1,
- i,
- name;
- for (i = 0; i < x; i++) {
- bulk_load_queue.add(names[i]);
- }
- this.value = x >= 0 ? names[x] : "";
- });
+ names_el.keyup(function () {
+ var names = this.value.split("\n"),
+ x = names.length - 1,
+ i,
+ name;
+ for (i = 0; i < x; i++) {
+ bulk_load_queue.add(names[i]);
+ }
+ this.value = x >= 0 ? names[x] : "";
+ });
- add_el.click(function () {
- bulk_load_queue.add(names_el.val());
- names_el.val("");
- });
+ add_el.click(function () {
+ bulk_load_queue.add(names_el.val());
+ names_el.val("");
+ });
- clear_el.click(function () {
- queue_el.children("li.loaded, li.failed").remove();
- });
+ clear_el.click(function () {
+ queue_el.children("li.loaded, li.failed").remove();
+ });
})();
diff --git a/app/javascript/application.js b/app/javascript/application.js
index 120fc031..48bb4030 100644
--- a/app/javascript/application.js
+++ b/app/javascript/application.js
@@ -1,5 +1,5 @@
import "@hotwired/turbo-rails";
document.getElementById("locale").addEventListener("change", function () {
- document.getElementById("locale-form").submit();
+ document.getElementById("locale-form").submit();
});
diff --git a/app/javascript/wardrobe-2020-page.js b/app/javascript/wardrobe-2020-page.js
index e8595f82..c07e4afd 100644
--- a/app/javascript/wardrobe-2020-page.js
+++ b/app/javascript/wardrobe-2020-page.js
@@ -7,8 +7,8 @@ const rootNode = document.querySelector("#wardrobe-2020-root");
// TODO: Use the new React 18 APIs instead!
// eslint-disable-next-line react/no-deprecated
ReactDOM.render(
-
-
- ,
- rootNode,
+
+
+ ,
+ rootNode,
);
diff --git a/app/javascript/wardrobe-2020/AppProvider.js b/app/javascript/wardrobe-2020/AppProvider.js
index f51a487d..a351982c 100644
--- a/app/javascript/wardrobe-2020/AppProvider.js
+++ b/app/javascript/wardrobe-2020/AppProvider.js
@@ -2,12 +2,12 @@ import React from "react";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import {
- ChakraProvider,
- Box,
- css as resolveCSS,
- extendTheme,
- useColorMode,
- useTheme,
+ ChakraProvider,
+ Box,
+ css as resolveCSS,
+ extendTheme,
+ useColorMode,
+ useTheme,
} from "@chakra-ui/react";
import { mode } from "@chakra-ui/theme-tools";
import { ApolloProvider } from "@apollo/client";
@@ -20,15 +20,15 @@ import apolloClient from "./apolloClient";
const reactQueryClient = new QueryClient();
let theme = extendTheme({
- styles: {
- global: (props) => ({
- body: {
- background: mode("gray.50", "gray.800")(props),
- color: mode("green.800", "green.50")(props),
- transition: "all 0.25s",
- },
- }),
- },
+ styles: {
+ global: (props) => ({
+ body: {
+ background: mode("gray.50", "gray.800")(props),
+ color: mode("green.800", "green.50")(props),
+ transition: "all 0.25s",
+ },
+ }),
+ },
});
// Capture the global styles function from our theme, but remove it from the
@@ -43,60 +43,60 @@ const globalStyles = theme.styles.global;
theme.styles.global = {};
export default function AppProvider({ children }) {
- React.useEffect(() => setupLogging(), []);
+ React.useEffect(() => setupLogging(), []);
- return (
-
-
-
-
- {children}
-
-
-
-
- );
+ return (
+
+
+
+
+ {children}
+
+
+
+
+ );
}
function setupLogging() {
- Sentry.init({
- dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
- autoSessionTracking: true,
- integrations: [
- new Integrations.BrowserTracing({
- beforeNavigate: (context) => ({
- ...context,
- // Assume any path segment starting with a digit is an ID, and replace
- // it with `:id`. This will help group related routes in Sentry stats.
- // NOTE: I'm a bit uncertain about the timing on this for tracking
- // client-side navs... but we now only track first-time
- // pageloads, and it definitely works correctly for them!
- name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
- }),
+ Sentry.init({
+ dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
+ autoSessionTracking: true,
+ integrations: [
+ new Integrations.BrowserTracing({
+ beforeNavigate: (context) => ({
+ ...context,
+ // Assume any path segment starting with a digit is an ID, and replace
+ // it with `:id`. This will help group related routes in Sentry stats.
+ // NOTE: I'm a bit uncertain about the timing on this for tracking
+ // client-side navs... but we now only track first-time
+ // pageloads, and it definitely works correctly for them!
+ name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
+ }),
- // We have a _lot_ of location changes that don't actually signify useful
- // navigations, like in the wardrobe page. It could be useful to trace
- // them with better filtering someday, but frankly we don't use the perf
- // features besides Web Vitals right now, and those only get tracked on
- // first-time pageloads, anyway. So, don't track client-side navs!
- startTransactionOnLocationChange: false,
- }),
- ],
- denyUrls: [
- // Don't log errors that were probably triggered by extensions and not by
- // our own app. (Apparently Sentry's setting to ignore browser extension
- // errors doesn't do this anywhere near as consistently as I'd expect?)
- //
- // Adapted from https://gist.github.com/impressiver/5092952, as linked in
- // https://docs.sentry.io/platforms/javascript/configuration/filtering/.
- /^chrome-extension:\/\//,
- /^moz-extension:\/\//,
- ],
+ // We have a _lot_ of location changes that don't actually signify useful
+ // navigations, like in the wardrobe page. It could be useful to trace
+ // them with better filtering someday, but frankly we don't use the perf
+ // features besides Web Vitals right now, and those only get tracked on
+ // first-time pageloads, anyway. So, don't track client-side navs!
+ startTransactionOnLocationChange: false,
+ }),
+ ],
+ denyUrls: [
+ // Don't log errors that were probably triggered by extensions and not by
+ // our own app. (Apparently Sentry's setting to ignore browser extension
+ // errors doesn't do this anywhere near as consistently as I'd expect?)
+ //
+ // Adapted from https://gist.github.com/impressiver/5092952, as linked in
+ // https://docs.sentry.io/platforms/javascript/configuration/filtering/.
+ /^chrome-extension:\/\//,
+ /^moz-extension:\/\//,
+ ],
- // Since we're only tracking first-page loads and not navigations, 100%
- // sampling isn't actually so much! Tune down if it becomes a problem, tho.
- tracesSampleRate: 1.0,
- });
+ // Since we're only tracking first-page loads and not navigations, 100%
+ // sampling isn't actually so much! Tune down if it becomes a problem, tho.
+ tracesSampleRate: 1.0,
+ });
}
/**
@@ -112,308 +112,308 @@ function setupLogging() {
* the selector `:where(.chakra-css-reset) h1` is lower specificity.
*/
function ScopedCSSReset({ children }) {
- // Get the current theme and color mode.
- //
- // NOTE: The theme object returned by `useTheme` has some extensions that are
- // necessary for the code below, but aren't present in the theme config
- // returned by `extendTheme`! That's why we use this here instead of `theme`.
- const liveTheme = useTheme();
- const colorMode = useColorMode();
+ // Get the current theme and color mode.
+ //
+ // NOTE: The theme object returned by `useTheme` has some extensions that are
+ // necessary for the code below, but aren't present in the theme config
+ // returned by `extendTheme`! That's why we use this here instead of `theme`.
+ const liveTheme = useTheme();
+ const colorMode = useColorMode();
- // Resolve the theme's global styles into CSS objects for Emotion.
- const globalStylesCSS = resolveCSS(
- globalStyles({ theme: liveTheme, colorMode }),
- )(liveTheme);
+ // Resolve the theme's global styles into CSS objects for Emotion.
+ const globalStylesCSS = resolveCSS(
+ globalStyles({ theme: liveTheme, colorMode }),
+ )(liveTheme);
- // Prepend our special scope selector to the global styles.
- const scopedGlobalStylesCSS = {};
- for (let [selector, rules] of Object.entries(globalStylesCSS)) {
- // The `body` selector is where typography etc rules go, but `body` isn't
- // actually *inside* our scoped element! Instead, ignore the `body` part,
- // and just apply it to the scoping element itself.
- if (selector.trim() === "body") {
- selector = "";
- }
+ // Prepend our special scope selector to the global styles.
+ const scopedGlobalStylesCSS = {};
+ for (let [selector, rules] of Object.entries(globalStylesCSS)) {
+ // The `body` selector is where typography etc rules go, but `body` isn't
+ // actually *inside* our scoped element! Instead, ignore the `body` part,
+ // and just apply it to the scoping element itself.
+ if (selector.trim() === "body") {
+ selector = "";
+ }
- const scopedSelector =
- ":where(.chakra-css-reset, .chakra-portal) " + selector;
- scopedGlobalStylesCSS[scopedSelector] = rules;
- }
+ const scopedSelector =
+ ":where(.chakra-css-reset, .chakra-portal) " + selector;
+ scopedGlobalStylesCSS[scopedSelector] = rules;
+ }
- return (
- <>
- {children}
-
+ {children}
+
- >
- );
+ select::-ms-expand {
+ display: none;
+ }
+ }
+ `}
+ />
+ >
+ );
}
diff --git a/app/javascript/wardrobe-2020/WardrobePage/Item.js b/app/javascript/wardrobe-2020/WardrobePage/Item.js
index b341ad81..a655f50b 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/Item.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/Item.js
@@ -1,31 +1,31 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
- Box,
- Flex,
- IconButton,
- Skeleton,
- Tooltip,
- useColorModeValue,
- useTheme,
+ Box,
+ Flex,
+ IconButton,
+ Skeleton,
+ Tooltip,
+ useColorModeValue,
+ useTheme,
} from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import { loadable } from "../util";
import {
- ItemCardContent,
- ItemBadgeList,
- ItemKindBadge,
- MaybeAnimatedBadge,
- YouOwnThisBadge,
- YouWantThisBadge,
- getZoneBadges,
+ ItemCardContent,
+ ItemBadgeList,
+ ItemKindBadge,
+ MaybeAnimatedBadge,
+ YouOwnThisBadge,
+ YouWantThisBadge,
+ getZoneBadges,
} from "../components/ItemCard";
import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport";
-const LoadableItemSupportDrawer = loadable(() =>
- import("./support/ItemSupportDrawer"),
+const LoadableItemSupportDrawer = loadable(
+ () => import("./support/ItemSupportDrawer"),
);
/**
@@ -48,79 +48,79 @@ const LoadableItemSupportDrawer = loadable(() =>
* devices.
*/
function Item({
- item,
- itemNameId,
- isWorn,
- isInOutfit,
- onRemove,
- isDisabled = false,
+ item,
+ itemNameId,
+ isWorn,
+ isInOutfit,
+ onRemove,
+ isDisabled = false,
}) {
- const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
+ const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
- return (
- <>
-
-
- }
- itemNameId={itemNameId}
- isWorn={isWorn}
- isDiabled={isDisabled}
- focusSelector={containerHasFocus}
- />
-
-
- {isInOutfit && (
- }
- label="Remove"
- onClick={(e) => {
- onRemove(item.id);
- e.preventDefault();
- }}
- />
- )}
-
- }
- label="Support"
- onClick={(e) => {
- setSupportDrawerIsOpen(true);
- e.preventDefault();
- }}
- />
-
- }
- label="More info"
- to={`/items/${item.id}`}
- target="_blank"
- />
-
-
-
- setSupportDrawerIsOpen(false)}
- />
-
- >
- );
+ return (
+ <>
+
+
+ }
+ itemNameId={itemNameId}
+ isWorn={isWorn}
+ isDiabled={isDisabled}
+ focusSelector={containerHasFocus}
+ />
+
+
+ {isInOutfit && (
+ }
+ label="Remove"
+ onClick={(e) => {
+ onRemove(item.id);
+ e.preventDefault();
+ }}
+ />
+ )}
+
+ }
+ label="Support"
+ onClick={(e) => {
+ setSupportDrawerIsOpen(true);
+ e.preventDefault();
+ }}
+ />
+
+ }
+ label="More info"
+ to={`/items/${item.id}`}
+ target="_blank"
+ />
+
+
+
+ setSupportDrawerIsOpen(false)}
+ />
+
+ >
+ );
}
/**
* ItemSkeleton is a placeholder for when an Item is loading.
*/
function ItemSkeleton() {
- return (
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+ );
}
/**
@@ -131,152 +131,152 @@ function ItemSkeleton() {
* .item-container parent!
*/
function ItemContainer({ children, isDisabled = false }) {
- const theme = useTheme();
+ const theme = useTheme();
- const focusBackgroundColor = useColorModeValue(
- theme.colors.gray["100"],
- theme.colors.gray["700"],
- );
+ const focusBackgroundColor = useColorModeValue(
+ theme.colors.gray["100"],
+ theme.colors.gray["700"],
+ );
- const activeBorderColor = useColorModeValue(
- theme.colors.green["400"],
- theme.colors.green["500"],
- );
+ const activeBorderColor = useColorModeValue(
+ theme.colors.green["400"],
+ theme.colors.green["500"],
+ );
- const focusCheckedBorderColor = useColorModeValue(
- theme.colors.green["800"],
- theme.colors.green["300"],
- );
+ const focusCheckedBorderColor = useColorModeValue(
+ theme.colors.green["800"],
+ theme.colors.green["300"],
+ );
- return (
-
- {({ css, cx }) => (
-
+ {({ css, cx }) => (
+
- {children}
-
- )}
-
- );
+ input:checked:focus + & {
+ border-color: ${focusCheckedBorderColor};
+ }
+ `,
+ ])}
+ >
+ {children}
+
+ )}
+
+ );
}
function ItemBadges({ item }) {
- const { isSupportUser } = useSupport();
- const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
- const restrictedZones = item.appearanceOn.restrictedZones.filter(
- (z) => z.isCommonlyUsedByItems,
- );
- const isMaybeAnimated = item.appearanceOn.layers.some(
- (l) => l.canvasMovieLibraryUrl,
- );
+ const { isSupportUser } = useSupport();
+ const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
+ const restrictedZones = item.appearanceOn.restrictedZones.filter(
+ (z) => z.isCommonlyUsedByItems,
+ );
+ const isMaybeAnimated = item.appearanceOn.layers.some(
+ (l) => l.canvasMovieLibraryUrl,
+ );
- return (
-
-
- {
- // This badge is unreliable, but it's helpful for looking for animated
- // items to test, so we show it only to support. We use this form
- // instead of , to avoid adding extra badge list spacing
- // on the additional empty child.
- isMaybeAnimated && isSupportUser &&
- }
- {getZoneBadges(occupiedZones, { variant: "occupies" })}
- {getZoneBadges(restrictedZones, { variant: "restricts" })}
- {item.currentUserOwnsThis && }
- {item.currentUserWantsThis && }
-
- );
+ return (
+
+
+ {
+ // This badge is unreliable, but it's helpful for looking for animated
+ // items to test, so we show it only to support. We use this form
+ // instead of , to avoid adding extra badge list spacing
+ // on the additional empty child.
+ isMaybeAnimated && isSupportUser &&
+ }
+ {getZoneBadges(occupiedZones, { variant: "occupies" })}
+ {getZoneBadges(restrictedZones, { variant: "restricts" })}
+ {item.currentUserOwnsThis && }
+ {item.currentUserWantsThis && }
+
+ );
}
/**
* ItemActionButton is one of a list of actions a user can take for this item.
*/
function ItemActionButton({ icon, label, to, onClick, ...props }) {
- const theme = useTheme();
+ const theme = useTheme();
- const focusBackgroundColor = useColorModeValue(
- theme.colors.gray["300"],
- theme.colors.gray["800"],
- );
- const focusColor = useColorModeValue(
- theme.colors.gray["700"],
- theme.colors.gray["200"],
- );
+ const focusBackgroundColor = useColorModeValue(
+ theme.colors.gray["300"],
+ theme.colors.gray["800"],
+ );
+ const focusColor = useColorModeValue(
+ theme.colors.gray["700"],
+ theme.colors.gray["200"],
+ );
- return (
-
- {({ css }) => (
-
-
+ {({ css }) => (
+
+
-
- )}
-
- );
+ @media (hover: none) {
+ opacity: 1;
+ }
+ `}
+ />
+
+ )}
+
+ );
}
function LinkOrButton({ href, component, ...props }) {
- const ButtonComponent = component;
- if (href != null) {
- return ;
- } else {
- return ;
- }
+ const ButtonComponent = component;
+ if (href != null) {
+ return ;
+ } else {
+ return ;
+ }
}
/**
@@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
* components in this to ensure a consistent list layout.
*/
export function ItemListContainer({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
/**
@@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
* Items are loading.
*/
export function ItemListSkeleton({ count, ...props }) {
- return (
-
- {Array.from({ length: count }).map((_, i) => (
-
- ))}
-
- );
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
}
/**
@@ -311,6 +311,6 @@ export function ItemListSkeleton({ count, ...props }) {
* focused.
*/
const containerHasFocus =
- ".item-container:hover &, input:focus + .item-container &";
+ ".item-container:hover &, input:focus + .item-container &";
export default React.memo(Item);
diff --git a/app/javascript/wardrobe-2020/WardrobePage/ItemsAndSearchPanels.js b/app/javascript/wardrobe-2020/WardrobePage/ItemsAndSearchPanels.js
index 176ebadd..881ed06d 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/ItemsAndSearchPanels.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/ItemsAndSearchPanels.js
@@ -21,72 +21,72 @@ import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
* state and refs.
*/
function ItemsAndSearchPanels({
- loading,
- searchQuery,
- onChangeSearchQuery,
- outfitState,
- outfitSaving,
- dispatchToOutfit,
+ loading,
+ searchQuery,
+ onChangeSearchQuery,
+ outfitState,
+ outfitSaving,
+ dispatchToOutfit,
}) {
- const scrollContainerRef = React.useRef();
- const searchQueryRef = React.useRef();
- const firstSearchResultRef = React.useRef();
+ const scrollContainerRef = React.useRef();
+ const searchQueryRef = React.useRef();
+ const firstSearchResultRef = React.useRef();
- const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
- const [canUseSearchFooter] = useLocalStorage(
- "DTIFeatureFlagCanUseSearchFooter",
- false,
- );
- const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
+ const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
+ const [canUseSearchFooter] = useLocalStorage(
+ "DTIFeatureFlagCanUseSearchFooter",
+ false,
+ );
+ const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
- return (
-
-
-
- {isShowingSearchFooter && }
- {!isShowingSearchFooter && (
-
-
-
- )}
- {!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
-
-
-
- ) : (
-
-
-
-
-
- )}
-
-
- );
+ return (
+
+
+
+ {isShowingSearchFooter && }
+ {!isShowingSearchFooter && (
+
+
+
+ )}
+ {!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
+
+
+
+ ) : (
+
+
+
+
+
+ )}
+
+
+ );
}
export default ItemsAndSearchPanels;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js b/app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
index 0b430fa3..4c1410ec 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/ItemsPanel.js
@@ -1,38 +1,38 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
- Box,
- Editable,
- EditablePreview,
- EditableInput,
- Flex,
- IconButton,
- Skeleton,
- Tooltip,
- VisuallyHidden,
- Menu,
- MenuButton,
- MenuList,
- MenuItem,
- Portal,
- Button,
- Spinner,
- useColorModeValue,
- Modal,
- ModalContent,
- ModalOverlay,
- ModalHeader,
- ModalBody,
- ModalFooter,
- useDisclosure,
- ModalCloseButton,
+ Box,
+ Editable,
+ EditablePreview,
+ EditableInput,
+ Flex,
+ IconButton,
+ Skeleton,
+ Tooltip,
+ VisuallyHidden,
+ Menu,
+ MenuButton,
+ MenuList,
+ MenuItem,
+ Portal,
+ Button,
+ Spinner,
+ useColorModeValue,
+ Modal,
+ ModalContent,
+ ModalOverlay,
+ ModalHeader,
+ ModalBody,
+ ModalFooter,
+ useDisclosure,
+ ModalCloseButton,
} from "@chakra-ui/react";
import {
- CheckIcon,
- DeleteIcon,
- EditIcon,
- QuestionIcon,
- WarningTwoIcon,
+ CheckIcon,
+ DeleteIcon,
+ EditIcon,
+ QuestionIcon,
+ WarningTwoIcon,
} from "@chakra-ui/icons";
import { IoBagCheck } from "react-icons/io5";
import { CSSTransition, TransitionGroup } from "react-transition-group";
@@ -59,70 +59,70 @@ import { useDeleteOutfitMutation } from "../loaders/outfits";
* full width of the container, it doesn't look like it!
*/
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
- const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
+ const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
- return (
-
- {({ css }) => (
-
-
-
-
-
- {loading ? (
-
- ) : (
- <>
-
- {zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
-
-
-
- ))}
-
- {incompatibleItems.length > 0 && (
-
-
-
- }
- items={incompatibleItems}
- outfitState={outfitState}
- dispatchToOutfit={dispatchToOutfit}
- isDisabled
- />
- )}
- >
- )}
-
-
- )}
-
- );
+ return (
+
+ {({ css }) => (
+
+
+
+
+
+ {loading ? (
+
+ ) : (
+ <>
+
+ {zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
+
+
+
+ ))}
+
+ {incompatibleItems.length > 0 && (
+
+
+
+ }
+ items={incompatibleItems}
+ outfitState={outfitState}
+ dispatchToOutfit={dispatchToOutfit}
+ isDisabled
+ />
+ )}
+ >
+ )}
+
+
+ )}
+
+ );
}
/**
@@ -134,102 +134,102 @@ function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
* makes the list screen-reader- and keyboard-accessible!
*/
function ItemZoneGroup({
- zoneLabel,
- items,
- outfitState,
- dispatchToOutfit,
- isDisabled = false,
- afterHeader = null,
+ zoneLabel,
+ items,
+ outfitState,
+ dispatchToOutfit,
+ isDisabled = false,
+ afterHeader = null,
}) {
- // onChange is fired when the radio button becomes checked, not unchecked!
- const onChange = (e) => {
- const itemId = e.target.value;
- dispatchToOutfit({ type: "wearItem", itemId });
- };
+ // onChange is fired when the radio button becomes checked, not unchecked!
+ const onChange = (e) => {
+ const itemId = e.target.value;
+ dispatchToOutfit({ type: "wearItem", itemId });
+ };
- // Clicking the radio button when already selected deselects it - this is how
- // you can select none!
- const onClick = (e) => {
- const itemId = e.target.value;
- if (outfitState.wornItemIds.includes(itemId)) {
- // We need the event handler to finish before this, so that simulated
- // events don't just come back around and undo it - but we can't just
- // solve that with `preventDefault`, because it breaks the radio's
- // intended visual updates when we unwear. So, we `setTimeout` to do it
- // after all event handlers resolve!
- setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
- }
- };
+ // Clicking the radio button when already selected deselects it - this is how
+ // you can select none!
+ const onClick = (e) => {
+ const itemId = e.target.value;
+ if (outfitState.wornItemIds.includes(itemId)) {
+ // We need the event handler to finish before this, so that simulated
+ // events don't just come back around and undo it - but we can't just
+ // solve that with `preventDefault`, because it breaks the radio's
+ // intended visual updates when we unwear. So, we `setTimeout` to do it
+ // after all event handlers resolve!
+ setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
+ }
+ };
- const onRemove = React.useCallback(
- (itemId) => {
- dispatchToOutfit({ type: "removeItem", itemId });
- },
- [dispatchToOutfit],
- );
+ const onRemove = React.useCallback(
+ (itemId) => {
+ dispatchToOutfit({ type: "removeItem", itemId });
+ },
+ [dispatchToOutfit],
+ );
- return (
-
- {({ css }) => (
-
-
- {zoneLabel}
- {afterHeader && {afterHeader}}
-
-
-
- {items.map((item) => {
- const itemNameId =
- zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
- const itemNode = (
-
- );
+ return (
+
+ {({ css }) => (
+
+
+ {zoneLabel}
+ {afterHeader && {afterHeader}}
+
+
+
+ {items.map((item) => {
+ const itemNameId =
+ zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
+ const itemNode = (
+
+ );
- return (
-
- {isDisabled ? (
- itemNode
- ) : (
-
- )}
-
- );
- })}
-
-
-
- )}
-
- );
+ return (
+
+ {isDisabled ? (
+ itemNode
+ ) : (
+
+ )}
+
+ );
+ })}
+
+
+
+ )}
+
+ );
}
/**
@@ -240,35 +240,35 @@ function ItemZoneGroup({
* we don't show skeleton items that just clear away!
*/
function ItemZoneGroupsSkeleton({ itemCount }) {
- const groups = [];
- for (let i = 0; i < itemCount; i++) {
- // NOTE: I initially wrote this to return groups of 3, which looks good for
- // outfit shares I think, but looks bad for pet loading... once shares
- // become a more common use case, it might be useful to figure out how
- // to differentiate these cases and show 1-per-group for pets, but
- // maybe more for built outfits?
- groups.push();
- }
- return groups;
+ const groups = [];
+ for (let i = 0; i < itemCount; i++) {
+ // NOTE: I initially wrote this to return groups of 3, which looks good for
+ // outfit shares I think, but looks bad for pet loading... once shares
+ // become a more common use case, it might be useful to figure out how
+ // to differentiate these cases and show 1-per-group for pets, but
+ // maybe more for built outfits?
+ groups.push();
+ }
+ return groups;
}
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
- return (
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ );
}
/**
@@ -277,36 +277,36 @@ function ItemZoneGroupSkeleton({ itemCount }) {
* this is disabled.
*/
function ShoppingListButton({ outfitState }) {
- const itemIds = [...outfitState.wornItemIds].sort();
- const isDisabled = itemIds.length === 0;
+ const itemIds = [...outfitState.wornItemIds].sort();
+ const isDisabled = itemIds.length === 0;
- let targetUrl = `/items/sources/${itemIds.join(",")}`;
- if (outfitState.name != null && outfitState.name.trim().length > 0) {
- const params = new URLSearchParams();
- params.append("for", outfitState.name);
- targetUrl += "?" + params.toString();
- }
+ let targetUrl = `/items/sources/${itemIds.join(",")}`;
+ if (outfitState.name != null && outfitState.name.trim().length > 0) {
+ const params = new URLSearchParams();
+ params.append("for", outfitState.name);
+ targetUrl += "?" + params.toString();
+ }
- return (
-
- }
- colorScheme="purple"
- size="sm"
- isRound
- isDisabled={isDisabled}
- />
-
- );
+ return (
+
+ }
+ colorScheme="purple"
+ size="sm"
+ isRound
+ isDisabled={isDisabled}
+ />
+
+ );
}
/**
@@ -314,100 +314,100 @@ function ShoppingListButton({ outfitState }) {
* if the user can save this outfit. If not, this is empty!
*/
function OutfitSavingIndicator({ outfitSaving }) {
- const {
- canSaveOutfit,
- isNewOutfit,
- isSaving,
- latestVersionIsSaved,
- saveError,
- saveOutfit,
- } = outfitSaving;
+ const {
+ canSaveOutfit,
+ isNewOutfit,
+ isSaving,
+ latestVersionIsSaved,
+ saveError,
+ saveOutfit,
+ } = outfitSaving;
- const errorTextColor = useColorModeValue("red.600", "red.400");
+ const errorTextColor = useColorModeValue("red.600", "red.400");
- if (!canSaveOutfit) {
- return null;
- }
+ if (!canSaveOutfit) {
+ return null;
+ }
- if (isNewOutfit) {
- return (
-
- );
- }
+ if (isNewOutfit) {
+ return (
+
+ );
+ }
- if (isSaving) {
- return (
-
-
- Saving…
-
- );
- }
+ if (isSaving) {
+ return (
+
+
+ Saving…
+
+ );
+ }
- if (latestVersionIsSaved) {
- return (
-
-
- Saved
-
- );
- }
+ if (latestVersionIsSaved) {
+ return (
+
+
+ Saved
+
+ );
+ }
- if (saveError) {
- return (
-
-
- Error saving
-
- );
- }
+ if (saveError) {
+ return (
+
+
+ Error saving
+
+ );
+ }
- // The most common way we'll hit this null is when the outfit is changing,
- // but the debouncing isn't done yet, so it's not saving yet.
- return null;
+ // The most common way we'll hit this null is when the outfit is changing,
+ // but the debouncing isn't done yet, so it's not saving yet.
+ return null;
}
/**
@@ -415,133 +415,133 @@ function OutfitSavingIndicator({ outfitSaving }) {
* It also contains the outfit menu, for saving etc.
*/
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
- const { canDeleteOutfit } = outfitSaving;
- const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
+ const { canDeleteOutfit } = outfitSaving;
+ const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
- return (
- // The Editable wraps everything, including the menu, because the menu has
- // a Rename option.
-
- dispatchToOutfit({ type: "rename", outfitName: value })
- }
- >
- {({ onEdit }) => (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
+ return (
+ // The Editable wraps everything, including the menu, because the menu has
+ // a Rename option.
+
+ dispatchToOutfit({ type: "rename", outfitName: value })
+ }
+ >
+ {({ onEdit }) => (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
}
function DeleteOutfitMenuItem({ outfitState }) {
- const { id, name } = outfitState;
- const { isOpen, onOpen, onClose } = useDisclosure();
+ const { id, name } = outfitState;
+ const { isOpen, onOpen, onClose } = useDisclosure();
- const { status, error, mutateAsync } = useDeleteOutfitMutation();
+ const { status, error, mutateAsync } = useDeleteOutfitMutation();
- return (
- <>
- } onClick={onOpen}>
- Delete
-
-
-
-
- Delete outfit "{name}"?
-
-
- We'll delete this data and remove it from your list of outfits.
- Links and image embeds pointing to this outfit will break. Is that
- okay?
- {status === "error" && (
-
- Error deleting outfit: "{error.message}". Try again?
-
- )}
-
-
-
-
-
-
-
-
- >
- );
+ return (
+ <>
+ } onClick={onOpen}>
+ Delete
+
+
+
+
+ Delete outfit "{name}"?
+
+
+ We'll delete this data and remove it from your list of outfits.
+ Links and image embeds pointing to this outfit will break. Is that
+ okay?
+ {status === "error" && (
+
+ Error deleting outfit: "{error.message}". Try again?
+
+ )}
+
+
+
+
+
+
+
+
+ >
+ );
}
/**
@@ -555,24 +555,24 @@ function DeleteOutfitMenuItem({ outfitState }) {
* See react-transition-group docs for more info!
*/
const fadeOutAndRollUpTransition = (css) => ({
- classNames: css`
- &-exit {
- opacity: 1;
- height: auto;
- }
+ classNames: css`
+ &-exit {
+ opacity: 1;
+ height: auto;
+ }
- &-exit-active {
- opacity: 0;
- height: 0 !important;
- margin-top: 0 !important;
- margin-bottom: 0 !important;
- transition: all 0.5s;
- }
- `,
- timeout: 500,
- onExit: (e) => {
- e.style.height = e.offsetHeight + "px";
- },
+ &-exit-active {
+ opacity: 0;
+ height: 0 !important;
+ margin-top: 0 !important;
+ margin-bottom: 0 !important;
+ transition: all 0.5s;
+ }
+ `,
+ timeout: 500,
+ onExit: (e) => {
+ e.style.height = e.offsetHeight + "px";
+ },
});
export default ItemsPanel;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/LayersInfoModal.js b/app/javascript/wardrobe-2020/WardrobePage/LayersInfoModal.js
index 297ddbd8..132c9b48 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/LayersInfoModal.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/LayersInfoModal.js
@@ -1,92 +1,92 @@
import React from "react";
import {
- Box,
- Button,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalHeader,
- ModalOverlay,
- Table,
- Tbody,
- Td,
- Th,
- Thead,
- Tr,
+ Box,
+ Button,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ Table,
+ Tbody,
+ Td,
+ Th,
+ Thead,
+ Tr,
} from "@chakra-ui/react";
function LayersInfoModal({ isOpen, onClose, visibleLayers }) {
- return (
-
-
-
- Outfit layers
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+ Outfit layers
+
+
+
+
+
+
+
+ );
}
function LayerTable({ layers }) {
- return (
-
-
-
- Preview |
- DTI ID |
- Zone |
- Links |
-
-
-
- {layers.map((layer) => (
-
- ))}
-
-
- );
+ return (
+
+
+
+ Preview |
+ DTI ID |
+ Zone |
+ Links |
+
+
+
+ {layers.map((layer) => (
+
+ ))}
+
+
+ );
}
function LayerTableRow({ layer, ...props }) {
- return (
-
-
-
- |
- {layer.id} |
- {layer.zone.label} |
-
-
- {layer.imageUrl && (
-
- )}
- {layer.swfUrl && (
-
- )}
- {layer.svgUrl && (
-
- )}
-
- |
-
- );
+ return (
+
+
+
+ |
+ {layer.id} |
+ {layer.zone.label} |
+
+
+ {layer.imageUrl && (
+
+ )}
+ {layer.swfUrl && (
+
+ )}
+ {layer.svgUrl && (
+
+ )}
+
+ |
+
+ );
}
export default LayersInfoModal;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js b/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
index 021a8ac6..bdd98f54 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/OutfitControls.js
@@ -1,40 +1,40 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
- Box,
- Button,
- DarkMode,
- Flex,
- FormControl,
- FormHelperText,
- FormLabel,
- HStack,
- IconButton,
- ListItem,
- Menu,
- MenuItem,
- MenuList,
- Popover,
- PopoverArrow,
- PopoverBody,
- PopoverContent,
- PopoverTrigger,
- Portal,
- Stack,
- Switch,
- Tooltip,
- UnorderedList,
- useBreakpointValue,
- useClipboard,
- useToast,
+ Box,
+ Button,
+ DarkMode,
+ Flex,
+ FormControl,
+ FormHelperText,
+ FormLabel,
+ HStack,
+ IconButton,
+ ListItem,
+ Menu,
+ MenuItem,
+ MenuList,
+ Popover,
+ PopoverArrow,
+ PopoverBody,
+ PopoverContent,
+ PopoverTrigger,
+ Portal,
+ Stack,
+ Switch,
+ Tooltip,
+ UnorderedList,
+ useBreakpointValue,
+ useClipboard,
+ useToast,
} from "@chakra-ui/react";
import {
- ArrowBackIcon,
- CheckIcon,
- ChevronDownIcon,
- DownloadIcon,
- LinkIcon,
- SettingsIcon,
+ ArrowBackIcon,
+ CheckIcon,
+ ChevronDownIcon,
+ DownloadIcon,
+ LinkIcon,
+ SettingsIcon,
} from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md";
@@ -55,589 +55,589 @@ const LoadableLayersInfoModal = loadable(() => import("./LayersInfoModal"));
* control things like species/color and sharing links!
*/
function OutfitControls({
- outfitState,
- dispatchToOutfit,
- showAnimationControls,
- appearance,
+ outfitState,
+ dispatchToOutfit,
+ showAnimationControls,
+ appearance,
}) {
- const [focusIsLocked, setFocusIsLocked] = React.useState(false);
- const onLockFocus = React.useCallback(
- () => setFocusIsLocked(true),
- [setFocusIsLocked],
- );
- const onUnlockFocus = React.useCallback(
- () => setFocusIsLocked(false),
- [setFocusIsLocked],
- );
+ const [focusIsLocked, setFocusIsLocked] = React.useState(false);
+ const onLockFocus = React.useCallback(
+ () => setFocusIsLocked(true),
+ [setFocusIsLocked],
+ );
+ const onUnlockFocus = React.useCallback(
+ () => setFocusIsLocked(false),
+ [setFocusIsLocked],
+ );
- // HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily,
- // which triggers unnecessary rebuilds of the `onSpeciesColorChange`
- // callback, which causes the `React.memo` on `SpeciesColorPicker` to
- // fail, which harms performance. But it seems to work just fine if we
- // hold onto the first copy of the function we get! :/
- const _toast = useToast();
- // eslint-disable-next-line react-hooks/exhaustive-deps
- const toast = React.useMemo(() => _toast, []);
+ // HACK: As of 1.0.0-rc.0, Chakra's `toast` function rebuilds unnecessarily,
+ // which triggers unnecessary rebuilds of the `onSpeciesColorChange`
+ // callback, which causes the `React.memo` on `SpeciesColorPicker` to
+ // fail, which harms performance. But it seems to work just fine if we
+ // hold onto the first copy of the function we get! :/
+ const _toast = useToast();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ const toast = React.useMemo(() => _toast, []);
- const speciesColorPickerSize = useBreakpointValue({ base: "sm", md: "md" });
+ const speciesColorPickerSize = useBreakpointValue({ base: "sm", md: "md" });
- const onSpeciesColorChange = React.useCallback(
- (species, color, isValid, closestPose) => {
- if (isValid) {
- dispatchToOutfit({
- type: "setSpeciesAndColor",
- speciesId: species.id,
- colorId: color.id,
- pose: closestPose,
- });
- } else {
- // NOTE: This shouldn't be possible to trigger, because the
- // `stateMustAlwaysBeValid` prop should prevent it. But we have
- // it as a fallback, just in case!
- toast({
- title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
- status: "warning",
- });
- }
- },
- [dispatchToOutfit, toast],
- );
+ const onSpeciesColorChange = React.useCallback(
+ (species, color, isValid, closestPose) => {
+ if (isValid) {
+ dispatchToOutfit({
+ type: "setSpeciesAndColor",
+ speciesId: species.id,
+ colorId: color.id,
+ pose: closestPose,
+ });
+ } else {
+ // NOTE: This shouldn't be possible to trigger, because the
+ // `stateMustAlwaysBeValid` prop should prevent it. But we have
+ // it as a fallback, just in case!
+ toast({
+ title: `We haven't seen a ${color.name} ${species.name} before! 😓`,
+ status: "warning",
+ });
+ }
+ },
+ [dispatchToOutfit, toast],
+ );
- const maybeUnlockFocus = (e) => {
- // We lock focus when a touch-device user taps the area. When they tap
- // empty space, we treat that as a toggle and release the focus lock.
- if (e.target === e.currentTarget) {
- onUnlockFocus();
- }
- };
+ const maybeUnlockFocus = (e) => {
+ // We lock focus when a touch-device user taps the area. When they tap
+ // empty space, we treat that as a toggle and release the focus lock.
+ if (e.target === e.currentTarget) {
+ onUnlockFocus();
+ }
+ };
- return (
-
- {({ css, cx }) => (
-
-
+ {({ css, cx }) => (
+
+ {
- const opacity = parseFloat(
- getComputedStyle(e.currentTarget).opacity,
- );
- if (opacity < 0.5) {
- // If the controls aren't visible right now, then clicks on them are
- // probably accidental. Ignore them! (We prevent default to block
- // built-in behaviors like link nav, and we stop propagation to block
- // our own custom click handlers. I don't know if I can prevent the
- // select clicks though?)
- e.preventDefault();
- e.stopPropagation();
+ @media (hover: hover) {
+ &:hover {
+ opacity: 1;
+ }
+ }
+ `,
+ focusIsLocked && "focus-is-locked",
+ )}
+ onClickCapture={(e) => {
+ const opacity = parseFloat(
+ getComputedStyle(e.currentTarget).opacity,
+ );
+ if (opacity < 0.5) {
+ // If the controls aren't visible right now, then clicks on them are
+ // probably accidental. Ignore them! (We prevent default to block
+ // built-in behaviors like link nav, and we stop propagation to block
+ // our own custom click handlers. I don't know if I can prevent the
+ // select clicks though?)
+ e.preventDefault();
+ e.stopPropagation();
- // We also show the controls, by locking focus. We'll undo this when
- // the user taps elsewhere (because it will trigger a blur event from
- // our child components), in `maybeUnlockFocus`.
- setFocusIsLocked(true);
- }
- }}
- data-test-id="wardrobe-outfit-controls"
- >
-
-
-
+ // We also show the controls, by locking focus. We'll undo this when
+ // the user taps elsewhere (because it will trigger a blur event from
+ // our child components), in `maybeUnlockFocus`.
+ setFocusIsLocked(true);
+ }
+ }}
+ data-test-id="wardrobe-outfit-controls"
+ >
+
+
+
-
- {showAnimationControls && }
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {outfitState.speciesId && outfitState.colorId && (
-
- {/**
- * We try to center the species/color picker, but the left spacer will
- * shrink more than the pose picker container if we run out of space!
- */}
-
-
-
-
-
-
-
-
-
- )}
-
-
- )}
-
- );
+
+ {showAnimationControls && }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {outfitState.speciesId && outfitState.colorId && (
+
+ {/**
+ * We try to center the species/color picker, but the left spacer will
+ * shrink more than the pose picker container if we run out of space!
+ */}
+
+
+
+
+
+
+
+
+
+ )}
+
+
+ )}
+
+ );
}
function OutfitControlsContextMenu({ outfitState, children }) {
- // NOTE: We track these separately, rather than in one atomic state object,
- // because I want to still keep the menu in the right position when it's
- // animating itself closed!
- const [isOpen, setIsOpen] = React.useState(false);
- const [position, setPosition] = React.useState({ x: 0, y: 0 });
+ // NOTE: We track these separately, rather than in one atomic state object,
+ // because I want to still keep the menu in the right position when it's
+ // animating itself closed!
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [position, setPosition] = React.useState({ x: 0, y: 0 });
- const [layersInfoModalIsOpen, setLayersInfoModalIsOpen] =
- React.useState(false);
+ const [layersInfoModalIsOpen, setLayersInfoModalIsOpen] =
+ React.useState(false);
- const { visibleLayers } = useOutfitAppearance(outfitState);
- const [downloadImageUrl, prepareDownload] =
- useDownloadableImage(visibleLayers);
+ const { visibleLayers } = useOutfitAppearance(outfitState);
+ const [downloadImageUrl, prepareDownload] =
+ useDownloadableImage(visibleLayers);
- return (
- {
- setIsOpen(true);
- setPosition({ x: e.pageX, y: e.pageY });
- e.preventDefault();
- }}
- >
- {children}
-
- setLayersInfoModalIsOpen(false)}
- visibleLayers={visibleLayers}
- />
-
- );
+ return (
+ {
+ setIsOpen(true);
+ setPosition({ x: e.pageX, y: e.pageY });
+ e.preventDefault();
+ }}
+ >
+ {children}
+
+ setLayersInfoModalIsOpen(false)}
+ visibleLayers={visibleLayers}
+ />
+
+ );
}
function OutfitHTML5Badge({ appearance }) {
- const petIsUsingHTML5 =
- appearance.petAppearance?.layers.every(layerUsesHTML5);
+ const petIsUsingHTML5 =
+ appearance.petAppearance?.layers.every(layerUsesHTML5);
- const itemsNotUsingHTML5 = appearance.items.filter((item) =>
- item.appearance.layers.some((l) => !layerUsesHTML5(l)),
- );
- itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name));
+ const itemsNotUsingHTML5 = appearance.items.filter((item) =>
+ item.appearance.layers.some((l) => !layerUsesHTML5(l)),
+ );
+ itemsNotUsingHTML5.sort((a, b) => a.name.localeCompare(b.name));
- const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0;
+ const usesHTML5 = petIsUsingHTML5 && itemsNotUsingHTML5.length === 0;
- let tooltipLabel;
- if (usesHTML5) {
- tooltipLabel = (
- <>This outfit is converted to HTML5, and ready to use on Neopets.com!>
- );
- } else {
- tooltipLabel = (
-
-
- This outfit isn't converted to HTML5 yet, so it might not appear in
- Neopets.com customization yet. Once it's ready, it could look a bit
- different than our temporary preview here. It might even be animated!
-
- {!petIsUsingHTML5 && (
-
- This pet is not yet converted.
-
- )}
- {itemsNotUsingHTML5.length > 0 && (
- <>
-
- The following items aren't yet converted:
-
-
- {itemsNotUsingHTML5.map((item) => (
- {item.name}
- ))}
-
- >
- )}
-
- );
- }
+ let tooltipLabel;
+ if (usesHTML5) {
+ tooltipLabel = (
+ <>This outfit is converted to HTML5, and ready to use on Neopets.com!>
+ );
+ } else {
+ tooltipLabel = (
+
+
+ This outfit isn't converted to HTML5 yet, so it might not appear in
+ Neopets.com customization yet. Once it's ready, it could look a bit
+ different than our temporary preview here. It might even be animated!
+
+ {!petIsUsingHTML5 && (
+
+ This pet is not yet converted.
+
+ )}
+ {itemsNotUsingHTML5.length > 0 && (
+ <>
+
+ The following items aren't yet converted:
+
+
+ {itemsNotUsingHTML5.map((item) => (
+ {item.name}
+ ))}
+
+ >
+ )}
+
+ );
+ }
- return (
-
- );
+ return (
+
+ );
}
/**
* BackButton takes you back home, or to Your Outfits if this outfit is yours.
*/
function BackButton({ outfitState }) {
- const currentUser = useCurrentUser();
- const outfitBelongsToCurrentUser =
- outfitState.creator && outfitState.creator.id === currentUser.id;
+ const currentUser = useCurrentUser();
+ const outfitBelongsToCurrentUser =
+ outfitState.creator && outfitState.creator.id === currentUser.id;
- return (
- }
- aria-label="Leave this outfit"
- d="inline-flex" // Not sure why requires this to style right! ^^`
- data-test-id="wardrobe-nav-back-button"
- />
- );
+ return (
+ }
+ aria-label="Leave this outfit"
+ d="inline-flex" // Not sure why requires this to style right! ^^`
+ data-test-id="wardrobe-nav-back-button"
+ />
+ );
}
/**
* DownloadButton downloads the outfit as an image!
*/
function DownloadButton({ outfitState }) {
- const { visibleLayers } = useOutfitAppearance(outfitState);
+ const { visibleLayers } = useOutfitAppearance(outfitState);
- const [downloadImageUrl, prepareDownload] =
- useDownloadableImage(visibleLayers);
+ const [downloadImageUrl, prepareDownload] =
+ useDownloadableImage(visibleLayers);
- return (
-
-
- }
- aria-label="Download"
- as="a"
- // eslint-disable-next-line no-script-url
- href={downloadImageUrl || "#"}
- onClick={(e) => {
- if (!downloadImageUrl) {
- e.preventDefault();
- }
- }}
- download={(outfitState.name || "Outfit") + ".png"}
- onMouseEnter={prepareDownload}
- onFocus={prepareDownload}
- cursor={!downloadImageUrl && "wait"}
- />
-
-
- );
+ return (
+
+
+ }
+ aria-label="Download"
+ as="a"
+ // eslint-disable-next-line no-script-url
+ href={downloadImageUrl || "#"}
+ onClick={(e) => {
+ if (!downloadImageUrl) {
+ e.preventDefault();
+ }
+ }}
+ download={(outfitState.name || "Outfit") + ".png"}
+ onMouseEnter={prepareDownload}
+ onFocus={prepareDownload}
+ cursor={!downloadImageUrl && "wait"}
+ />
+
+
+ );
}
/**
* CopyLinkButton copies the outfit URL to the clipboard!
*/
function CopyLinkButton({ outfitState }) {
- const { onCopy, hasCopied } = useClipboard(outfitState.url);
+ const { onCopy, hasCopied } = useClipboard(outfitState.url);
- return (
-
-
- : }
- aria-label="Copy link"
- onClick={onCopy}
- />
-
-
- );
+ return (
+
+
+ : }
+ aria-label="Copy link"
+ onClick={onCopy}
+ />
+
+
+ );
}
function PlayPauseButton() {
- const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
+ const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
- // We show an intro animation if this mounts while paused. Whereas if we're
- // not paused, we initialize as if we had already finished.
- const [blinkInState, setBlinkInState] = React.useState(
- isPaused ? { type: "ready" } : { type: "done" },
- );
- const buttonRef = React.useRef(null);
+ // We show an intro animation if this mounts while paused. Whereas if we're
+ // not paused, we initialize as if we had already finished.
+ const [blinkInState, setBlinkInState] = React.useState(
+ isPaused ? { type: "ready" } : { type: "done" },
+ );
+ const buttonRef = React.useRef(null);
- React.useLayoutEffect(() => {
- if (blinkInState.type === "ready" && buttonRef.current) {
- setBlinkInState({
- type: "started",
- position: {
- left: buttonRef.current.offsetLeft,
- top: buttonRef.current.offsetTop,
- },
- });
- }
- }, [blinkInState, setBlinkInState]);
+ React.useLayoutEffect(() => {
+ if (blinkInState.type === "ready" && buttonRef.current) {
+ setBlinkInState({
+ type: "started",
+ position: {
+ left: buttonRef.current.offsetLeft,
+ top: buttonRef.current.offsetTop,
+ },
+ });
+ }
+ }, [blinkInState, setBlinkInState]);
- return (
-
- {({ css }) => (
- <>
-
- {blinkInState.type === "started" && (
-
- setBlinkInState({ type: "done" })}
- // Don't disrupt the hover state of the controls! (And the button
- // doesn't seem to click correctly, not sure why, but instead of
- // debugging I'm adding this :p)
- pointerEvents="none"
- className={css`
- @keyframes fade-in-out {
- 0% {
- opacity: 0;
- }
+ return (
+
+ {({ css }) => (
+ <>
+
+ {blinkInState.type === "started" && (
+
+ setBlinkInState({ type: "done" })}
+ // Don't disrupt the hover state of the controls! (And the button
+ // doesn't seem to click correctly, not sure why, but instead of
+ // debugging I'm adding this :p)
+ pointerEvents="none"
+ className={css`
+ @keyframes fade-in-out {
+ 0% {
+ opacity: 0;
+ }
- 10% {
- opacity: 1;
- }
+ 10% {
+ opacity: 1;
+ }
- 90% {
- opacity: 1;
- }
+ 90% {
+ opacity: 1;
+ }
- 100% {
- opacity: 0;
- }
- }
+ 100% {
+ opacity: 0;
+ }
+ }
- opacity: 0;
- animation: fade-in-out 2s;
- `}
- />
-
- )}
- >
- )}
-
- );
+ opacity: 0;
+ animation: fade-in-out 2s;
+ `}
+ />
+
+ )}
+ >
+ )}
+
+ );
}
const PlayPauseButtonContent = React.forwardRef(
- ({ isPaused, setIsPaused, ...props }, ref) => {
- return (
- : }
- onClick={() => setIsPaused(!isPaused)}
- {...props}
- >
- {isPaused ? <>Paused> : <>Playing>}
-
- );
- },
+ ({ isPaused, setIsPaused, ...props }, ref) => {
+ return (
+ : }
+ onClick={() => setIsPaused(!isPaused)}
+ {...props}
+ >
+ {isPaused ? <>Paused> : <>Playing>}
+
+ );
+ },
);
function SettingsButton({ onLockFocus, onUnlockFocus }) {
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
function HiResModeSetting() {
- const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false);
- const [preferArchive, setPreferArchive] = usePreferArchive();
+ const [hiResMode, setHiResMode] = useLocalStorage("DTIHiResMode", false);
+ const [preferArchive, setPreferArchive] = usePreferArchive();
- return (
-
-
-
-
-
- Hi-res mode (SVG)
-
-
- Crisper at higher resolutions, but not always accurate
-
-
-
- setHiResMode(e.target.checked)}
- />
-
-
-
-
-
-
-
- Use DTI's image archive
-
-
- Turn this on when images.neopets.com is slow!
-
-
-
- setPreferArchive(e.target.checked)}
- />
-
-
-
- );
+ return (
+
+
+
+
+
+ Hi-res mode (SVG)
+
+
+ Crisper at higher resolutions, but not always accurate
+
+
+
+ setHiResMode(e.target.checked)}
+ />
+
+
+
+
+
+
+
+ Use DTI's image archive
+
+
+ Turn this on when images.neopets.com is slow!
+
+
+
+ setPreferArchive(e.target.checked)}
+ />
+
+
+
+ );
}
const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => {
- return (
-
- );
+ return (
+
+ );
});
/**
@@ -645,25 +645,25 @@ const TranslucentButton = React.forwardRef(({ children, ...props }, ref) => {
* OutfitControls!
*/
function ControlButton({ icon, "aria-label": ariaLabel, ...props }) {
- return (
-
- );
+ return (
+
+ );
}
/**
@@ -671,71 +671,71 @@ function ControlButton({ icon, "aria-label": ariaLabel, ...props }) {
* image URL.
*/
function useDownloadableImage(visibleLayers) {
- const [hiResMode] = useLocalStorage("DTIHiResMode", false);
- const [preferArchive] = usePreferArchive();
+ const [hiResMode] = useLocalStorage("DTIHiResMode", false);
+ const [preferArchive] = usePreferArchive();
- const [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
- const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
- const toast = useToast();
+ const [downloadImageUrl, setDownloadImageUrl] = React.useState(null);
+ const [preparedForLayerIds, setPreparedForLayerIds] = React.useState([]);
+ const toast = useToast();
- const prepareDownload = React.useCallback(async () => {
- // Skip if the current image URL is already correct for these layers.
- const layerIds = visibleLayers.map((l) => l.id);
- if (layerIds.join(",") === preparedForLayerIds.join(",")) {
- return;
- }
+ const prepareDownload = React.useCallback(async () => {
+ // Skip if the current image URL is already correct for these layers.
+ const layerIds = visibleLayers.map((l) => l.id);
+ if (layerIds.join(",") === preparedForLayerIds.join(",")) {
+ return;
+ }
- // Skip if there are no layers. (This probably means we're still loading!)
- if (layerIds.length === 0) {
- return;
- }
+ // Skip if there are no layers. (This probably means we're still loading!)
+ if (layerIds.length === 0) {
+ return;
+ }
- setDownloadImageUrl(null);
+ setDownloadImageUrl(null);
- // NOTE: You could argue that we may as well just always use PNGs here,
- // regardless of hi-res mode… but using the same src will help both
- // performance (can use cached SVG), and predictability (image will
- // look like what you see here).
- const imagePromises = visibleLayers.map((layer) =>
- loadImage(getBestImageUrlForLayer(layer, { hiResMode }), {
- crossOrigin: "anonymous",
- preferArchive,
- }),
- );
+ // NOTE: You could argue that we may as well just always use PNGs here,
+ // regardless of hi-res mode… but using the same src will help both
+ // performance (can use cached SVG), and predictability (image will
+ // look like what you see here).
+ const imagePromises = visibleLayers.map((layer) =>
+ loadImage(getBestImageUrlForLayer(layer, { hiResMode }), {
+ crossOrigin: "anonymous",
+ preferArchive,
+ }),
+ );
- let images;
- try {
- images = await Promise.all(imagePromises);
- } catch (e) {
- console.error("Error building downloadable image", e);
- toast({
- status: "error",
- title: "Oops, sorry, we couldn't download the image!",
- description:
- "Check your connection, then reload the page and try again.",
- });
- return;
- }
+ let images;
+ try {
+ images = await Promise.all(imagePromises);
+ } catch (e) {
+ console.error("Error building downloadable image", e);
+ toast({
+ status: "error",
+ title: "Oops, sorry, we couldn't download the image!",
+ description:
+ "Check your connection, then reload the page and try again.",
+ });
+ return;
+ }
- const canvas = document.createElement("canvas");
- const context = canvas.getContext("2d");
- canvas.width = 600;
- canvas.height = 600;
+ const canvas = document.createElement("canvas");
+ const context = canvas.getContext("2d");
+ canvas.width = 600;
+ canvas.height = 600;
- for (const image of images) {
- context.drawImage(image, 0, 0);
- }
+ for (const image of images) {
+ context.drawImage(image, 0, 0);
+ }
- console.debug(
- "Generated image for download",
- layerIds,
- canvas.toDataURL("image/png"),
- );
- setDownloadImageUrl(canvas.toDataURL("image/png"));
- setPreparedForLayerIds(layerIds);
- }, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]);
+ console.debug(
+ "Generated image for download",
+ layerIds,
+ canvas.toDataURL("image/png"),
+ );
+ setDownloadImageUrl(canvas.toDataURL("image/png"));
+ setPreparedForLayerIds(layerIds);
+ }, [preparedForLayerIds, visibleLayers, toast, hiResMode, preferArchive]);
- return [downloadImageUrl, prepareDownload];
+ return [downloadImageUrl, prepareDownload];
}
export default OutfitControls;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js b/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js
index ed7692f8..639aeae2 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/OutfitKnownGlitchesBadge.js
@@ -7,310 +7,310 @@ import getVisibleLayers from "../components/getVisibleLayers";
import { useLocalStorage } from "../util";
function OutfitKnownGlitchesBadge({ appearance }) {
- const [hiResMode] = useLocalStorage("DTIHiResMode", false);
- const { petAppearance, items } = appearance;
+ const [hiResMode] = useLocalStorage("DTIHiResMode", false);
+ const { petAppearance, items } = appearance;
- const glitchMessages = [];
+ const glitchMessages = [];
- // Look for UC/Invisible/etc incompatibilities that we hid, that we should
- // just mark Incompatible someday instead; or with correctly partially-hidden
- // art.
- //
- // NOTE: This particular glitch is checking for the *absence* of layers, so
- // we skip it if we're still loading!
- if (!appearance.loading) {
- for (const item of items) {
- // HACK: We use `getVisibleLayers` with just this pet appearance and item
- // appearance, to run the logic for which layers are compatible with
- // this pet. But `getVisibleLayers` does other things too, so it's
- // plausible that this could do not quite what we want in some cases!
- const allItemLayers = item.appearance.layers;
- const compatibleItemLayers = getVisibleLayers(petAppearance, [
- item.appearance,
- ]).filter((l) => l.source === "item");
+ // Look for UC/Invisible/etc incompatibilities that we hid, that we should
+ // just mark Incompatible someday instead; or with correctly partially-hidden
+ // art.
+ //
+ // NOTE: This particular glitch is checking for the *absence* of layers, so
+ // we skip it if we're still loading!
+ if (!appearance.loading) {
+ for (const item of items) {
+ // HACK: We use `getVisibleLayers` with just this pet appearance and item
+ // appearance, to run the logic for which layers are compatible with
+ // this pet. But `getVisibleLayers` does other things too, so it's
+ // plausible that this could do not quite what we want in some cases!
+ const allItemLayers = item.appearance.layers;
+ const compatibleItemLayers = getVisibleLayers(petAppearance, [
+ item.appearance,
+ ]).filter((l) => l.source === "item");
- if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
- glitchMessages.push(
-
- {item.name} isn't actually compatible with this special pet.
- We're hiding the item art, which is outdated behavior, and we should
- instead be treating it as entirely incompatible. Fixing this is in
- our todo list, sorry for the confusing UI!
- ,
- );
- } else if (compatibleItemLayers.length < allItemLayers.length) {
- glitchMessages.push(
-
- {item.name}'s compatibility with this pet is complicated, but
- we believe this is how it looks: some zones are visible, and some
- zones are hidden. If this isn't quite right, please email me at
- matchu@openneo.net and let me know!
- ,
- );
- }
- }
- }
+ if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
+ glitchMessages.push(
+
+ {item.name} isn't actually compatible with this special pet.
+ We're hiding the item art, which is outdated behavior, and we should
+ instead be treating it as entirely incompatible. Fixing this is in
+ our todo list, sorry for the confusing UI!
+ ,
+ );
+ } else if (compatibleItemLayers.length < allItemLayers.length) {
+ glitchMessages.push(
+
+ {item.name}'s compatibility with this pet is complicated, but
+ we believe this is how it looks: some zones are visible, and some
+ zones are hidden. If this isn't quite right, please email me at
+ matchu@openneo.net and let me know!
+ ,
+ );
+ }
+ }
+ }
- // Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
- for (const item of items) {
- const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
- (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
- );
- const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
- (l) =>
- (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
- !layerUsesHTML5(l),
- );
- if (itemHasBrokenOnNeopetsDotCom) {
- glitchMessages.push(
-
- {itemHasBrokenUnconvertedLayers ? (
- <>
- We're aware of a glitch affecting the art for {item.name}.
- Last time we checked, this glitch affected its appearance on
- Neopets.com, too. Hopefully this will be fixed once it's converted
- to HTML5!
- >
- ) : (
- <>
- We're aware of a previous glitch affecting the art for{" "}
- {item.name}, but it might have been resolved during HTML5
- conversion. Please use the feedback form on the homepage to let us
- know if it looks right, or still looks wrong! Thank you!
- >
- )}
- ,
- );
- }
- }
+ // Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
+ for (const item of items) {
+ const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
+ (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
+ );
+ const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
+ (l) =>
+ (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
+ !layerUsesHTML5(l),
+ );
+ if (itemHasBrokenOnNeopetsDotCom) {
+ glitchMessages.push(
+
+ {itemHasBrokenUnconvertedLayers ? (
+ <>
+ We're aware of a glitch affecting the art for {item.name}.
+ Last time we checked, this glitch affected its appearance on
+ Neopets.com, too. Hopefully this will be fixed once it's converted
+ to HTML5!
+ >
+ ) : (
+ <>
+ We're aware of a previous glitch affecting the art for{" "}
+ {item.name}, but it might have been resolved during HTML5
+ conversion. Please use the feedback form on the homepage to let us
+ know if it looks right, or still looks wrong! Thank you!
+ >
+ )}
+ ,
+ );
+ }
+ }
- // Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
- for (const item of items) {
- const itemHasGlitch = item.appearance.layers.some((l) =>
- (l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
- );
- if (itemHasGlitch) {
- glitchMessages.push(
-
- There's a glitch in the art for {item.name}, and we believe it
- looks this way on-site, too. But our version might be out of date! If
- you've seen it look better on-site, please email me at
- matchu@openneo.net so we can fix it!
- ,
- );
- }
- }
+ // Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
+ for (const item of items) {
+ const itemHasGlitch = item.appearance.layers.some((l) =>
+ (l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
+ );
+ if (itemHasGlitch) {
+ glitchMessages.push(
+
+ There's a glitch in the art for {item.name}, and we believe it
+ looks this way on-site, too. But our version might be out of date! If
+ you've seen it look better on-site, please email me at
+ matchu@openneo.net so we can fix it!
+ ,
+ );
+ }
+ }
- // Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
- // if hi-res mode is on, because otherwise it doesn't affect the user anyway!
- if (hiResMode) {
- for (const item of items) {
- const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
- (l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
- );
- if (itemHasOfficialSvgIsIncorrect) {
- glitchMessages.push(
-
- There's a glitch in the art for {item.name} that prevents us
- from showing the SVG image for Hi-Res Mode. Instead, we're showing a
- PNG, which might look a bit blurry on larger screens.
- ,
- );
- }
- }
- }
+ // Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
+ // if hi-res mode is on, because otherwise it doesn't affect the user anyway!
+ if (hiResMode) {
+ for (const item of items) {
+ const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
+ (l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
+ );
+ if (itemHasOfficialSvgIsIncorrect) {
+ glitchMessages.push(
+
+ There's a glitch in the art for {item.name} that prevents us
+ from showing the SVG image for Hi-Res Mode. Instead, we're showing a
+ PNG, which might look a bit blurry on larger screens.
+ ,
+ );
+ }
+ }
+ }
- // Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
- for (const item of items) {
- const itemHasGlitch = item.appearance.layers.some((l) =>
- (l.knownGlitches || []).includes(
- "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
- ),
- );
- if (itemHasGlitch) {
- glitchMessages.push(
-
- There's a glitch in the art for {item.name} that causes it to
- display incorrectly—but we're not sure if it's on our end, or TNT's.
- If you own this item, please email me at matchu@openneo.net to let us
- know how it looks in the on-site customizer!
- ,
- );
- }
- }
+ // Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
+ for (const item of items) {
+ const itemHasGlitch = item.appearance.layers.some((l) =>
+ (l.knownGlitches || []).includes(
+ "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
+ ),
+ );
+ if (itemHasGlitch) {
+ glitchMessages.push(
+
+ There's a glitch in the art for {item.name} that causes it to
+ display incorrectly—but we're not sure if it's on our end, or TNT's.
+ If you own this item, please email me at matchu@openneo.net to let us
+ know how it looks in the on-site customizer!
+ ,
+ );
+ }
+ }
- // Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
- for (const item of items) {
- const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
- (l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
- );
- if (itemHasOfficialBodyIdIsIncorrect) {
- glitchMessages.push(
-
- Last we checked, {item.name} actually is compatible with this
- pet, even though it seems like it shouldn't be. But TNT might change
- this at any time, so be careful!
- ,
- );
- }
- }
+ // Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
+ for (const item of items) {
+ const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
+ (l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
+ );
+ if (itemHasOfficialBodyIdIsIncorrect) {
+ glitchMessages.push(
+
+ Last we checked, {item.name} actually is compatible with this
+ pet, even though it seems like it shouldn't be. But TNT might change
+ this at any time, so be careful!
+ ,
+ );
+ }
+ }
- // Look for Dyeworks items that aren't converted yet.
- for (const item of items) {
- const itemIsDyeworks = item.name.includes("Dyeworks");
- const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
+ // Look for Dyeworks items that aren't converted yet.
+ for (const item of items) {
+ const itemIsDyeworks = item.name.includes("Dyeworks");
+ const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
- if (itemIsDyeworks && !itemIsConverted) {
- glitchMessages.push(
-
- {item.name} isn't converted to HTML5 yet, and our Classic DTI
- code often shows old Dyeworks items in the wrong color. Once it's
- converted, we'll display it correctly!
- ,
- );
- }
- }
+ if (itemIsDyeworks && !itemIsConverted) {
+ glitchMessages.push(
+
+ {item.name} isn't converted to HTML5 yet, and our Classic DTI
+ code often shows old Dyeworks items in the wrong color. Once it's
+ converted, we'll display it correctly!
+ ,
+ );
+ }
+ }
- // Look for Baby Body Paint items.
- for (const item of items) {
- const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
- if (itemIsBabyBodyPaint) {
- glitchMessages.push(
-
- {item.name} seems to have new zone restriction rules that our
- system doesn't support yet, whuh oh! This might require major changes
- to how we handle zones. Until then, this item will be very buggy,
- sorry!
- ,
- );
- }
- }
+ // Look for Baby Body Paint items.
+ for (const item of items) {
+ const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
+ if (itemIsBabyBodyPaint) {
+ glitchMessages.push(
+
+ {item.name} seems to have new zone restriction rules that our
+ system doesn't support yet, whuh oh! This might require major changes
+ to how we handle zones. Until then, this item will be very buggy,
+ sorry!
+ ,
+ );
+ }
+ }
- // Check whether the pet is Invisible. If so, we'll show a blanket warning.
- if (petAppearance?.color?.id === "38") {
- glitchMessages.push(
-
- Invisible pets are affected by a number of glitches, including faces
- sometimes being visible on-site, and errors in the HTML5 conversion. If
- this pose looks incorrect, you can try another by clicking the emoji
- face next to the species/color picker. But be aware that Neopets.com
- might look different!
- ,
- );
- }
+ // Check whether the pet is Invisible. If so, we'll show a blanket warning.
+ if (petAppearance?.color?.id === "38") {
+ glitchMessages.push(
+
+ Invisible pets are affected by a number of glitches, including faces
+ sometimes being visible on-site, and errors in the HTML5 conversion. If
+ this pose looks incorrect, you can try another by clicking the emoji
+ face next to the species/color picker. But be aware that Neopets.com
+ might look different!
+ ,
+ );
+ }
- // Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
- if (
- petAppearance?.color?.id === "26" &&
- petAppearance?.species?.id === "49"
- ) {
- glitchMessages.push(
-
- The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
- sometimes yellow. To help you design for both cases, we show the blue
- horn with the feminine design, and the yellow horn with the masculine
- design—but the pet's gender does not actually affect which horn you'll
- get, and it will often change over time!
- ,
- );
- }
+ // Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
+ if (
+ petAppearance?.color?.id === "26" &&
+ petAppearance?.species?.id === "49"
+ ) {
+ glitchMessages.push(
+
+ The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
+ sometimes yellow. To help you design for both cases, we show the blue
+ horn with the feminine design, and the yellow horn with the masculine
+ design—but the pet's gender does not actually affect which horn you'll
+ get, and it will often change over time!
+ ,
+ );
+ }
- // Check whether the pet appearance is marked as Glitched.
- if (petAppearance?.isGlitched) {
- glitchMessages.push(
- // NOTE: This message assumes that the current pet appearance is the
- // best canonical one, but it's _possible_ to view Glitched
- // appearances even if we _do_ have a better one saved... but
- // only the Support UI ever takes you there.
-
- We know that the art for this pet is incorrect, but we still haven't
- seen a correct model for this pose yet. Once someone models the
- correct data, we'll use that instead. For now, you could also try
- switching to another pose, by clicking the emoji face next to the
- species/color picker!
- ,
- );
- }
+ // Check whether the pet appearance is marked as Glitched.
+ if (petAppearance?.isGlitched) {
+ glitchMessages.push(
+ // NOTE: This message assumes that the current pet appearance is the
+ // best canonical one, but it's _possible_ to view Glitched
+ // appearances even if we _do_ have a better one saved... but
+ // only the Support UI ever takes you there.
+
+ We know that the art for this pet is incorrect, but we still haven't
+ seen a correct model for this pose yet. Once someone models the
+ correct data, we'll use that instead. For now, you could also try
+ switching to another pose, by clicking the emoji face next to the
+ species/color picker!
+ ,
+ );
+ }
- const petLayers = petAppearance?.layers || [];
+ const petLayers = petAppearance?.layers || [];
- // Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
- for (const layer of petLayers) {
- const layerHasGlitch = (layer.knownGlitches || []).includes(
- "OFFICIAL_SWF_IS_INCORRECT",
- );
- if (layerHasGlitch) {
- glitchMessages.push(
-
- We're aware of a glitch affecting the art for this pet's{" "}
- {layer.zone.label} zone. Last time we checked, this glitch
- affected its appearance on Neopets.com, too. But our version might be
- out of date! If you've seen it look better on-site, please email me at
- matchu@openneo.net so we can fix it!
- ,
- );
- }
- }
+ // Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
+ for (const layer of petLayers) {
+ const layerHasGlitch = (layer.knownGlitches || []).includes(
+ "OFFICIAL_SWF_IS_INCORRECT",
+ );
+ if (layerHasGlitch) {
+ glitchMessages.push(
+
+ We're aware of a glitch affecting the art for this pet's{" "}
+ {layer.zone.label} zone. Last time we checked, this glitch
+ affected its appearance on Neopets.com, too. But our version might be
+ out of date! If you've seen it look better on-site, please email me at
+ matchu@openneo.net so we can fix it!
+ ,
+ );
+ }
+ }
- // Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
- if (hiResMode) {
- for (const layer of petLayers) {
- const layerHasOfficialSvgIsIncorrect = (
- layer.knownGlitches || []
- ).includes("OFFICIAL_SVG_IS_INCORRECT");
- if (layerHasOfficialSvgIsIncorrect) {
- glitchMessages.push(
-
- There's a glitch in the art for this pet's {layer.zone.label}{" "}
- zone that prevents us from showing the SVG image for Hi-Res Mode.
- Instead, we're showing a PNG, which might look a bit blurry on
- larger screens.
- ,
- );
- }
- }
- }
+ // Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
+ if (hiResMode) {
+ for (const layer of petLayers) {
+ const layerHasOfficialSvgIsIncorrect = (
+ layer.knownGlitches || []
+ ).includes("OFFICIAL_SVG_IS_INCORRECT");
+ if (layerHasOfficialSvgIsIncorrect) {
+ glitchMessages.push(
+
+ There's a glitch in the art for this pet's {layer.zone.label}{" "}
+ zone that prevents us from showing the SVG image for Hi-Res Mode.
+ Instead, we're showing a PNG, which might look a bit blurry on
+ larger screens.
+ ,
+ );
+ }
+ }
+ }
- // Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
- for (const layer of petLayers) {
- const layerHasGlitch = (layer.knownGlitches || []).includes(
- "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
- );
- if (layerHasGlitch) {
- glitchMessages.push(
-
- There's a glitch in the art for this pet's {layer.zone.label}{" "}
- zone that causes it to display incorrectly—but we're not sure if it's
- on our end, or TNT's. If you have this pet, please email me at
- matchu@openneo.net to let us know how it looks in the on-site
- customizer!
- ,
- );
- }
- }
+ // Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
+ for (const layer of petLayers) {
+ const layerHasGlitch = (layer.knownGlitches || []).includes(
+ "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
+ );
+ if (layerHasGlitch) {
+ glitchMessages.push(
+
+ There's a glitch in the art for this pet's {layer.zone.label}{" "}
+ zone that causes it to display incorrectly—but we're not sure if it's
+ on our end, or TNT's. If you have this pet, please email me at
+ matchu@openneo.net to let us know how it looks in the on-site
+ customizer!
+ ,
+ );
+ }
+ }
- if (glitchMessages.length === 0) {
- return null;
- }
+ if (glitchMessages.length === 0) {
+ return null;
+ }
- return (
-
-
- Known glitches
-
- {glitchMessages}
-
- }
- >
-
-
-
- );
+ return (
+
+
+ Known glitches
+
+ {glitchMessages}
+
+ }
+ >
+
+
+
+ );
}
export default OutfitKnownGlitchesBadge;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js b/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
index ce020796..865fcaf2 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
@@ -3,25 +3,25 @@ import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { ClassNames } from "@emotion/react";
import {
- Box,
- Button,
- Flex,
- Popover,
- PopoverArrow,
- PopoverContent,
- PopoverTrigger,
- Portal,
- Tab,
- Tabs,
- TabList,
- TabPanel,
- TabPanels,
- VisuallyHidden,
- useBreakpointValue,
- useColorModeValue,
- useTheme,
- useToast,
- useToken,
+ Box,
+ Button,
+ Flex,
+ Popover,
+ PopoverArrow,
+ PopoverContent,
+ PopoverTrigger,
+ Portal,
+ Tab,
+ Tabs,
+ TabList,
+ TabPanel,
+ TabPanels,
+ VisuallyHidden,
+ useBreakpointValue,
+ useColorModeValue,
+ useTheme,
+ useToast,
+ useToken,
} from "@chakra-ui/react";
import { ChevronDownIcon, WarningTwoIcon } from "@chakra-ui/icons";
import { loadable } from "../util";
@@ -46,7 +46,7 @@ import twemojiHourglass from "../images/twemoji/hourglass.svg";
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
const PosePickerSupportSwitch = loadable(() =>
- import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch),
+ import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch),
);
/**
@@ -63,246 +63,246 @@ const PosePickerSupportSwitch = loadable(() =>
* that matter, and skip updates that don't.
*/
function PosePicker({
- speciesId,
- colorId,
- pose,
- altStyleId,
- appearanceId,
- dispatchToOutfit,
- onLockFocus,
- onUnlockFocus,
- ...props
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ appearanceId,
+ dispatchToOutfit,
+ onLockFocus,
+ onUnlockFocus,
+ ...props
}) {
- const initialFocusRef = React.useRef();
- const posesQuery = usePoses(speciesId, colorId, pose);
- const altStylesQuery = useAltStylesForSpecies(speciesId);
- const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
- "DTIPosePickerIsInSupportMode",
- false,
- );
- const toast = useToast();
+ const initialFocusRef = React.useRef();
+ const posesQuery = usePoses(speciesId, colorId, pose);
+ const altStylesQuery = useAltStylesForSpecies(speciesId);
+ const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
+ "DTIPosePickerIsInSupportMode",
+ false,
+ );
+ const toast = useToast();
- const loading = posesQuery.loading || altStylesQuery.isLoading;
- const error = posesQuery.error ?? altStylesQuery.error;
- const poseInfos = posesQuery.poseInfos;
- const altStyles = altStylesQuery.data ?? [];
+ const loading = posesQuery.loading || altStylesQuery.isLoading;
+ const error = posesQuery.error ?? altStylesQuery.error;
+ const poseInfos = posesQuery.poseInfos;
+ const altStyles = altStylesQuery.data ?? [];
- const [isOpen, setIsOpen] = React.useState(false);
- const [tabIndex, setTabIndex] = React.useState(0);
+ const [isOpen, setIsOpen] = React.useState(false);
+ const [tabIndex, setTabIndex] = React.useState(0);
- const altStyle = altStyles.find((s) => s.id === altStyleId);
+ const altStyle = altStyles.find((s) => s.id === altStyleId);
- const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
+ const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
- React.useEffect(() => {
- // While the popover is open, don't change which tab is open.
- if (isOpen) {
- return;
- }
+ React.useEffect(() => {
+ // While the popover is open, don't change which tab is open.
+ if (isOpen) {
+ return;
+ }
- // Otherwise, set the tab to Styles if we're wearing an Alt Style, or
- // Expressions if we're not.
- setTabIndex(altStyle != null ? 1 : 0);
- }, [altStyle, isOpen]);
+ // Otherwise, set the tab to Styles if we're wearing an Alt Style, or
+ // Expressions if we're not.
+ setTabIndex(altStyle != null ? 1 : 0);
+ }, [altStyle, isOpen]);
- // Resize the Popover when we toggle support mode, because it probably will
- // affect the content size.
- React.useLayoutEffect(() => {
- // HACK: To trigger a Popover resize, we simulate a window resize event,
- // because Popover listens for window resizes to reposition itself.
- // I've also filed an issue requesting an official API!
- // https://github.com/chakra-ui/chakra-ui/issues/1853
- window.dispatchEvent(new Event("resize"));
- }, [isInSupportMode]);
+ // Resize the Popover when we toggle support mode, because it probably will
+ // affect the content size.
+ React.useLayoutEffect(() => {
+ // HACK: To trigger a Popover resize, we simulate a window resize event,
+ // because Popover listens for window resizes to reposition itself.
+ // I've also filed an issue requesting an official API!
+ // https://github.com/chakra-ui/chakra-ui/issues/1853
+ window.dispatchEvent(new Event("resize"));
+ }, [isInSupportMode]);
- // Generally, the app tries to never put us in an invalid pose state. But it
- // can happen with direct URL navigation, or pet loading when modeling isn't
- // updated! Let's do some recovery.
- const selectedPoseIsAvailable = Object.values(poseInfos).some(
- (pi) => pi.isSelected && pi.isAvailable,
- );
- const firstAvailablePose = Object.values(poseInfos).find(
- (pi) => pi.isAvailable,
- )?.pose;
- React.useEffect(() => {
- if (loading) {
- return;
- }
+ // Generally, the app tries to never put us in an invalid pose state. But it
+ // can happen with direct URL navigation, or pet loading when modeling isn't
+ // updated! Let's do some recovery.
+ const selectedPoseIsAvailable = Object.values(poseInfos).some(
+ (pi) => pi.isSelected && pi.isAvailable,
+ );
+ const firstAvailablePose = Object.values(poseInfos).find(
+ (pi) => pi.isAvailable,
+ )?.pose;
+ React.useEffect(() => {
+ if (loading) {
+ return;
+ }
- if (!selectedPoseIsAvailable) {
- if (!firstAvailablePose) {
- // TODO: I suppose this error would fit better in SpeciesColorPicker!
- toast({
- status: "error",
- title: "Oops, we don't have data for this pet color!",
- description:
- "If it's new, this might be a modeling issue—try modeling it on " +
- "Classic DTI first. Sorry!",
- duration: null,
- isClosable: true,
- });
- return;
- }
+ if (!selectedPoseIsAvailable) {
+ if (!firstAvailablePose) {
+ // TODO: I suppose this error would fit better in SpeciesColorPicker!
+ toast({
+ status: "error",
+ title: "Oops, we don't have data for this pet color!",
+ description:
+ "If it's new, this might be a modeling issue—try modeling it on " +
+ "Classic DTI first. Sorry!",
+ duration: null,
+ isClosable: true,
+ });
+ return;
+ }
- console.warn(
- `Pose ${pose} not found for speciesId=${speciesId}, ` +
- `colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`,
- );
- dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
- }
- }, [
- loading,
- selectedPoseIsAvailable,
- firstAvailablePose,
- speciesId,
- colorId,
- pose,
- toast,
- dispatchToOutfit,
- ]);
+ console.warn(
+ `Pose ${pose} not found for speciesId=${speciesId}, ` +
+ `colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`,
+ );
+ dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
+ }
+ }, [
+ loading,
+ selectedPoseIsAvailable,
+ firstAvailablePose,
+ speciesId,
+ colorId,
+ pose,
+ toast,
+ dispatchToOutfit,
+ ]);
- // This is a low-stakes enough control, where enough pairs don't have data
- // anyway, that I think I want to just not draw attention to failures.
- if (error) {
- return null;
- }
+ // This is a low-stakes enough control, where enough pairs don't have data
+ // anyway, that I think I want to just not draw attention to failures.
+ if (error) {
+ return null;
+ }
- const numStandardPoses = Object.values(poseInfos).filter(
- (p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
- ).length;
+ const numStandardPoses = Object.values(poseInfos).filter(
+ (p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
+ ).length;
- const onChangePose = (e) => {
- dispatchToOutfit({ type: "setPose", pose: e.target.value });
- };
+ const onChangePose = (e) => {
+ dispatchToOutfit({ type: "setPose", pose: e.target.value });
+ };
- const onChangeStyle = (altStyleId) => {
- dispatchToOutfit({ type: "setStyle", altStyleId });
- };
+ const onChangeStyle = (altStyleId) => {
+ dispatchToOutfit({ type: "setStyle", altStyleId });
+ };
- return (
- {
- setIsOpen(true);
- onLockFocus();
- }}
- onClose={() => {
- setIsOpen(false);
- onUnlockFocus();
- }}
- initialFocusRef={initialFocusRef}
- isLazy
- lazyBehavior="keepMounted"
- >
- {() => (
- <>
-
-
-
-
-
-
-
- Expressions
- Styles
-
-
-
- {isInSupportMode ? (
-
- ) : (
- <>
-
- {numStandardPoses == 0 && (
-
- )}
- >
- )}
-
-
- setIsInSupportMode(e.target.checked)}
- />
-
-
-
-
-
-
-
-
-
-
-
-
- >
- )}
-
- );
+ return (
+ {
+ setIsOpen(true);
+ onLockFocus();
+ }}
+ onClose={() => {
+ setIsOpen(false);
+ onUnlockFocus();
+ }}
+ initialFocusRef={initialFocusRef}
+ isLazy
+ lazyBehavior="keepMounted"
+ >
+ {() => (
+ <>
+
+
+
+
+
+
+
+ Expressions
+ Styles
+
+
+
+ {isInSupportMode ? (
+
+ ) : (
+ <>
+
+ {numStandardPoses == 0 && (
+
+ )}
+ >
+ )}
+
+
+ setIsInSupportMode(e.target.checked)}
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+ );
}
const PosePickerButton = React.forwardRef(
- ({ pose, altStyle, isOpen, loading, ...props }, ref) => {
- const theme = useTheme();
+ ({ pose, altStyle, isOpen, loading, ...props }, ref) => {
+ const theme = useTheme();
- const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
- const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
+ const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
+ const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
- return (
-
- {({ css, cx }) => (
-
- );
- },
+ isOpen && "is-open",
+ )}
+ {...props}
+ ref={ref}
+ >
+
+
+ {label}
+
+
+
+ )}
+
+ );
+ },
);
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
- return (
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
-
- |
-
-
- |
-
-
- |
-
-
- |
-
-
-
- {poseInfos.unconverted.isAvailable && (
-
-
-
-
- )}
-
- );
+ return (
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+ |
+
+
+
+ {poseInfos.unconverted.isAvailable && (
+
+
+
+
+ )}
+
+ );
}
function Cell({ children, as }) {
- const Tag = as;
- return (
-
-
- {children}
-
-
- );
+ const Tag = as;
+ return (
+
+
+ {children}
+
+
+ );
}
const EMOTION_STRINGS = {
- HAPPY_MASC: "Happy",
- HAPPY_FEM: "Happy",
- SAD_MASC: "Sad",
- SAD_FEM: "Sad",
- SICK_MASC: "Sick",
- SICK_FEM: "Sick",
+ HAPPY_MASC: "Happy",
+ HAPPY_FEM: "Happy",
+ SAD_MASC: "Sad",
+ SAD_FEM: "Sad",
+ SICK_MASC: "Sick",
+ SICK_FEM: "Sick",
};
const GENDER_PRESENTATION_STRINGS = {
- HAPPY_MASC: "Masculine",
- SAD_MASC: "Masculine",
- SICK_MASC: "Masculine",
- HAPPY_FEM: "Feminine",
- SAD_FEM: "Feminine",
- SICK_FEM: "Feminine",
+ HAPPY_MASC: "Masculine",
+ SAD_MASC: "Masculine",
+ SICK_MASC: "Masculine",
+ HAPPY_FEM: "Feminine",
+ SAD_FEM: "Feminine",
+ SICK_FEM: "Feminine",
};
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
function PoseOption({
- poseInfo,
- onChange,
- inputRef,
- size = "md",
- label,
- ...otherProps
+ poseInfo,
+ onChange,
+ inputRef,
+ size = "md",
+ label,
+ ...otherProps
}) {
- const theme = useTheme();
- const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
- const emotionStr = EMOTION_STRINGS[poseInfo.pose];
+ const theme = useTheme();
+ const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
+ const emotionStr = EMOTION_STRINGS[poseInfo.pose];
- let poseName =
- poseInfo.pose === "UNCONVERTED"
- ? "Unconverted"
- : `${emotionStr} and ${genderPresentationStr}`;
- if (!poseInfo.isAvailable) {
- poseName += ` (not modeled yet)`;
- }
+ let poseName =
+ poseInfo.pose === "UNCONVERTED"
+ ? "Unconverted"
+ : `${emotionStr} and ${genderPresentationStr}`;
+ if (!poseInfo.isAvailable) {
+ poseName += ` (not modeled yet)`;
+ }
- const borderColor = useColorModeValue(
- theme.colors.green["600"],
- theme.colors.green["300"],
- );
+ const borderColor = useColorModeValue(
+ theme.colors.green["600"],
+ theme.colors.green["300"],
+ );
- return (
-
- {({ css, cx }) => (
- {
- // HACK: We need the timeout to beat the popover's focus stealing!
- const input = e.currentTarget.querySelector("input");
- setTimeout(() => input.focus(), 0);
- }}
- {...otherProps}
- >
-
-
+ {({ css, cx }) => (
+ {
+ // HACK: We need the timeout to beat the popover's focus stealing!
+ const input = e.currentTarget.querySelector("input");
+ setTimeout(() => input.focus(), 0);
+ }}
+ {...otherProps}
+ >
+
+
-
+
- {poseInfo.isAvailable ? (
-
-
-
- ) : (
-
-
-
- )}
-
- {label && (
-
- {label}
-
- )}
-
- )}
-
- );
+ input:focus + * & {
+ border-width: 3px;
+ }
+ `,
+ !poseInfo.isAvailable && "not-available",
+ )}
+ />
+ {poseInfo.isAvailable ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+ {label && (
+
+ {label}
+
+ )}
+
+ )}
+
+ );
}
function PosePickerEmptyExplanation() {
- return (
-
- We're still working on labeling these! For now, we're just giving you one
- of the expressions we have.
-
- );
+ return (
+
+ We're still working on labeling these! For now, we're just giving you one
+ of the expressions we have.
+
+ );
}
function RetiredUCWarning({ isSelected }) {
- return (
-
-
-
-
-
-
-
- "Unconverted" pets are no longer available on Neopets.com, and have been
- replaced with the very similar Styles feature. We're just keeping this
- as an archive!
-
-
- );
+ return (
+
+
+
+
+
+
+
+ "Unconverted" pets are no longer available on Neopets.com, and have been
+ replaced with the very similar Styles feature. We're just keeping this
+ as an archive!
+
+
+ );
}
function StyleSelect({
- selectedStyleId,
- altStyles,
- onChange,
- initialFocusRef,
+ selectedStyleId,
+ altStyles,
+ onChange,
+ initialFocusRef,
}) {
- const defaultStyle = { id: null, adjectiveName: "Default" };
+ const defaultStyle = { id: null, adjectiveName: "Default" };
- const styles = [defaultStyle, ...altStyles];
+ const styles = [defaultStyle, ...altStyles];
- return (
-
- {styles.map((altStyle) => (
-
- ))}
-
- );
+ return (
+
+ {styles.map((altStyle) => (
+
+ ))}
+
+ );
}
function StyleOption({ altStyle, checked, onChange, inputRef }) {
- const theme = useTheme();
- const selectedBorderColor = useColorModeValue(
- theme.colors.green["600"],
- theme.colors.green["300"],
- );
- const outlineShadow = useToken("shadows", "outline");
+ const theme = useTheme();
+ const selectedBorderColor = useColorModeValue(
+ theme.colors.green["600"],
+ theme.colors.green["300"],
+ );
+ const outlineShadow = useToken("shadows", "outline");
- return (
-
- {({ css, cx }) => (
- {
- // HACK: We need the timeout to beat the popover's focus stealing!
- const input = e.currentTarget.querySelector("input");
- setTimeout(() => input.focus(), 0);
- }}
- >
- onChange(altStyle.id)}
- ref={inputRef}
- />
-
+ {({ css, cx }) => (
+ {
+ // HACK: We need the timeout to beat the popover's focus stealing!
+ const input = e.currentTarget.querySelector("input");
+ setTimeout(() => input.focus(), 0);
+ }}
+ >
+ onChange(altStyle.id)}
+ ref={inputRef}
+ />
+
- {altStyle.thumbnailUrl ? (
-
- ) : (
-
- )}
-
- {altStyle.adjectiveName}
-
-
- )}
-
- );
+ input:checked + & {
+ border-color: ${selectedBorderColor};
+ opacity: 1;
+ font-weight: bold;
+ }
+ `)}
+ >
+ {altStyle.thumbnailUrl ? (
+
+ ) : (
+
+ )}
+
+ {altStyle.adjectiveName}
+
+
+ )}
+
+ );
}
function StyleExplanation() {
- return (
-
-
- Alt Styles
- {" "}
- are NC items that override the pet's appearance via the{" "}
-
- Styling Chamber
-
- . Not all items fit Alt Style pets. The pet's color doesn't have to match.
-
- );
+ return (
+
+
+ Alt Styles
+ {" "}
+ are NC items that override the pet's appearance via the{" "}
+
+ Styling Chamber
+
+ . Not all items fit Alt Style pets. The pet's color doesn't have to match.
+
+ );
}
function EmojiImage({ src, alt, boxSize = 16 }) {
- return ;
+ return ;
}
function usePoses(speciesId, colorId, selectedPose) {
- const { loading, error, data } = useQuery(
- gql`
- query PosePicker($speciesId: ID!, $colorId: ID!) {
- happyMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: HAPPY_MASC
- ) {
- ...PetAppearanceForPosePicker
- }
- sadMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SAD_MASC
- ) {
- ...PetAppearanceForPosePicker
- }
- sickMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SICK_MASC
- ) {
- ...PetAppearanceForPosePicker
- }
- happyFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: HAPPY_FEM
- ) {
- ...PetAppearanceForPosePicker
- }
- sadFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SAD_FEM
- ) {
- ...PetAppearanceForPosePicker
- }
- sickFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SICK_FEM
- ) {
- ...PetAppearanceForPosePicker
- }
- unconverted: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: UNCONVERTED
- ) {
- ...PetAppearanceForPosePicker
- }
- unknown: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: UNKNOWN
- ) {
- ...PetAppearanceForPosePicker
- }
- }
+ const { loading, error, data } = useQuery(
+ gql`
+ query PosePicker($speciesId: ID!, $colorId: ID!) {
+ happyMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: HAPPY_MASC
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ sadMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SAD_MASC
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ sickMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SICK_MASC
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ happyFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: HAPPY_FEM
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ sadFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SAD_FEM
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ sickFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SICK_FEM
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ unconverted: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: UNCONVERTED
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ unknown: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: UNKNOWN
+ ) {
+ ...PetAppearanceForPosePicker
+ }
+ }
- ${petAppearanceForPosePickerFragment}
- `,
- { variables: { speciesId, colorId }, onError: (e) => console.error(e) },
- );
+ ${petAppearanceForPosePickerFragment}
+ `,
+ { variables: { speciesId, colorId }, onError: (e) => console.error(e) },
+ );
- const poseInfos = {
- happyMasc: {
- ...data?.happyMasc,
- pose: "HAPPY_MASC",
- isAvailable: Boolean(data?.happyMasc),
- isSelected: selectedPose === "HAPPY_MASC",
- },
- sadMasc: {
- ...data?.sadMasc,
- pose: "SAD_MASC",
- isAvailable: Boolean(data?.sadMasc),
- isSelected: selectedPose === "SAD_MASC",
- },
- sickMasc: {
- ...data?.sickMasc,
- pose: "SICK_MASC",
- isAvailable: Boolean(data?.sickMasc),
- isSelected: selectedPose === "SICK_MASC",
- },
- happyFem: {
- ...data?.happyFem,
- pose: "HAPPY_FEM",
- isAvailable: Boolean(data?.happyFem),
- isSelected: selectedPose === "HAPPY_FEM",
- },
- sadFem: {
- ...data?.sadFem,
- pose: "SAD_FEM",
- isAvailable: Boolean(data?.sadFem),
- isSelected: selectedPose === "SAD_FEM",
- },
- sickFem: {
- ...data?.sickFem,
- pose: "SICK_FEM",
- isAvailable: Boolean(data?.sickFem),
- isSelected: selectedPose === "SICK_FEM",
- },
- unconverted: {
- ...data?.unconverted,
- pose: "UNCONVERTED",
- isAvailable: Boolean(data?.unconverted),
- isSelected: selectedPose === "UNCONVERTED",
- },
- unknown: {
- ...data?.unknown,
- pose: "UNKNOWN",
- isAvailable: Boolean(data?.unknown),
- isSelected: selectedPose === "UNKNOWN",
- },
- };
+ const poseInfos = {
+ happyMasc: {
+ ...data?.happyMasc,
+ pose: "HAPPY_MASC",
+ isAvailable: Boolean(data?.happyMasc),
+ isSelected: selectedPose === "HAPPY_MASC",
+ },
+ sadMasc: {
+ ...data?.sadMasc,
+ pose: "SAD_MASC",
+ isAvailable: Boolean(data?.sadMasc),
+ isSelected: selectedPose === "SAD_MASC",
+ },
+ sickMasc: {
+ ...data?.sickMasc,
+ pose: "SICK_MASC",
+ isAvailable: Boolean(data?.sickMasc),
+ isSelected: selectedPose === "SICK_MASC",
+ },
+ happyFem: {
+ ...data?.happyFem,
+ pose: "HAPPY_FEM",
+ isAvailable: Boolean(data?.happyFem),
+ isSelected: selectedPose === "HAPPY_FEM",
+ },
+ sadFem: {
+ ...data?.sadFem,
+ pose: "SAD_FEM",
+ isAvailable: Boolean(data?.sadFem),
+ isSelected: selectedPose === "SAD_FEM",
+ },
+ sickFem: {
+ ...data?.sickFem,
+ pose: "SICK_FEM",
+ isAvailable: Boolean(data?.sickFem),
+ isSelected: selectedPose === "SICK_FEM",
+ },
+ unconverted: {
+ ...data?.unconverted,
+ pose: "UNCONVERTED",
+ isAvailable: Boolean(data?.unconverted),
+ isSelected: selectedPose === "UNCONVERTED",
+ },
+ unknown: {
+ ...data?.unknown,
+ pose: "UNKNOWN",
+ isAvailable: Boolean(data?.unknown),
+ isSelected: selectedPose === "UNKNOWN",
+ },
+ };
- return { loading, error, poseInfos };
+ return { loading, error, poseInfos };
}
function getIcon(pose) {
- if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
- return twemojiSmile;
- } else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
- return twemojiCry;
- } else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
- return twemojiSick;
- } else if (pose === "UNCONVERTED") {
- return twemojiHourglass;
- } else {
- return twemojiSmile;
- }
+ if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
+ return twemojiSmile;
+ } else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
+ return twemojiCry;
+ } else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
+ return twemojiSick;
+ } else if (pose === "UNCONVERTED") {
+ return twemojiHourglass;
+ } else {
+ return twemojiSmile;
+ }
}
function getLabel(pose) {
- if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
- return "Happy";
- } else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
- return "Sad";
- } else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
- return "Sick";
- } else if (pose === "UNCONVERTED") {
- return "Retired UC";
- } else {
- return "Default";
- }
+ if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
+ return "Happy";
+ } else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
+ return "Sad";
+ } else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
+ return "Sick";
+ } else if (pose === "UNCONVERTED") {
+ return "Retired UC";
+ } else {
+ return "Default";
+ }
}
function getTransform(poseInfo) {
- const { pose, bodyId } = poseInfo;
- if (pose === "UNCONVERTED") {
- return transformsByBodyId.default;
- }
- if (bodyId in transformsByBodyId) {
- return transformsByBodyId[bodyId];
- }
- return transformsByBodyId.default;
+ const { pose, bodyId } = poseInfo;
+ if (pose === "UNCONVERTED") {
+ return transformsByBodyId.default;
+ }
+ if (bodyId in transformsByBodyId) {
+ return transformsByBodyId[bodyId];
+ }
+ return transformsByBodyId.default;
}
export const petAppearanceForPosePickerFragment = gql`
- fragment PetAppearanceForPosePicker on PetAppearance {
- id
- bodyId
- pose
- ...PetAppearanceForOutfitPreview
- }
- ${petAppearanceFragment}
+ fragment PetAppearanceForPosePicker on PetAppearance {
+ id
+ bodyId
+ pose
+ ...PetAppearanceForOutfitPreview
+ }
+ ${petAppearanceFragment}
`;
const transformsByBodyId = {
- 93: "translate(-5px, 10px) scale(2.8)",
- 106: "translate(-8px, 8px) scale(2.9)",
- 47: "translate(-1px, 17px) scale(3)",
- 84: "translate(-21px, 22px) scale(3.2)",
- 146: "translate(2px, 15px) scale(3.3)",
- 250: "translate(-14px, 28px) scale(3.4)",
- 212: "translate(-4px, 8px) scale(2.9)",
- 74: "translate(-26px, 30px) scale(3.0)",
- 94: "translate(-4px, 8px) scale(3.1)",
- 132: "translate(-14px, 18px) scale(3.0)",
- 56: "translate(-7px, 24px) scale(2.9)",
- 90: "translate(-16px, 20px) scale(3.5)",
- 136: "translate(-11px, 18px) scale(3.0)",
- 138: "translate(-14px, 26px) scale(3.5)",
- 166: "translate(-13px, 24px) scale(3.1)",
- 119: "translate(-6px, 29px) scale(3.1)",
- 126: "translate(3px, 13px) scale(3.1)",
- 67: "translate(2px, 27px) scale(3.4)",
- 163: "translate(-7px, 16px) scale(3.1)",
- 147: "translate(-2px, 15px) scale(3.0)",
- 80: "translate(-2px, -17px) scale(3.0)",
- 117: "translate(-14px, 16px) scale(3.6)",
- 201: "translate(-16px, 16px) scale(3.2)",
- 51: "translate(-2px, 6px) scale(3.2)",
- 208: "translate(-3px, 6px) scale(3.7)",
- 196: "translate(-7px, 19px) scale(5.2)",
- 143: "translate(-16px, 20px) scale(3.5)",
- 150: "translate(-3px, 24px) scale(3.2)",
- 175: "translate(-9px, 15px) scale(3.4)",
- 173: "translate(3px, 57px) scale(4.4)",
- 199: "translate(-28px, 35px) scale(3.8)",
- 52: "translate(-8px, 33px) scale(3.5)",
- 109: "translate(-8px, -6px) scale(3.2)",
- 134: "translate(-14px, 14px) scale(3.1)",
- 95: "translate(-12px, 0px) scale(3.4)",
- 96: "translate(6px, 23px) scale(3.3)",
- 154: "translate(-20px, 25px) scale(3.6)",
- 55: "translate(-16px, 28px) scale(4.0)",
- 76: "translate(-8px, 11px) scale(3.0)",
- 156: "translate(2px, 12px) scale(3.5)",
- 78: "translate(-3px, 18px) scale(3.0)",
- 191: "translate(-18px, 46px) scale(4.4)",
- 187: "translate(-6px, 22px) scale(3.2)",
- 46: "translate(-2px, 19px) scale(3.4)",
- 178: "translate(-11px, 32px) scale(3.3)",
- 100: "translate(-13px, 23px) scale(3.3)",
- 130: "translate(-14px, 4px) scale(3.1)",
- 188: "translate(-9px, 24px) scale(3.5)",
- 257: "translate(-14px, 25px) scale(3.4)",
- 206: "translate(-7px, 4px) scale(3.6)",
- 101: "translate(-13px, 16px) scale(3.2)",
- 68: "translate(-2px, 13px) scale(3.2)",
- 182: "translate(-6px, 4px) scale(3.1)",
- 180: "translate(-15px, 22px) scale(3.6)",
- 306: "translate(1px, 14px) scale(3.1)",
- default: "scale(2.5)",
+ 93: "translate(-5px, 10px) scale(2.8)",
+ 106: "translate(-8px, 8px) scale(2.9)",
+ 47: "translate(-1px, 17px) scale(3)",
+ 84: "translate(-21px, 22px) scale(3.2)",
+ 146: "translate(2px, 15px) scale(3.3)",
+ 250: "translate(-14px, 28px) scale(3.4)",
+ 212: "translate(-4px, 8px) scale(2.9)",
+ 74: "translate(-26px, 30px) scale(3.0)",
+ 94: "translate(-4px, 8px) scale(3.1)",
+ 132: "translate(-14px, 18px) scale(3.0)",
+ 56: "translate(-7px, 24px) scale(2.9)",
+ 90: "translate(-16px, 20px) scale(3.5)",
+ 136: "translate(-11px, 18px) scale(3.0)",
+ 138: "translate(-14px, 26px) scale(3.5)",
+ 166: "translate(-13px, 24px) scale(3.1)",
+ 119: "translate(-6px, 29px) scale(3.1)",
+ 126: "translate(3px, 13px) scale(3.1)",
+ 67: "translate(2px, 27px) scale(3.4)",
+ 163: "translate(-7px, 16px) scale(3.1)",
+ 147: "translate(-2px, 15px) scale(3.0)",
+ 80: "translate(-2px, -17px) scale(3.0)",
+ 117: "translate(-14px, 16px) scale(3.6)",
+ 201: "translate(-16px, 16px) scale(3.2)",
+ 51: "translate(-2px, 6px) scale(3.2)",
+ 208: "translate(-3px, 6px) scale(3.7)",
+ 196: "translate(-7px, 19px) scale(5.2)",
+ 143: "translate(-16px, 20px) scale(3.5)",
+ 150: "translate(-3px, 24px) scale(3.2)",
+ 175: "translate(-9px, 15px) scale(3.4)",
+ 173: "translate(3px, 57px) scale(4.4)",
+ 199: "translate(-28px, 35px) scale(3.8)",
+ 52: "translate(-8px, 33px) scale(3.5)",
+ 109: "translate(-8px, -6px) scale(3.2)",
+ 134: "translate(-14px, 14px) scale(3.1)",
+ 95: "translate(-12px, 0px) scale(3.4)",
+ 96: "translate(6px, 23px) scale(3.3)",
+ 154: "translate(-20px, 25px) scale(3.6)",
+ 55: "translate(-16px, 28px) scale(4.0)",
+ 76: "translate(-8px, 11px) scale(3.0)",
+ 156: "translate(2px, 12px) scale(3.5)",
+ 78: "translate(-3px, 18px) scale(3.0)",
+ 191: "translate(-18px, 46px) scale(4.4)",
+ 187: "translate(-6px, 22px) scale(3.2)",
+ 46: "translate(-2px, 19px) scale(3.4)",
+ 178: "translate(-11px, 32px) scale(3.3)",
+ 100: "translate(-13px, 23px) scale(3.3)",
+ 130: "translate(-14px, 4px) scale(3.1)",
+ 188: "translate(-9px, 24px) scale(3.5)",
+ 257: "translate(-14px, 25px) scale(3.4)",
+ 206: "translate(-7px, 4px) scale(3.6)",
+ 101: "translate(-13px, 16px) scale(3.2)",
+ 68: "translate(-2px, 13px) scale(3.2)",
+ 182: "translate(-6px, 4px) scale(3.1)",
+ 180: "translate(-15px, 22px) scale(3.6)",
+ 306: "translate(1px, 14px) scale(3.1)",
+ default: "scale(2.5)",
};
export default React.memo(PosePicker);
diff --git a/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js b/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js
index c8e2fe8e..acd9d1ac 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/SearchFooter.js
@@ -11,70 +11,70 @@ import { useSearchResults } from "./useSearchResults";
* while still keeping the rest of the item screen open!
*/
function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
- const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
- "DTIFeatureFlagCanUseSearchFooter",
- false,
- );
+ const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
+ "DTIFeatureFlagCanUseSearchFooter",
+ false,
+ );
- const { items, numTotalPages } = useSearchResults(
- searchQuery,
- outfitState,
- 1,
- );
+ const { items, numTotalPages } = useSearchResults(
+ searchQuery,
+ outfitState,
+ 1,
+ );
- React.useEffect(() => {
- if (window.location.search.includes("feature-flag-can-use-search-footer")) {
- setCanUseSearchFooter(true);
- }
- }, [setCanUseSearchFooter]);
+ React.useEffect(() => {
+ if (window.location.search.includes("feature-flag-can-use-search-footer")) {
+ setCanUseSearchFooter(true);
+ }
+ }, [setCanUseSearchFooter]);
- // TODO: Show the new footer to other users, too!
- if (!canUseSearchFooter) {
- return null;
- }
+ // TODO: Show the new footer to other users, too!
+ if (!canUseSearchFooter) {
+ return null;
+ }
- return (
-
-
-
-
-
-
- Add new items:
-
-
-
-
- {numTotalPages != null && (
-
- alert("TODO")}
- buildPageUrl={() => null}
- size="sm"
- />
-
- )}
-
-
-
-
- {items.map((item) => (
-
- {item.name}
-
- ))}
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+ Add new items:
+
+
+
+
+ {numTotalPages != null && (
+
+ alert("TODO")}
+ buildPageUrl={() => null}
+ size="sm"
+ />
+
+ )}
+
+
+
+
+ {items.map((item) => (
+
+ {item.name}
+
+ ))}
+
+
+
+
+ );
}
export default SearchFooter;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js b/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js
index 99eef2aa..bb43a1de 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/SearchPanel.js
@@ -16,54 +16,54 @@ export const SEARCH_PER_PAGE = 30;
* keyboard and focus interactions.
*/
function SearchPanel({
- query,
- outfitState,
- dispatchToOutfit,
- scrollContainerRef,
- searchQueryRef,
- firstSearchResultRef,
+ query,
+ outfitState,
+ dispatchToOutfit,
+ scrollContainerRef,
+ searchQueryRef,
+ firstSearchResultRef,
}) {
- const scrollToTop = React.useCallback(() => {
- if (scrollContainerRef.current) {
- scrollContainerRef.current.scrollTop = 0;
- }
- }, [scrollContainerRef]);
+ const scrollToTop = React.useCallback(() => {
+ if (scrollContainerRef.current) {
+ scrollContainerRef.current.scrollTop = 0;
+ }
+ }, [scrollContainerRef]);
- // Sometimes we want to give focus back to the search field!
- const onMoveFocusUpToQuery = (e) => {
- if (searchQueryRef.current) {
- searchQueryRef.current.focus();
- e.preventDefault();
- }
- };
+ // Sometimes we want to give focus back to the search field!
+ const onMoveFocusUpToQuery = (e) => {
+ if (searchQueryRef.current) {
+ searchQueryRef.current.focus();
+ e.preventDefault();
+ }
+ };
- return (
- {
- // This will catch any Escape presses when the user's focus is inside
- // the SearchPanel.
- if (e.key === "Escape") {
- onMoveFocusUpToQuery(e);
- }
- }}
- >
-
-
- );
+ return (
+ {
+ // This will catch any Escape presses when the user's focus is inside
+ // the SearchPanel.
+ if (e.key === "Escape") {
+ onMoveFocusUpToQuery(e);
+ }
+ }}
+ >
+
+
+ );
}
/**
@@ -75,191 +75,191 @@ function SearchPanel({
* the list screen-reader- and keyboard-accessible!
*/
function SearchResults({
- query,
- outfitState,
- dispatchToOutfit,
- firstSearchResultRef,
- scrollToTop,
- onMoveFocusUpToQuery,
+ query,
+ outfitState,
+ dispatchToOutfit,
+ firstSearchResultRef,
+ scrollToTop,
+ onMoveFocusUpToQuery,
}) {
- const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
- const { loading, error, items, numTotalPages } = useSearchResults(
- query,
- outfitState,
- currentPageNumber,
- );
+ const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
+ const { loading, error, items, numTotalPages } = useSearchResults(
+ query,
+ outfitState,
+ currentPageNumber,
+ );
- // Preload the previous and next page of search results, with this quick
- // ~hacky trick: just `useSearchResults` two more times, with some extra
- // attention to skip the query when we don't know if it will exist!
- useSearchResults(query, outfitState, currentPageNumber - 1, {
- skip: currentPageNumber <= 1,
- });
- useSearchResults(query, outfitState, currentPageNumber + 1, {
- skip: numTotalPages == null || currentPageNumber >= numTotalPages,
- });
+ // Preload the previous and next page of search results, with this quick
+ // ~hacky trick: just `useSearchResults` two more times, with some extra
+ // attention to skip the query when we don't know if it will exist!
+ useSearchResults(query, outfitState, currentPageNumber - 1, {
+ skip: currentPageNumber <= 1,
+ });
+ useSearchResults(query, outfitState, currentPageNumber + 1, {
+ skip: numTotalPages == null || currentPageNumber >= numTotalPages,
+ });
- // This will save the `wornItemIds` when the SearchResults first mounts, and
- // keep it saved even after the outfit changes. We use this to try to restore
- // these items after the user makes changes, e.g., after they try on another
- // Background we want to restore the previous one!
- const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
+ // This will save the `wornItemIds` when the SearchResults first mounts, and
+ // keep it saved even after the outfit changes. We use this to try to restore
+ // these items after the user makes changes, e.g., after they try on another
+ // Background we want to restore the previous one!
+ const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
- // Whenever the page number changes, scroll back to the top!
- React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
+ // Whenever the page number changes, scroll back to the top!
+ React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
- // You can use UpArrow/DownArrow to navigate between items, and even back up
- // to the search field!
- const goToPrevItem = React.useCallback(
- (e) => {
- const prevLabel = e.target.closest("label").previousSibling;
- if (prevLabel) {
- prevLabel.querySelector("input[type=checkbox]").focus();
- prevLabel.scrollIntoView({ block: "center" });
- e.preventDefault();
- } else {
- // If we're at the top of the list, move back up to the search box!
- onMoveFocusUpToQuery(e);
- }
- },
- [onMoveFocusUpToQuery],
- );
- const goToNextItem = React.useCallback((e) => {
- const nextLabel = e.target.closest("label").nextSibling;
- if (nextLabel) {
- nextLabel.querySelector("input[type=checkbox]").focus();
- nextLabel.scrollIntoView({ block: "center" });
- e.preventDefault();
- }
- }, []);
+ // You can use UpArrow/DownArrow to navigate between items, and even back up
+ // to the search field!
+ const goToPrevItem = React.useCallback(
+ (e) => {
+ const prevLabel = e.target.closest("label").previousSibling;
+ if (prevLabel) {
+ prevLabel.querySelector("input[type=checkbox]").focus();
+ prevLabel.scrollIntoView({ block: "center" });
+ e.preventDefault();
+ } else {
+ // If we're at the top of the list, move back up to the search box!
+ onMoveFocusUpToQuery(e);
+ }
+ },
+ [onMoveFocusUpToQuery],
+ );
+ const goToNextItem = React.useCallback((e) => {
+ const nextLabel = e.target.closest("label").nextSibling;
+ if (nextLabel) {
+ nextLabel.querySelector("input[type=checkbox]").focus();
+ nextLabel.scrollIntoView({ block: "center" });
+ e.preventDefault();
+ }
+ }, []);
- const searchPanelBackground = useColorModeValue("white", "gray.900");
+ const searchPanelBackground = useColorModeValue("white", "gray.900");
- if (error) {
- return ;
- }
+ if (error) {
+ return ;
+ }
- // Finally, render the item list, with checkboxes and Item components!
- // We also render some extra skeleton items at the bottom during infinite
- // scroll loading.
- return (
-
-
- null}
- size="sm"
- />
-
-
- {items.map((item, index) => (
-
- ))}
-
- {loading && (
-
- )}
- {!loading && items.length === 0 && (
-
- We couldn't find any matching items{" "}
-
- 🤔
- {" "}
- Try again?
-
- )}
-
- );
+ // Finally, render the item list, with checkboxes and Item components!
+ // We also render some extra skeleton items at the bottom during infinite
+ // scroll loading.
+ return (
+
+
+ null}
+ size="sm"
+ />
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+ {loading && (
+
+ )}
+ {!loading && items.length === 0 && (
+
+ We couldn't find any matching items{" "}
+
+ 🤔
+ {" "}
+ Try again?
+
+ )}
+
+ );
}
function SearchResultItem({
- item,
- itemIdsToReconsider,
- isWorn,
- isInOutfit,
- dispatchToOutfit,
- checkboxRef,
- goToPrevItem,
- goToNextItem,
+ item,
+ itemIdsToReconsider,
+ isWorn,
+ isInOutfit,
+ dispatchToOutfit,
+ checkboxRef,
+ goToPrevItem,
+ goToNextItem,
}) {
- // It's important to use `useCallback` for `onRemove`, to avoid re-rendering
- // the whole list of - s!
- const onRemove = React.useCallback(
- () =>
- dispatchToOutfit({
- type: "removeItem",
- itemId: item.id,
- itemIdsToReconsider,
- }),
- [item.id, itemIdsToReconsider, dispatchToOutfit],
- );
+ // It's important to use `useCallback` for `onRemove`, to avoid re-rendering
+ // the whole list of
- s!
+ const onRemove = React.useCallback(
+ () =>
+ dispatchToOutfit({
+ type: "removeItem",
+ itemId: item.id,
+ itemIdsToReconsider,
+ }),
+ [item.id, itemIdsToReconsider, dispatchToOutfit],
+ );
- return (
- // We're wrapping the control inside the label, which works just fine!
- // eslint-disable-next-line jsx-a11y/label-has-associated-control
-
- );
+ return (
+ // We're wrapping the control inside the label, which works just fine!
+ // eslint-disable-next-line jsx-a11y/label-has-associated-control
+
+ );
}
/**
@@ -267,12 +267,12 @@ function SearchResultItem({
* JS comparison.
*/
function serializeQuery(query) {
- return `${JSON.stringify([
- query.value,
- query.filterToItemKind,
- query.filterToZoneLabel,
- query.filterToCurrentUserOwnsOrWants,
- ])}`;
+ return `${JSON.stringify([
+ query.value,
+ query.filterToItemKind,
+ query.filterToZoneLabel,
+ query.filterToCurrentUserOwnsOrWants,
+ ])}`;
}
export default SearchPanel;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js b/app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js
index 87940623..9934ccd8 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/SearchToolbar.js
@@ -2,21 +2,21 @@ import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import {
- Box,
- IconButton,
- Input,
- InputGroup,
- InputLeftAddon,
- InputLeftElement,
- InputRightElement,
- Tooltip,
- useColorModeValue,
+ Box,
+ IconButton,
+ Input,
+ InputGroup,
+ InputLeftAddon,
+ InputLeftElement,
+ InputRightElement,
+ Tooltip,
+ useColorModeValue,
} from "@chakra-ui/react";
import {
- ChevronDownIcon,
- ChevronUpIcon,
- CloseIcon,
- SearchIcon,
+ ChevronDownIcon,
+ ChevronUpIcon,
+ CloseIcon,
+ SearchIcon,
} from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react";
import Autosuggest from "react-autosuggest";
@@ -25,25 +25,25 @@ import useCurrentUser from "../components/useCurrentUser";
import { logAndCapture } from "../util";
export const emptySearchQuery = {
- value: "",
- filterToZoneLabel: null,
- filterToItemKind: null,
- filterToCurrentUserOwnsOrWants: null,
+ value: "",
+ filterToZoneLabel: null,
+ filterToItemKind: null,
+ filterToCurrentUserOwnsOrWants: null,
};
export function searchQueryIsEmpty(query) {
- return Object.values(query).every((value) => !value);
+ return Object.values(query).every((value) => !value);
}
const SUGGESTIONS_PLACEMENT_PROPS = {
- inline: {
- borderBottomRadius: "md",
- },
- top: {
- position: "absolute",
- bottom: "100%",
- borderTopRadius: "md",
- },
+ inline: {
+ borderBottomRadius: "md",
+ },
+ top: {
+ position: "absolute",
+ bottom: "100%",
+ borderTopRadius: "md",
+ },
};
/**
@@ -56,387 +56,387 @@ const SUGGESTIONS_PLACEMENT_PROPS = {
* from anywhere, or UpArrow from the first result!)
*/
function SearchToolbar({
- query,
- searchQueryRef,
- firstSearchResultRef = null,
- onChange,
- autoFocus,
- showItemsLabel = false,
- background = null,
- boxShadow = null,
- suggestionsPlacement = "inline",
- ...props
+ query,
+ searchQueryRef,
+ firstSearchResultRef = null,
+ onChange,
+ autoFocus,
+ showItemsLabel = false,
+ background = null,
+ boxShadow = null,
+ suggestionsPlacement = "inline",
+ ...props
}) {
- const [suggestions, setSuggestions] = React.useState([]);
- const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
- const { isLoggedIn } = useCurrentUser();
+ const [suggestions, setSuggestions] = React.useState([]);
+ const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
+ const { isLoggedIn } = useCurrentUser();
- // NOTE: This query should always load ~instantly, from the client cache.
- const { data } = useQuery(gql`
- query SearchToolbarZones {
- allZones {
- id
- label
- depth
- isCommonlyUsedByItems
- }
- }
- `);
- const zones = data?.allZones || [];
- const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
+ // NOTE: This query should always load ~instantly, from the client cache.
+ const { data } = useQuery(gql`
+ query SearchToolbarZones {
+ allZones {
+ id
+ label
+ depth
+ isCommonlyUsedByItems
+ }
+ }
+ `);
+ const zones = data?.allZones || [];
+ const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
- let zoneLabels = itemZones.map((z) => z.label);
- zoneLabels = [...new Set(zoneLabels)];
- zoneLabels.sort();
+ let zoneLabels = itemZones.map((z) => z.label);
+ zoneLabels = [...new Set(zoneLabels)];
+ zoneLabels.sort();
- const onMoveFocusDownToResults = (e) => {
- if (firstSearchResultRef && firstSearchResultRef.current) {
- firstSearchResultRef.current.focus();
- e.preventDefault();
- }
- };
+ const onMoveFocusDownToResults = (e) => {
+ if (firstSearchResultRef && firstSearchResultRef.current) {
+ firstSearchResultRef.current.focus();
+ e.preventDefault();
+ }
+ };
- const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
- const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
+ const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
+ const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
- const renderSuggestion = React.useCallback(
- ({ text }, { isHighlighted }) => (
-
- {text}
-
- ),
- [suggestionBgColor, highlightedBgColor],
- );
+ const renderSuggestion = React.useCallback(
+ ({ text }, { isHighlighted }) => (
+
+ {text}
+
+ ),
+ [suggestionBgColor, highlightedBgColor],
+ );
- const renderSuggestionsContainer = React.useCallback(
- ({ containerProps, children }) => {
- const { className, ...otherContainerProps } = containerProps;
- return (
-
- {({ css, cx }) => (
-
- {children}
- {!children && advancedSearchIsOpen && (
-
- No more filters available!
-
- )}
-
- )}
-
- );
- },
- [advancedSearchIsOpen, suggestionsPlacement],
- );
+ const renderSuggestionsContainer = React.useCallback(
+ ({ containerProps, children }) => {
+ const { className, ...otherContainerProps } = containerProps;
+ return (
+
+ {({ css, cx }) => (
+
+ {children}
+ {!children && advancedSearchIsOpen && (
+
+ No more filters available!
+
+ )}
+
+ )}
+
+ );
+ },
+ [advancedSearchIsOpen, suggestionsPlacement],
+ );
- // When we change the query filters, clear out the suggestions.
- React.useEffect(() => {
- setSuggestions([]);
- }, [
- query.filterToItemKind,
- query.filterToZoneLabel,
- query.filterToCurrentUserOwnsOrWants,
- ]);
+ // When we change the query filters, clear out the suggestions.
+ React.useEffect(() => {
+ setSuggestions([]);
+ }, [
+ query.filterToItemKind,
+ query.filterToZoneLabel,
+ query.filterToCurrentUserOwnsOrWants,
+ ]);
- let queryFilterText = getQueryFilterText(query);
- if (showItemsLabel) {
- queryFilterText = queryFilterText ? (
- <>
-
- Items:
- {" "}
- {queryFilterText}
- >
- ) : (
-
- Items
-
- );
- }
+ let queryFilterText = getQueryFilterText(query);
+ if (showItemsLabel) {
+ queryFilterText = queryFilterText ? (
+ <>
+
+ Items:
+ {" "}
+ {queryFilterText}
+ >
+ ) : (
+
+ Items
+
+ );
+ }
- const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
- showAll: true,
- });
+ const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
+ showAll: true,
+ });
- // Once you remove the final suggestion available, close Advanced Search. We
- // have placeholder text available, sure, but this feels more natural!
- React.useEffect(() => {
- if (allSuggestions.length === 0) {
- setAdvancedSearchIsOpen(false);
- }
- }, [allSuggestions.length]);
+ // Once you remove the final suggestion available, close Advanced Search. We
+ // have placeholder text available, sure, but this feels more natural!
+ React.useEffect(() => {
+ if (allSuggestions.length === 0) {
+ setAdvancedSearchIsOpen(false);
+ }
+ }, [allSuggestions.length]);
- const focusBorderColor = useColorModeValue("green.600", "green.400");
+ const focusBorderColor = useColorModeValue("green.600", "green.400");
- return (
-
- {
- // HACK: I'm not sure why, but apparently this gets called with value
- // set to the _chosen suggestion_ after choosing it? Has that
- // always happened? Idk? Let's just, gate around it, I guess?
- if (typeof value === "string") {
- setSuggestions(
- getSuggestions(value, query, zoneLabels, isLoggedIn),
- );
- }
- }}
- onSuggestionSelected={(e, { suggestion }) => {
- onChange({
- ...query,
- // If the suggestion was from typing, remove the last word of the
- // query value. Or, if it was from Advanced Search, leave it alone!
- value: advancedSearchIsOpen
- ? query.value
- : removeLastWord(query.value),
- filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
- filterToItemKind: suggestion.itemKind || query.filterToItemKind,
- filterToCurrentUserOwnsOrWants:
- suggestion.userOwnsOrWants ||
- query.filterToCurrentUserOwnsOrWants,
- });
- }}
- getSuggestionValue={(zl) => zl}
- alwaysRenderSuggestions={true}
- renderSuggestion={renderSuggestion}
- renderSuggestionsContainer={renderSuggestionsContainer}
- renderInputComponent={(inputProps) => (
-
- {queryFilterText ? (
-
-
- {queryFilterText}
-
- ) : (
-
-
-
- )}
-
-
- {!searchQueryIsEmpty(query) && (
-
- }
- color="gray.400"
- variant="ghost"
- height="100%"
- marginLeft="1"
- aria-label="Clear search"
- onClick={() => {
- setSuggestions([]);
- onChange(emptySearchQuery);
- }}
- />
-
- )}
-
-
- ) : (
-
- )
- }
- color="gray.400"
- variant="ghost"
- height="100%"
- aria-label="Open advanced search"
- onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
- />
-
-
-
- )}
- inputProps={{
- placeholder: "Search all items…",
- focusBorderColor: focusBorderColor,
- value: query.value || "",
- ref: searchQueryRef,
- minWidth: 0,
- "data-test-id": "item-search-input",
- onChange: (e, { newValue, method }) => {
- // The Autosuggest tries to change the _entire_ value of the element
- // when navigating suggestions, which isn't actually what we want.
- // Only accept value changes that are typed by the user!
- if (method === "type") {
- onChange({ ...query, value: newValue });
- }
- },
- onKeyDown: (e) => {
- if (e.key === "Escape") {
- if (suggestions.length > 0) {
- setSuggestions([]);
- return;
- }
- onChange(emptySearchQuery);
- e.target.blur();
- } else if (e.key === "Enter") {
- // Pressing Enter doesn't actually submit because it's all on
- // debounce, but it can be a declaration that the query is done, so
- // filter suggestions should go away!
- if (suggestions.length > 0) {
- setSuggestions([]);
- return;
- }
- } else if (e.key === "ArrowDown") {
- if (suggestions.length > 0) {
- return;
- }
- onMoveFocusDownToResults(e);
- } else if (e.key === "Backspace" && e.target.selectionStart === 0) {
- onChange({
- ...query,
- filterToItemKind: null,
- filterToZoneLabel: null,
- filterToCurrentUserOwnsOrWants: null,
- });
- }
- },
- }}
- />
-
- );
+ return (
+
+ {
+ // HACK: I'm not sure why, but apparently this gets called with value
+ // set to the _chosen suggestion_ after choosing it? Has that
+ // always happened? Idk? Let's just, gate around it, I guess?
+ if (typeof value === "string") {
+ setSuggestions(
+ getSuggestions(value, query, zoneLabels, isLoggedIn),
+ );
+ }
+ }}
+ onSuggestionSelected={(e, { suggestion }) => {
+ onChange({
+ ...query,
+ // If the suggestion was from typing, remove the last word of the
+ // query value. Or, if it was from Advanced Search, leave it alone!
+ value: advancedSearchIsOpen
+ ? query.value
+ : removeLastWord(query.value),
+ filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
+ filterToItemKind: suggestion.itemKind || query.filterToItemKind,
+ filterToCurrentUserOwnsOrWants:
+ suggestion.userOwnsOrWants ||
+ query.filterToCurrentUserOwnsOrWants,
+ });
+ }}
+ getSuggestionValue={(zl) => zl}
+ alwaysRenderSuggestions={true}
+ renderSuggestion={renderSuggestion}
+ renderSuggestionsContainer={renderSuggestionsContainer}
+ renderInputComponent={(inputProps) => (
+
+ {queryFilterText ? (
+
+
+ {queryFilterText}
+
+ ) : (
+
+
+
+ )}
+
+
+ {!searchQueryIsEmpty(query) && (
+
+ }
+ color="gray.400"
+ variant="ghost"
+ height="100%"
+ marginLeft="1"
+ aria-label="Clear search"
+ onClick={() => {
+ setSuggestions([]);
+ onChange(emptySearchQuery);
+ }}
+ />
+
+ )}
+
+
+ ) : (
+
+ )
+ }
+ color="gray.400"
+ variant="ghost"
+ height="100%"
+ aria-label="Open advanced search"
+ onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
+ />
+
+
+
+ )}
+ inputProps={{
+ placeholder: "Search all items…",
+ focusBorderColor: focusBorderColor,
+ value: query.value || "",
+ ref: searchQueryRef,
+ minWidth: 0,
+ "data-test-id": "item-search-input",
+ onChange: (e, { newValue, method }) => {
+ // The Autosuggest tries to change the _entire_ value of the element
+ // when navigating suggestions, which isn't actually what we want.
+ // Only accept value changes that are typed by the user!
+ if (method === "type") {
+ onChange({ ...query, value: newValue });
+ }
+ },
+ onKeyDown: (e) => {
+ if (e.key === "Escape") {
+ if (suggestions.length > 0) {
+ setSuggestions([]);
+ return;
+ }
+ onChange(emptySearchQuery);
+ e.target.blur();
+ } else if (e.key === "Enter") {
+ // Pressing Enter doesn't actually submit because it's all on
+ // debounce, but it can be a declaration that the query is done, so
+ // filter suggestions should go away!
+ if (suggestions.length > 0) {
+ setSuggestions([]);
+ return;
+ }
+ } else if (e.key === "ArrowDown") {
+ if (suggestions.length > 0) {
+ return;
+ }
+ onMoveFocusDownToResults(e);
+ } else if (e.key === "Backspace" && e.target.selectionStart === 0) {
+ onChange({
+ ...query,
+ filterToItemKind: null,
+ filterToZoneLabel: null,
+ filterToCurrentUserOwnsOrWants: null,
+ });
+ }
+ },
+ }}
+ />
+
+ );
}
function getSuggestions(
- value,
- query,
- zoneLabels,
- isLoggedIn,
- { showAll = false } = {},
+ value,
+ query,
+ zoneLabels,
+ isLoggedIn,
+ { showAll = false } = {},
) {
- if (!value && !showAll) {
- return [];
- }
+ if (!value && !showAll) {
+ return [];
+ }
- const words = (value || "").split(/\s+/);
- const lastWord = words[words.length - 1];
- if (lastWord.length < 2 && !showAll) {
- return [];
- }
+ const words = (value || "").split(/\s+/);
+ const lastWord = words[words.length - 1];
+ if (lastWord.length < 2 && !showAll) {
+ return [];
+ }
- const suggestions = [];
+ const suggestions = [];
- if (query.filterToItemKind == null) {
- if (
- wordMatches("NC", lastWord) ||
- wordMatches("Neocash", lastWord) ||
- showAll
- ) {
- suggestions.push({ itemKind: "NC", text: "Neocash items" });
- }
+ if (query.filterToItemKind == null) {
+ if (
+ wordMatches("NC", lastWord) ||
+ wordMatches("Neocash", lastWord) ||
+ showAll
+ ) {
+ suggestions.push({ itemKind: "NC", text: "Neocash items" });
+ }
- if (
- wordMatches("NP", lastWord) ||
- wordMatches("Neopoints", lastWord) ||
- showAll
- ) {
- suggestions.push({ itemKind: "NP", text: "Neopoint items" });
- }
+ if (
+ wordMatches("NP", lastWord) ||
+ wordMatches("Neopoints", lastWord) ||
+ showAll
+ ) {
+ suggestions.push({ itemKind: "NP", text: "Neopoint items" });
+ }
- if (
- wordMatches("PB", lastWord) ||
- wordMatches("Paintbrush", lastWord) ||
- showAll
- ) {
- suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
- }
- }
+ if (
+ wordMatches("PB", lastWord) ||
+ wordMatches("Paintbrush", lastWord) ||
+ showAll
+ ) {
+ suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
+ }
+ }
- if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
- if (wordMatches("Items you own", lastWord) || showAll) {
- suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
- }
+ if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
+ if (wordMatches("Items you own", lastWord) || showAll) {
+ suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
+ }
- if (wordMatches("Items you want", lastWord) || showAll) {
- suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
- }
- }
+ if (wordMatches("Items you want", lastWord) || showAll) {
+ suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
+ }
+ }
- if (query.filterToZoneLabel == null) {
- for (const zoneLabel of zoneLabels) {
- if (wordMatches(zoneLabel, lastWord) || showAll) {
- suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
- }
- }
- }
+ if (query.filterToZoneLabel == null) {
+ for (const zoneLabel of zoneLabels) {
+ if (wordMatches(zoneLabel, lastWord) || showAll) {
+ suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
+ }
+ }
+ }
- return suggestions;
+ return suggestions;
}
function wordMatches(target, word) {
- return target.toLowerCase().includes(word.toLowerCase());
+ return target.toLowerCase().includes(word.toLowerCase());
}
function getQueryFilterText(query) {
- const textWords = [];
+ const textWords = [];
- if (query.filterToItemKind) {
- textWords.push(query.filterToItemKind);
- }
+ if (query.filterToItemKind) {
+ textWords.push(query.filterToItemKind);
+ }
- if (query.filterToZoneLabel) {
- textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
- }
+ if (query.filterToZoneLabel) {
+ textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
+ }
- if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
- if (!query.filterToItemKind && !query.filterToZoneLabel) {
- textWords.push("Items");
- } else if (query.filterToItemKind && !query.filterToZoneLabel) {
- textWords.push("items");
- }
- textWords.push("you own");
- } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
- if (!query.filterToItemKind && !query.filterToZoneLabel) {
- textWords.push("Items");
- } else if (query.filterToItemKind && !query.filterToZoneLabel) {
- textWords.push("items");
- }
- textWords.push("you want");
- }
+ if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
+ if (!query.filterToItemKind && !query.filterToZoneLabel) {
+ textWords.push("Items");
+ } else if (query.filterToItemKind && !query.filterToZoneLabel) {
+ textWords.push("items");
+ }
+ textWords.push("you own");
+ } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
+ if (!query.filterToItemKind && !query.filterToZoneLabel) {
+ textWords.push("Items");
+ } else if (query.filterToItemKind && !query.filterToZoneLabel) {
+ textWords.push("items");
+ }
+ textWords.push("you want");
+ }
- return textWords.join(" ");
+ return textWords.join(" ");
}
/**
@@ -446,13 +446,13 @@ function getQueryFilterText(query) {
* manually creating the plural for each zone. But, ehh! ¯\_ (ツ)_/¯
*/
function pluralizeZoneLabel(zoneLabel) {
- if (zoneLabel.endsWith("ss")) {
- return zoneLabel + "es";
- } else if (zoneLabel.endsWith("s")) {
- return zoneLabel;
- } else {
- return zoneLabel + "s";
- }
+ if (zoneLabel.endsWith("ss")) {
+ return zoneLabel + "es";
+ } else if (zoneLabel.endsWith("s")) {
+ return zoneLabel;
+ } else {
+ return zoneLabel + "s";
+ }
}
/**
@@ -460,22 +460,22 @@ function pluralizeZoneLabel(zoneLabel) {
* preceding space removed.
*/
function removeLastWord(text) {
- // This regex matches the full text, and assigns the last word and any
- // preceding text to subgroup 2, and all preceding text to subgroup 1. If
- // there's no last word, we'll still match, and the full string will be in
- // subgroup 1, including any space - no changes made!
- const match = text.match(/^(.*?)(\s*\S+)?$/);
- if (!match) {
- logAndCapture(
- new Error(
- `Assertion failure: pattern should match any input text, ` +
- `but failed to match ${JSON.stringify(text)}`,
- ),
- );
- return text;
- }
+ // This regex matches the full text, and assigns the last word and any
+ // preceding text to subgroup 2, and all preceding text to subgroup 1. If
+ // there's no last word, we'll still match, and the full string will be in
+ // subgroup 1, including any space - no changes made!
+ const match = text.match(/^(.*?)(\s*\S+)?$/);
+ if (!match) {
+ logAndCapture(
+ new Error(
+ `Assertion failure: pattern should match any input text, ` +
+ `but failed to match ${JSON.stringify(text)}`,
+ ),
+ );
+ return text;
+ }
- return match[1];
+ return match[1];
}
export default SearchToolbar;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/WardrobePageLayout.js b/app/javascript/wardrobe-2020/WardrobePage/WardrobePageLayout.js
index 6dbf8d9f..dc771061 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/WardrobePageLayout.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/WardrobePageLayout.js
@@ -3,65 +3,65 @@ import { Box, Grid, useColorModeValue, useToken } from "@chakra-ui/react";
import { useCommonStyles } from "../util";
function WardrobePageLayout({
- previewAndControls = null,
- itemsAndMaybeSearchPanel = null,
- searchFooter = null,
+ previewAndControls = null,
+ itemsAndMaybeSearchPanel = null,
+ searchFooter = null,
}) {
- const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
- const searchBackground = useCommonStyles().bodyBackground;
- const searchShadowColorValue = useToken("colors", "gray.400");
+ const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
+ const searchBackground = useCommonStyles().bodyBackground;
+ const searchShadowColorValue = useToken("colors", "gray.400");
- return (
-
-
+
-
- {previewAndControls}
-
-
- {itemsAndMaybeSearchPanel}
-
-
- {searchFooter}
-
-
-
- );
+ }}
+ templateRows={{
+ base: "minmax(100px, 45%) minmax(300px, 55%)",
+ md: "minmax(300px, 1fr) auto",
+ }}
+ templateColumns={{
+ base: "100%",
+ md: "50% 50%",
+ }}
+ height="100%"
+ width="100%"
+ >
+
+ {previewAndControls}
+
+
+ {itemsAndMaybeSearchPanel}
+
+
+ {searchFooter}
+
+
+
+ );
}
export default WardrobePageLayout;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/WardrobePreviewAndControls.js b/app/javascript/wardrobe-2020/WardrobePage/WardrobePreviewAndControls.js
index b83ae076..2ee5e10a 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/WardrobePreviewAndControls.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/WardrobePreviewAndControls.js
@@ -11,43 +11,43 @@ import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
const OutfitControls = loadable(() => import("./OutfitControls"));
function WardrobePreviewAndControls({
- isLoading,
- outfitState,
- dispatchToOutfit,
+ isLoading,
+ outfitState,
+ dispatchToOutfit,
}) {
- // Whether the current outfit preview has animations. Determines whether we
- // show the play/pause button.
- const [hasAnimations, setHasAnimations] = React.useState(false);
+ // Whether the current outfit preview has animations. Determines whether we
+ // show the play/pause button.
+ const [hasAnimations, setHasAnimations] = React.useState(false);
- const { appearance, preview } = useOutfitPreview({
- isLoading: isLoading,
- speciesId: outfitState.speciesId,
- colorId: outfitState.colorId,
- pose: outfitState.pose,
- altStyleId: outfitState.altStyleId,
- appearanceId: outfitState.appearanceId,
- wornItemIds: outfitState.wornItemIds,
- onChangeHasAnimations: setHasAnimations,
- placeholder: ,
- "data-test-id": "wardrobe-outfit-preview",
- });
+ const { appearance, preview } = useOutfitPreview({
+ isLoading: isLoading,
+ speciesId: outfitState.speciesId,
+ colorId: outfitState.colorId,
+ pose: outfitState.pose,
+ altStyleId: outfitState.altStyleId,
+ appearanceId: outfitState.appearanceId,
+ wornItemIds: outfitState.wornItemIds,
+ onChangeHasAnimations: setHasAnimations,
+ placeholder: ,
+ "data-test-id": "wardrobe-outfit-preview",
+ });
- return (
-
-
-
- {preview}
-
-
-
-
-
- );
+ return (
+
+
+
+ {preview}
+
+
+
+
+
+ );
}
/**
@@ -61,40 +61,40 @@ function WardrobePreviewAndControls({
* like usual!
*/
function OutfitThumbnailIfCached({ outfitId }) {
- const { data } = useQuery(
- gql`
- query OutfitThumbnailIfCached($outfitId: ID!) {
- outfit(id: $outfitId) {
- id
- updatedAt
- }
- }
- `,
- {
- variables: {
- outfitId,
- },
- skip: outfitId == null,
- fetchPolicy: "cache-only",
- onError: (e) => console.error(e),
- },
- );
+ const { data } = useQuery(
+ gql`
+ query OutfitThumbnailIfCached($outfitId: ID!) {
+ outfit(id: $outfitId) {
+ id
+ updatedAt
+ }
+ }
+ `,
+ {
+ variables: {
+ outfitId,
+ },
+ skip: outfitId == null,
+ fetchPolicy: "cache-only",
+ onError: (e) => console.error(e),
+ },
+ );
- if (!data?.outfit) {
- return null;
- }
+ if (!data?.outfit) {
+ return null;
+ }
- return (
-
- );
+ return (
+
+ );
}
export default WardrobePreviewAndControls;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/index.js b/app/javascript/wardrobe-2020/WardrobePage/index.js
index 20f842be..3ae174d5 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/index.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/index.js
@@ -21,93 +21,93 @@ import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
* page layout.
*/
function WardrobePage() {
- const toast = useToast();
- const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
+ const toast = useToast();
+ const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
- const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
+ const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
- // We manage outfit saving up here, rather than at the point of the UI where
- // "Saving" indicators appear. That way, auto-saving still happens even when
- // the indicator isn't on the page, e.g. when searching.
- // NOTE: This only applies to navigations leaving the wardrobe-2020 app, not
- // within!
- const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
+ // We manage outfit saving up here, rather than at the point of the UI where
+ // "Saving" indicators appear. That way, auto-saving still happens even when
+ // the indicator isn't on the page, e.g. when searching.
+ // NOTE: This only applies to navigations leaving the wardrobe-2020 app, not
+ // within!
+ const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
- // TODO: I haven't found a great place for this error UI yet, and this case
- // isn't very common, so this lil toast notification seems good enough!
- React.useEffect(() => {
- if (error) {
- console.error(error);
- toast({
- title: "We couldn't load this outfit 😖",
- description: "Please reload the page to try again. Sorry!",
- status: "error",
- isClosable: true,
- duration: 999999999,
- });
- }
- }, [error, toast]);
+ // TODO: I haven't found a great place for this error UI yet, and this case
+ // isn't very common, so this lil toast notification seems good enough!
+ React.useEffect(() => {
+ if (error) {
+ console.error(error);
+ toast({
+ title: "We couldn't load this outfit 😖",
+ description: "Please reload the page to try again. Sorry!",
+ status: "error",
+ isClosable: true,
+ duration: 999999999,
+ });
+ }
+ }, [error, toast]);
- // For new outfits, we only block navigation while saving. For existing
- // outfits, we block navigation while there are any unsaved changes.
- const shouldBlockNavigation =
- outfitSaving.canSaveOutfit &&
- ((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
- (!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
+ // For new outfits, we only block navigation while saving. For existing
+ // outfits, we block navigation while there are any unsaved changes.
+ const shouldBlockNavigation =
+ outfitSaving.canSaveOutfit &&
+ ((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
+ (!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
- // In addition to a for client-side nav, we need to block full nav!
- React.useEffect(() => {
- if (shouldBlockNavigation) {
- const onBeforeUnload = (e) => {
- // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
- e.preventDefault();
- e.returnValue = "";
- };
+ // In addition to a for client-side nav, we need to block full nav!
+ React.useEffect(() => {
+ if (shouldBlockNavigation) {
+ const onBeforeUnload = (e) => {
+ // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
+ e.preventDefault();
+ e.returnValue = "";
+ };
- window.addEventListener("beforeunload", onBeforeUnload);
- return () => window.removeEventListener("beforeunload", onBeforeUnload);
- }
- }, [shouldBlockNavigation]);
+ window.addEventListener("beforeunload", onBeforeUnload);
+ return () => window.removeEventListener("beforeunload", onBeforeUnload);
+ }
+ }, [shouldBlockNavigation]);
- const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
- React.useEffect(() => {
- document.title = title;
- }, [title]);
+ const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
+ React.useEffect(() => {
+ document.title = title;
+ }, [title]);
- // NOTE: Most components pass around outfitState directly, to make the data
- // relationships more explicit... but there are some deep components
- // that need it, where it's more useful and more performant to access
- // via context.
- return (
-
-
- }
- itemsAndMaybeSearchPanel={
-
- }
- searchFooter={
-
- }
- />
-
- );
+ // NOTE: Most components pass around outfitState directly, to make the data
+ // relationships more explicit... but there are some deep components
+ // that need it, where it's more useful and more performant to access
+ // via context.
+ return (
+
+
+ }
+ itemsAndMaybeSearchPanel={
+
+ }
+ searchFooter={
+
+ }
+ />
+
+ );
}
export default WardrobePage;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/AllItemLayersSupportModal.js b/app/javascript/wardrobe-2020/WardrobePage/support/AllItemLayersSupportModal.js
index 021fa3c2..428e812a 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/AllItemLayersSupportModal.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/AllItemLayersSupportModal.js
@@ -1,27 +1,27 @@
import React from "react";
import {
- Box,
- Button,
- Flex,
- Heading,
- Input,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalHeader,
- ModalOverlay,
- Select,
- Tooltip,
- Wrap,
- WrapItem,
+ Box,
+ Button,
+ Flex,
+ Heading,
+ Input,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalHeader,
+ ModalOverlay,
+ Select,
+ Tooltip,
+ Wrap,
+ WrapItem,
} from "@chakra-ui/react";
import { gql, useMutation, useQuery } from "@apollo/client";
import {
- appearanceLayerFragment,
- appearanceLayerFragmentForSupport,
- itemAppearanceFragment,
- petAppearanceFragment,
+ appearanceLayerFragment,
+ appearanceLayerFragmentForSupport,
+ itemAppearanceFragment,
+ petAppearanceFragment,
} from "../../components/useOutfitAppearance";
import HangerSpinner from "../../components/HangerSpinner";
import { ErrorMessage, useCommonStyles } from "../../util";
@@ -30,540 +30,540 @@ import { EditIcon } from "@chakra-ui/icons";
import useSupport from "./useSupport";
function AllItemLayersSupportModal({ item, isOpen, onClose }) {
- const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
+ const [bulkAddProposal, setBulkAddProposal] = React.useState(null);
- const { bodyBackground } = useCommonStyles();
+ const { bodyBackground } = useCommonStyles();
- return (
-
-
-
-
-
- Layers on all pets:
- {" "}
-
- {item.name}
-
-
-
-
-
-
- setBulkAddProposal(null)}
- />
-
-
-
-
- );
+ return (
+
+
+
+
+
+ Layers on all pets:
+ {" "}
+
+ {item.name}
+
+
+
+
+
+
+ setBulkAddProposal(null)}
+ />
+
+
+
+
+ );
}
function BulkAddBodySpecificAssetsForm({ bulkAddProposal, onSubmit }) {
- const [minAssetId, setMinAssetId] = React.useState(
- bulkAddProposal?.minAssetId,
- );
- const [assetIdStepValue, setAssetIdStepValue] = React.useState(1);
- const [numSpecies, setNumSpecies] = React.useState(55);
- const [colorId, setColorId] = React.useState("8");
+ const [minAssetId, setMinAssetId] = React.useState(
+ bulkAddProposal?.minAssetId,
+ );
+ const [assetIdStepValue, setAssetIdStepValue] = React.useState(1);
+ const [numSpecies, setNumSpecies] = React.useState(55);
+ const [colorId, setColorId] = React.useState("8");
- return (
- {
- e.preventDefault();
- onSubmit({ minAssetId, numSpecies, assetIdStepValue, colorId });
- }}
- >
-
-
- When an item accidentally gets assigned to fit all bodies, this
- tool can help you recover the original appearances, by assuming
- the layer IDs are assigned to each species in alphabetical order.
-
-
- This will only find layers that have already been modeled!
-
-
- }
- >
-
-
- Bulk-add:
-
-
-
- setMinAssetId(e.target.value || null)}
- />
-
- –
-
-
- setMinAssetId(
- e.target.value
- ? Number(e.target.value) - assetIdStepValue * (numSpecies - 1)
- : null,
- )
- }
- />
-
-
-
- for
-
-
-
-
-
-
- Preview
-
-
- );
+ return (
+ {
+ e.preventDefault();
+ onSubmit({ minAssetId, numSpecies, assetIdStepValue, colorId });
+ }}
+ >
+
+
+ When an item accidentally gets assigned to fit all bodies, this
+ tool can help you recover the original appearances, by assuming
+ the layer IDs are assigned to each species in alphabetical order.
+
+
+ This will only find layers that have already been modeled!
+
+
+ }
+ >
+
+
+ Bulk-add:
+
+
+
+ setMinAssetId(e.target.value || null)}
+ />
+
+ –
+
+
+ setMinAssetId(
+ e.target.value
+ ? Number(e.target.value) - assetIdStepValue * (numSpecies - 1)
+ : null,
+ )
+ }
+ />
+
+
+
+ for
+
+
+
+
+
+
+ Preview
+
+
+ );
}
const allAppearancesFragment = gql`
- fragment AllAppearancesForItem on Item {
- allAppearances {
- id
- body {
- id
- representsAllBodies
- canonicalAppearance {
- id
- species {
- id
- name
- }
- color {
- id
- name
- isStandard
- }
- pose
- ...PetAppearanceForOutfitPreview
- }
- }
- ...ItemAppearanceForOutfitPreview
- }
- }
+ fragment AllAppearancesForItem on Item {
+ allAppearances {
+ id
+ body {
+ id
+ representsAllBodies
+ canonicalAppearance {
+ id
+ species {
+ id
+ name
+ }
+ color {
+ id
+ name
+ isStandard
+ }
+ pose
+ ...PetAppearanceForOutfitPreview
+ }
+ }
+ ...ItemAppearanceForOutfitPreview
+ }
+ }
- ${itemAppearanceFragment}
- ${petAppearanceFragment}
+ ${itemAppearanceFragment}
+ ${petAppearanceFragment}
`;
function AllItemLayersSupportModalContent({
- item,
- bulkAddProposal,
- onBulkAddComplete,
+ item,
+ bulkAddProposal,
+ onBulkAddComplete,
}) {
- const { supportSecret } = useSupport();
+ const { supportSecret } = useSupport();
- const { loading, error, data } = useQuery(
- gql`
- query AllItemLayersSupportModal($itemId: ID!) {
- item(id: $itemId) {
- id
- ...AllAppearancesForItem
- }
- }
+ const { loading, error, data } = useQuery(
+ gql`
+ query AllItemLayersSupportModal($itemId: ID!) {
+ item(id: $itemId) {
+ id
+ ...AllAppearancesForItem
+ }
+ }
- ${allAppearancesFragment}
- `,
- { variables: { itemId: item.id } },
- );
+ ${allAppearancesFragment}
+ `,
+ { variables: { itemId: item.id } },
+ );
- const {
- loading: loading2,
- error: error2,
- data: bulkAddProposalData,
- } = useQuery(
- gql`
- query AllItemLayersSupportModal_BulkAddProposal(
- $layerRemoteIds: [ID!]!
- $colorId: ID!
- ) {
- layersToAdd: itemAppearanceLayersByRemoteId(
- remoteIds: $layerRemoteIds
- ) {
- id
- remoteId
- ...AppearanceLayerForOutfitPreview
- ...AppearanceLayerForSupport
- }
+ const {
+ loading: loading2,
+ error: error2,
+ data: bulkAddProposalData,
+ } = useQuery(
+ gql`
+ query AllItemLayersSupportModal_BulkAddProposal(
+ $layerRemoteIds: [ID!]!
+ $colorId: ID!
+ ) {
+ layersToAdd: itemAppearanceLayersByRemoteId(
+ remoteIds: $layerRemoteIds
+ ) {
+ id
+ remoteId
+ ...AppearanceLayerForOutfitPreview
+ ...AppearanceLayerForSupport
+ }
- color(id: $colorId) {
- id
- appliedToAllCompatibleSpecies {
- id
- species {
- id
- name
- }
- body {
- id
- }
- canonicalAppearance {
- # These are a bit redundant, but it's convenient to just reuse
- # what the other query is already doing.
- id
- species {
- id
- name
- }
- color {
- id
- name
- isStandard
- }
- pose
- ...PetAppearanceForOutfitPreview
- }
- }
- }
- }
+ color(id: $colorId) {
+ id
+ appliedToAllCompatibleSpecies {
+ id
+ species {
+ id
+ name
+ }
+ body {
+ id
+ }
+ canonicalAppearance {
+ # These are a bit redundant, but it's convenient to just reuse
+ # what the other query is already doing.
+ id
+ species {
+ id
+ name
+ }
+ color {
+ id
+ name
+ isStandard
+ }
+ pose
+ ...PetAppearanceForOutfitPreview
+ }
+ }
+ }
+ }
- ${appearanceLayerFragment}
- ${appearanceLayerFragmentForSupport}
- ${petAppearanceFragment}
- `,
- {
- variables: {
- layerRemoteIds: bulkAddProposal
- ? Array.from({ length: 54 }, (_, i) =>
- String(
- Number(bulkAddProposal.minAssetId) +
- i * bulkAddProposal.assetIdStepValue,
- ),
- )
- : [],
- colorId: bulkAddProposal?.colorId,
- },
- skip: bulkAddProposal == null,
- },
- );
+ ${appearanceLayerFragment}
+ ${appearanceLayerFragmentForSupport}
+ ${petAppearanceFragment}
+ `,
+ {
+ variables: {
+ layerRemoteIds: bulkAddProposal
+ ? Array.from({ length: 54 }, (_, i) =>
+ String(
+ Number(bulkAddProposal.minAssetId) +
+ i * bulkAddProposal.assetIdStepValue,
+ ),
+ )
+ : [],
+ colorId: bulkAddProposal?.colorId,
+ },
+ skip: bulkAddProposal == null,
+ },
+ );
- const [
- sendBulkAddMutation,
- { loading: mutationLoading, error: mutationError },
- ] = useMutation(gql`
- mutation AllItemLayersSupportModal_BulkAddMutation(
- $itemId: ID!
- $entries: [BulkAddLayersToItemEntry!]!
- $supportSecret: String!
- ) {
- bulkAddLayersToItem(
- itemId: $itemId
- entries: $entries
- supportSecret: $supportSecret
- ) {
- id
- ...AllAppearancesForItem
- }
- }
+ const [
+ sendBulkAddMutation,
+ { loading: mutationLoading, error: mutationError },
+ ] = useMutation(gql`
+ mutation AllItemLayersSupportModal_BulkAddMutation(
+ $itemId: ID!
+ $entries: [BulkAddLayersToItemEntry!]!
+ $supportSecret: String!
+ ) {
+ bulkAddLayersToItem(
+ itemId: $itemId
+ entries: $entries
+ supportSecret: $supportSecret
+ ) {
+ id
+ ...AllAppearancesForItem
+ }
+ }
- ${allAppearancesFragment}
- `);
+ ${allAppearancesFragment}
+ `);
- if (loading || loading2) {
- return (
-
-
-
- );
- }
+ if (loading || loading2) {
+ return (
+
+
+
+ );
+ }
- if (error || error2) {
- return {(error || error2).message};
- }
+ if (error || error2) {
+ return {(error || error2).message};
+ }
- let itemAppearances = data.item?.allAppearances || [];
- itemAppearances = mergeBulkAddProposalIntoItemAppearances(
- itemAppearances,
- bulkAddProposal,
- bulkAddProposalData,
- );
- itemAppearances = [...itemAppearances].sort((a, b) => {
- const aKey = getSortKeyForBody(a.body);
- const bKey = getSortKeyForBody(b.body);
- return aKey.localeCompare(bKey);
- });
+ let itemAppearances = data.item?.allAppearances || [];
+ itemAppearances = mergeBulkAddProposalIntoItemAppearances(
+ itemAppearances,
+ bulkAddProposal,
+ bulkAddProposalData,
+ );
+ itemAppearances = [...itemAppearances].sort((a, b) => {
+ const aKey = getSortKeyForBody(a.body);
+ const bKey = getSortKeyForBody(b.body);
+ return aKey.localeCompare(bKey);
+ });
- return (
-
- {bulkAddProposalData && (
-
- Previewing bulk-add changes
-
- {mutationError && (
-
- {mutationError.message}
-
- )}
-
- Clear
-
-
- {
- if (
- !window.confirm("Are you sure? Bulk operations are dangerous!")
- ) {
- return;
- }
+ return (
+
+ {bulkAddProposalData && (
+
+ Previewing bulk-add changes
+
+ {mutationError && (
+
+ {mutationError.message}
+
+ )}
+
+ Clear
+
+
+ {
+ if (
+ !window.confirm("Are you sure? Bulk operations are dangerous!")
+ ) {
+ return;
+ }
- // HACK: This could pick up not just new layers, but existing layers
- // that aren't changing. Shouldn't be a problem to save,
- // though?
- // NOTE: This API uses actual layer IDs, instead of the remote IDs
- // that we use for body assignment in most of this tool.
- const entries = itemAppearances
- .map((a) =>
- a.layers.map((l) => ({ layerId: l.id, bodyId: a.body.id })),
- )
- .flat();
+ // HACK: This could pick up not just new layers, but existing layers
+ // that aren't changing. Shouldn't be a problem to save,
+ // though?
+ // NOTE: This API uses actual layer IDs, instead of the remote IDs
+ // that we use for body assignment in most of this tool.
+ const entries = itemAppearances
+ .map((a) =>
+ a.layers.map((l) => ({ layerId: l.id, bodyId: a.body.id })),
+ )
+ .flat();
- sendBulkAddMutation({
- variables: { itemId: item.id, entries, supportSecret },
- })
- .then(onBulkAddComplete)
- .catch((e) => {
- /* Handled in UI */
- });
- }}
- >
- Save {bulkAddProposalData.layersToAdd.length} changes
-
-
- )}
-
- {itemAppearances.map((itemAppearance) => (
-
-
-
- ))}
-
-
- );
+ sendBulkAddMutation({
+ variables: { itemId: item.id, entries, supportSecret },
+ })
+ .then(onBulkAddComplete)
+ .catch((e) => {
+ /* Handled in UI */
+ });
+ }}
+ >
+ Save {bulkAddProposalData.layersToAdd.length} changes
+
+
+ )}
+
+ {itemAppearances.map((itemAppearance) => (
+
+
+
+ ))}
+
+
+ );
}
function ItemAppearanceCard({ item, itemAppearance }) {
- const petAppearance = itemAppearance.body.canonicalAppearance;
- const biologyLayers = petAppearance.layers;
- const itemLayers = [...itemAppearance.layers].sort(
- (a, b) => a.zone.depth - b.zone.depth,
- );
+ const petAppearance = itemAppearance.body.canonicalAppearance;
+ const biologyLayers = petAppearance.layers;
+ const itemLayers = [...itemAppearance.layers].sort(
+ (a, b) => a.zone.depth - b.zone.depth,
+ );
- const { brightBackground } = useCommonStyles();
+ const { brightBackground } = useCommonStyles();
- return (
-
-
- {getBodyName(itemAppearance.body)}
-
-
-
- {itemLayers.length === 0 && (
-
-
- (No data)
-
-
- )}
- {itemLayers.map((itemLayer) => (
-
-
-
- ))}
-
-
- );
+ return (
+
+
+ {getBodyName(itemAppearance.body)}
+
+
+
+ {itemLayers.length === 0 && (
+
+
+ (No data)
+
+
+ )}
+ {itemLayers.map((itemLayer) => (
+
+
+
+ ))}
+
+
+ );
}
function getSortKeyForBody(body) {
- // "All bodies" sorts first!
- if (body.representsAllBodies) {
- return "";
- }
+ // "All bodies" sorts first!
+ if (body.representsAllBodies) {
+ return "";
+ }
- const { color, species } = body.canonicalAppearance;
- // Sort standard colors first, then special colors by name, then by species
- // within each color.
- return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
+ const { color, species } = body.canonicalAppearance;
+ // Sort standard colors first, then special colors by name, then by species
+ // within each color.
+ return `${color.isStandard ? "A" : "Z"}-${color.name}-${species.name}`;
}
function getBodyName(body) {
- if (body.representsAllBodies) {
- return "All bodies";
- }
+ if (body.representsAllBodies) {
+ return "All bodies";
+ }
- const { species, color } = body.canonicalAppearance;
- const speciesName = capitalize(species.name);
- const colorName = color.isStandard ? "Standard" : capitalize(color.name);
- return `${colorName} ${speciesName}`;
+ const { species, color } = body.canonicalAppearance;
+ const speciesName = capitalize(species.name);
+ const colorName = color.isStandard ? "Standard" : capitalize(color.name);
+ return `${colorName} ${speciesName}`;
}
function capitalize(str) {
- return str[0].toUpperCase() + str.slice(1);
+ return str[0].toUpperCase() + str.slice(1);
}
function mergeBulkAddProposalIntoItemAppearances(
- itemAppearances,
- bulkAddProposal,
- bulkAddProposalData,
+ itemAppearances,
+ bulkAddProposal,
+ bulkAddProposalData,
) {
- if (!bulkAddProposalData) {
- return itemAppearances;
- }
+ if (!bulkAddProposalData) {
+ return itemAppearances;
+ }
- const { color, layersToAdd } = bulkAddProposalData;
+ const { color, layersToAdd } = bulkAddProposalData;
- // Do a deep copy of the existing item appearances, so we can mutate them as
- // we loop through them in this function!
- const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances));
+ // Do a deep copy of the existing item appearances, so we can mutate them as
+ // we loop through them in this function!
+ const mergedItemAppearances = JSON.parse(JSON.stringify(itemAppearances));
- // To exclude Vandagyre, we take the first N species by ID - which is
- // different than the alphabetical sort order we use for assigning layers!
- const speciesColorPairsToInclude = [...color.appliedToAllCompatibleSpecies]
- .sort((a, b) => Number(a.species.id) - Number(b.species.id))
- .slice(0, bulkAddProposal.numSpecies);
+ // To exclude Vandagyre, we take the first N species by ID - which is
+ // different than the alphabetical sort order we use for assigning layers!
+ const speciesColorPairsToInclude = [...color.appliedToAllCompatibleSpecies]
+ .sort((a, b) => Number(a.species.id) - Number(b.species.id))
+ .slice(0, bulkAddProposal.numSpecies);
- // Set up the incoming data in convenient formats.
- const sortedSpeciesColorPairs = [...speciesColorPairsToInclude].sort((a, b) =>
- a.species.name.localeCompare(b.species.name),
- );
- const layersToAddByRemoteId = {};
- for (const layer of layersToAdd) {
- layersToAddByRemoteId[layer.remoteId] = layer;
- }
+ // Set up the incoming data in convenient formats.
+ const sortedSpeciesColorPairs = [...speciesColorPairsToInclude].sort((a, b) =>
+ a.species.name.localeCompare(b.species.name),
+ );
+ const layersToAddByRemoteId = {};
+ for (const layer of layersToAdd) {
+ layersToAddByRemoteId[layer.remoteId] = layer;
+ }
- for (const [index, speciesColorPair] of sortedSpeciesColorPairs.entries()) {
- const { body, canonicalAppearance } = speciesColorPair;
+ for (const [index, speciesColorPair] of sortedSpeciesColorPairs.entries()) {
+ const { body, canonicalAppearance } = speciesColorPair;
- // Find the existing item appearance to add to, or create a new one if it
- // doesn't exist yet.
- let itemAppearance = mergedItemAppearances.find(
- (a) => a.body.id === body.id && !a.body.representsAllBodies,
- );
- if (!itemAppearance) {
- itemAppearance = {
- id: `bulk-add-proposal-new-item-appearance-for-body-${body.id}`,
- layers: [],
- body: {
- id: body.id,
- canonicalAppearance,
- },
- };
- mergedItemAppearances.push(itemAppearance);
- }
+ // Find the existing item appearance to add to, or create a new one if it
+ // doesn't exist yet.
+ let itemAppearance = mergedItemAppearances.find(
+ (a) => a.body.id === body.id && !a.body.representsAllBodies,
+ );
+ if (!itemAppearance) {
+ itemAppearance = {
+ id: `bulk-add-proposal-new-item-appearance-for-body-${body.id}`,
+ layers: [],
+ body: {
+ id: body.id,
+ canonicalAppearance,
+ },
+ };
+ mergedItemAppearances.push(itemAppearance);
+ }
- const layerToAddRemoteId = String(
- Number(bulkAddProposal.minAssetId) +
- index * bulkAddProposal.assetIdStepValue,
- );
- const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId];
- if (!layerToAdd) {
- continue;
- }
+ const layerToAddRemoteId = String(
+ Number(bulkAddProposal.minAssetId) +
+ index * bulkAddProposal.assetIdStepValue,
+ );
+ const layerToAdd = layersToAddByRemoteId[layerToAddRemoteId];
+ if (!layerToAdd) {
+ continue;
+ }
- // Delete this layer from other appearances (because we're going to
- // override its body ID), then add it to this new one.
- for (const otherItemAppearance of mergedItemAppearances) {
- const indexToDelete = otherItemAppearance.layers.findIndex(
- (l) => l.remoteId === layerToAddRemoteId,
- );
- if (indexToDelete >= 0) {
- otherItemAppearance.layers.splice(indexToDelete, 1);
- }
- }
- itemAppearance.layers.push(layerToAdd);
- }
+ // Delete this layer from other appearances (because we're going to
+ // override its body ID), then add it to this new one.
+ for (const otherItemAppearance of mergedItemAppearances) {
+ const indexToDelete = otherItemAppearance.layers.findIndex(
+ (l) => l.remoteId === layerToAddRemoteId,
+ );
+ if (indexToDelete >= 0) {
+ otherItemAppearance.layers.splice(indexToDelete, 1);
+ }
+ }
+ itemAppearance.layers.push(layerToAdd);
+ }
- return mergedItemAppearances;
+ return mergedItemAppearances;
}
export default AllItemLayersSupportModal;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js
index 2032ff04..ed133d6f 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportModal.js
@@ -2,28 +2,28 @@ import * as React from "react";
import gql from "graphql-tag";
import { useMutation } from "@apollo/client";
import {
- Button,
- Box,
- FormControl,
- FormErrorMessage,
- FormHelperText,
- FormLabel,
- HStack,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalFooter,
- ModalHeader,
- ModalOverlay,
- Radio,
- RadioGroup,
- Spinner,
- useDisclosure,
- useToast,
- CheckboxGroup,
- VStack,
- Checkbox,
+ Button,
+ Box,
+ FormControl,
+ FormErrorMessage,
+ FormHelperText,
+ FormLabel,
+ HStack,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Radio,
+ RadioGroup,
+ Spinner,
+ useDisclosure,
+ useToast,
+ CheckboxGroup,
+ VStack,
+ Checkbox,
} from "@chakra-ui/react";
import { ChevronRightIcon, ExternalLinkIcon } from "@chakra-ui/icons";
@@ -32,7 +32,7 @@ import Metadata, { MetadataLabel, MetadataValue } from "./Metadata";
import { OutfitLayers } from "../../components/OutfitPreview";
import SpeciesColorPicker from "../../components/SpeciesColorPicker";
import useOutfitAppearance, {
- itemAppearanceFragment,
+ itemAppearanceFragment,
} from "../../components/useOutfitAppearance";
import useSupport from "./useSupport";
@@ -41,604 +41,604 @@ import useSupport from "./useSupport";
* appearance layer. Open it by clicking a layer from ItemSupportDrawer.
*/
function AppearanceLayerSupportModal({
- item, // Specify this or `petAppearance`
- petAppearance, // Specify this or `item`
- layer,
- outfitState, // speciesId, colorId, pose
- isOpen,
- onClose,
+ item, // Specify this or `petAppearance`
+ petAppearance, // Specify this or `item`
+ layer,
+ outfitState, // speciesId, colorId, pose
+ isOpen,
+ onClose,
}) {
- const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId);
- const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState(
- layer.knownGlitches,
- );
+ const [selectedBodyId, setSelectedBodyId] = React.useState(layer.bodyId);
+ const [selectedKnownGlitches, setSelectedKnownGlitches] = React.useState(
+ layer.knownGlitches,
+ );
- const [previewBiology, setPreviewBiology] = React.useState({
- speciesId: outfitState.speciesId,
- colorId: outfitState.colorId,
- pose: outfitState.pose,
- isValid: true,
- });
- const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false);
- const { supportSecret } = useSupport();
- const toast = useToast();
+ const [previewBiology, setPreviewBiology] = React.useState({
+ speciesId: outfitState.speciesId,
+ colorId: outfitState.colorId,
+ pose: outfitState.pose,
+ isValid: true,
+ });
+ const [uploadModalIsOpen, setUploadModalIsOpen] = React.useState(false);
+ const { supportSecret } = useSupport();
+ const toast = useToast();
- const parentName = item
- ? item.name
- : `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`;
+ const parentName = item
+ ? item.name
+ : `${petAppearance.color.name} ${petAppearance.species.name} ${petAppearance.id}`;
- const [mutate, { loading: mutationLoading, error: mutationError }] =
- useMutation(
- gql`
- mutation ApperanceLayerSupportSetLayerBodyId(
- $layerId: ID!
- $bodyId: ID!
- $knownGlitches: [AppearanceLayerKnownGlitch!]!
- $supportSecret: String!
- $outfitSpeciesId: ID!
- $outfitColorId: ID!
- $formPreviewSpeciesId: ID!
- $formPreviewColorId: ID!
- ) {
- setLayerBodyId(
- layerId: $layerId
- bodyId: $bodyId
- supportSecret: $supportSecret
- ) {
- # This mutation returns the affected AppearanceLayer. Fetch the
- # updated fields, including the appearance on the outfit pet and the
- # form preview pet, to automatically update our cached appearance in
- # the rest of the app. That means you should be able to see your
- # changes immediately!
- id
- bodyId
- item {
- id
- appearanceOnOutfit: appearanceOn(
- speciesId: $outfitSpeciesId
- colorId: $outfitColorId
- ) {
- ...ItemAppearanceForOutfitPreview
- }
+ const [mutate, { loading: mutationLoading, error: mutationError }] =
+ useMutation(
+ gql`
+ mutation ApperanceLayerSupportSetLayerBodyId(
+ $layerId: ID!
+ $bodyId: ID!
+ $knownGlitches: [AppearanceLayerKnownGlitch!]!
+ $supportSecret: String!
+ $outfitSpeciesId: ID!
+ $outfitColorId: ID!
+ $formPreviewSpeciesId: ID!
+ $formPreviewColorId: ID!
+ ) {
+ setLayerBodyId(
+ layerId: $layerId
+ bodyId: $bodyId
+ supportSecret: $supportSecret
+ ) {
+ # This mutation returns the affected AppearanceLayer. Fetch the
+ # updated fields, including the appearance on the outfit pet and the
+ # form preview pet, to automatically update our cached appearance in
+ # the rest of the app. That means you should be able to see your
+ # changes immediately!
+ id
+ bodyId
+ item {
+ id
+ appearanceOnOutfit: appearanceOn(
+ speciesId: $outfitSpeciesId
+ colorId: $outfitColorId
+ ) {
+ ...ItemAppearanceForOutfitPreview
+ }
- appearanceOnFormPreviewPet: appearanceOn(
- speciesId: $formPreviewSpeciesId
- colorId: $formPreviewColorId
- ) {
- ...ItemAppearanceForOutfitPreview
- }
- }
- }
+ appearanceOnFormPreviewPet: appearanceOn(
+ speciesId: $formPreviewSpeciesId
+ colorId: $formPreviewColorId
+ ) {
+ ...ItemAppearanceForOutfitPreview
+ }
+ }
+ }
- setLayerKnownGlitches(
- layerId: $layerId
- knownGlitches: $knownGlitches
- supportSecret: $supportSecret
- ) {
- id
- knownGlitches
- svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT
- }
- }
- ${itemAppearanceFragment}
- `,
- {
- variables: {
- layerId: layer.id,
- bodyId: selectedBodyId,
- knownGlitches: selectedKnownGlitches,
- supportSecret,
- outfitSpeciesId: outfitState.speciesId,
- outfitColorId: outfitState.colorId,
- formPreviewSpeciesId: previewBiology.speciesId,
- formPreviewColorId: previewBiology.colorId,
- },
- onCompleted: () => {
- onClose();
- toast({
- status: "success",
- title: `Saved layer ${layer.id}: ${parentName}`,
- });
- },
- },
- );
+ setLayerKnownGlitches(
+ layerId: $layerId
+ knownGlitches: $knownGlitches
+ supportSecret: $supportSecret
+ ) {
+ id
+ knownGlitches
+ svgUrl # Affected by OFFICIAL_SVG_IS_INCORRECT
+ }
+ }
+ ${itemAppearanceFragment}
+ `,
+ {
+ variables: {
+ layerId: layer.id,
+ bodyId: selectedBodyId,
+ knownGlitches: selectedKnownGlitches,
+ supportSecret,
+ outfitSpeciesId: outfitState.speciesId,
+ outfitColorId: outfitState.colorId,
+ formPreviewSpeciesId: previewBiology.speciesId,
+ formPreviewColorId: previewBiology.colorId,
+ },
+ onCompleted: () => {
+ onClose();
+ toast({
+ status: "success",
+ title: `Saved layer ${layer.id}: ${parentName}`,
+ });
+ },
+ },
+ );
- // TODO: Would be nicer to just learn the correct URL from the server, but we
- // don't happen to be saving it, and it would be extra stuff to put on
- // the GraphQL request for non-Support users. We could also just try
- // loading them, but, ehhh…
- const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls(
- layer.swfUrl,
- );
+ // TODO: Would be nicer to just learn the correct URL from the server, but we
+ // don't happen to be saving it, and it would be extra stuff to put on
+ // the GraphQL request for non-Support users. We could also just try
+ // loading them, but, ehhh…
+ const [newManifestUrl, oldManifestUrl] = convertSwfUrlToPossibleManifestUrls(
+ layer.swfUrl,
+ );
- return (
-
-
-
-
- Layer {layer.id}: {parentName}
-
-
-
-
- DTI ID:
- {layer.id}
- Neopets ID:
- {layer.remoteId}
- Zone:
-
- {layer.zone.label} ({layer.zone.id})
-
- Assets:
-
-
-
- Manifest (new)
-
-
- Manifest (old)
-
-
-
- {layer.canvasMovieLibraryUrl ? (
-
- Movie
-
- ) : (
-
- No Movie
-
- )}
- {layer.svgUrl ? (
-
- SVG
-
- ) : (
-
- No SVG
-
- )}
- {layer.imageUrl ? (
-
- PNG
-
- ) : (
-
- No PNG
-
- )}
-
- SWF
-
-
- {item && (
- <>
- setUploadModalIsOpen(true)}
- >
- Upload PNG
-
- setUploadModalIsOpen(false)}
- />
- >
- )}
-
-
-
-
- {item && (
- <>
-
-
- >
- )}
-
-
-
- {item && (
-
- )}
-
- {mutationError && (
-
- {mutationError.message}
-
- )}
-
- mutate().catch((e) => {
- /* Discard errors here; we'll show them in the UI! */
- })
- }
- flex="0 0 auto"
- >
- Save changes
-
-
-
-
-
- );
+ return (
+
+
+
+
+ Layer {layer.id}: {parentName}
+
+
+
+
+ DTI ID:
+ {layer.id}
+ Neopets ID:
+ {layer.remoteId}
+ Zone:
+
+ {layer.zone.label} ({layer.zone.id})
+
+ Assets:
+
+
+
+ Manifest (new)
+
+
+ Manifest (old)
+
+
+
+ {layer.canvasMovieLibraryUrl ? (
+
+ Movie
+
+ ) : (
+
+ No Movie
+
+ )}
+ {layer.svgUrl ? (
+
+ SVG
+
+ ) : (
+
+ No SVG
+
+ )}
+ {layer.imageUrl ? (
+
+ PNG
+
+ ) : (
+
+ No PNG
+
+ )}
+
+ SWF
+
+
+ {item && (
+ <>
+ setUploadModalIsOpen(true)}
+ >
+ Upload PNG
+
+ setUploadModalIsOpen(false)}
+ />
+ >
+ )}
+
+
+
+
+ {item && (
+ <>
+
+
+ >
+ )}
+
+
+
+ {item && (
+
+ )}
+
+ {mutationError && (
+
+ {mutationError.message}
+
+ )}
+
+ mutate().catch((e) => {
+ /* Discard errors here; we'll show them in the UI! */
+ })
+ }
+ flex="0 0 auto"
+ >
+ Save changes
+
+
+
+
+
+ );
}
function AppearanceLayerSupportPetCompatibilityFields({
- item,
- layer,
- outfitState,
- selectedBodyId,
- previewBiology,
- onChangeBodyId,
- onChangePreviewBiology,
+ item,
+ layer,
+ outfitState,
+ selectedBodyId,
+ previewBiology,
+ onChangeBodyId,
+ onChangePreviewBiology,
}) {
- const [selectedBiology, setSelectedBiology] = React.useState(previewBiology);
+ const [selectedBiology, setSelectedBiology] = React.useState(previewBiology);
- const {
- loading,
- error,
- visibleLayers,
- bodyId: appearanceBodyId,
- } = useOutfitAppearance({
- speciesId: previewBiology.speciesId,
- colorId: previewBiology.colorId,
- pose: previewBiology.pose,
- wornItemIds: [item.id],
- });
+ const {
+ loading,
+ error,
+ visibleLayers,
+ bodyId: appearanceBodyId,
+ } = useOutfitAppearance({
+ speciesId: previewBiology.speciesId,
+ colorId: previewBiology.colorId,
+ pose: previewBiology.pose,
+ wornItemIds: [item.id],
+ });
- const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
+ const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
- // After we touch a species/color selector and null out `bodyId`, when the
- // appearance body ID loads in, select it as the new body ID.
- //
- // This might move the radio button away from "all pets", but I think that's
- // a _less_ surprising experience: if you're touching the pickers, then
- // that's probably where you head is.
- React.useEffect(() => {
- if (selectedBodyId == null && appearanceBodyId != null) {
- onChangeBodyId(appearanceBodyId);
- }
- }, [selectedBodyId, appearanceBodyId, onChangeBodyId]);
+ // After we touch a species/color selector and null out `bodyId`, when the
+ // appearance body ID loads in, select it as the new body ID.
+ //
+ // This might move the radio button away from "all pets", but I think that's
+ // a _less_ surprising experience: if you're touching the pickers, then
+ // that's probably where you head is.
+ React.useEffect(() => {
+ if (selectedBodyId == null && appearanceBodyId != null) {
+ onChangeBodyId(appearanceBodyId);
+ }
+ }, [selectedBodyId, appearanceBodyId, onChangeBodyId]);
- return (
-
- Pet compatibility
- onChangeBodyId(newBodyId)}
- marginBottom="4"
- >
-
- Fits all pets{" "}
-
- (Body ID: 0)
-
-
-
- Fits all pets with the same body as:{" "}
-
- (Body ID:{" "}
- {appearanceBodyId == null ? (
-
- ) : (
- appearanceBodyId
- )}
- )
-
-
-
-
-
-
-
- {
- const speciesId = species.id;
- const colorId = color.id;
+ return (
+
+ Pet compatibility
+ onChangeBodyId(newBodyId)}
+ marginBottom="4"
+ >
+
+ Fits all pets{" "}
+
+ (Body ID: 0)
+
+
+
+ Fits all pets with the same body as:{" "}
+
+ (Body ID:{" "}
+ {appearanceBodyId == null ? (
+
+ ) : (
+ appearanceBodyId
+ )}
+ )
+
+
+
+
+
+
+
+ {
+ const speciesId = species.id;
+ const colorId = color.id;
- setSelectedBiology({ speciesId, colorId, isValid, pose });
- if (isValid) {
- onChangePreviewBiology({ speciesId, colorId, isValid, pose });
+ setSelectedBiology({ speciesId, colorId, isValid, pose });
+ if (isValid) {
+ onChangePreviewBiology({ speciesId, colorId, isValid, pose });
- // Also temporarily null out the body ID. We'll switch to the new
- // body ID once it's loaded.
- onChangeBodyId(null);
- }
- }}
- />
-
- {!error && (
-
- If it doesn't look right, try some other options until it does!
-
- )}
- {error && {error.message}}
-
-
- );
+ // Also temporarily null out the body ID. We'll switch to the new
+ // body ID once it's loaded.
+ onChangeBodyId(null);
+ }
+ }}
+ />
+
+ {!error && (
+
+ If it doesn't look right, try some other options until it does!
+
+ )}
+ {error && {error.message}}
+
+
+ );
}
function AppearanceLayerSupportKnownGlitchesFields({
- selectedKnownGlitches,
- onChange,
+ selectedKnownGlitches,
+ onChange,
}) {
- return (
-
- Known glitches
-
-
-
- Official SWF is incorrect{" "}
-
- (Will display a message)
-
-
-
- Official SVG is incorrect{" "}
-
- (Will use the PNG instead)
-
-
-
- Official Movie is incorrect{" "}
-
- (Will display a message)
-
-
-
- Displays incorrectly, but cause unknown{" "}
-
- (Will display a vague message)
-
-
-
- Fits all pets on-site, but should not{" "}
-
- (TNT's fault. Will show a message, and keep the compatibility
- settings above.)
-
-
-
- Only fits pets with other body-specific assets{" "}
-
- (DTI's fault: bodyId=0 is a lie! Will mark incompatible for some
- pets.)
-
-
-
-
-
- );
+ return (
+
+ Known glitches
+
+
+
+ Official SWF is incorrect{" "}
+
+ (Will display a message)
+
+
+
+ Official SVG is incorrect{" "}
+
+ (Will use the PNG instead)
+
+
+
+ Official Movie is incorrect{" "}
+
+ (Will display a message)
+
+
+
+ Displays incorrectly, but cause unknown{" "}
+
+ (Will display a vague message)
+
+
+
+ Fits all pets on-site, but should not{" "}
+
+ (TNT's fault. Will show a message, and keep the compatibility
+ settings above.)
+
+
+
+ Only fits pets with other body-specific assets{" "}
+
+ (DTI's fault: bodyId=0 is a lie! Will mark incompatible for some
+ pets.)
+
+
+
+
+
+ );
}
function AppearanceLayerSupportModalRemoveButton({
- item,
- layer,
- outfitState,
- onRemoveSuccess,
+ item,
+ layer,
+ outfitState,
+ onRemoveSuccess,
}) {
- const { isOpen, onOpen, onClose } = useDisclosure();
- const toast = useToast();
- const { supportSecret } = useSupport();
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ const toast = useToast();
+ const { supportSecret } = useSupport();
- const [mutate, { loading, error }] = useMutation(
- gql`
- mutation AppearanceLayerSupportRemoveButton(
- $layerId: ID!
- $itemId: ID!
- $outfitSpeciesId: ID!
- $outfitColorId: ID!
- $supportSecret: String!
- ) {
- removeLayerFromItem(
- layerId: $layerId
- itemId: $itemId
- supportSecret: $supportSecret
- ) {
- # This mutation returns the affected layer, and the affected item.
- # Fetch the updated appearance for the current outfit, which should
- # no longer include this layer. This means you should be able to see
- # your changes immediately!
- item {
- id
- appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) {
- ...ItemAppearanceForOutfitPreview
- }
- }
+ const [mutate, { loading, error }] = useMutation(
+ gql`
+ mutation AppearanceLayerSupportRemoveButton(
+ $layerId: ID!
+ $itemId: ID!
+ $outfitSpeciesId: ID!
+ $outfitColorId: ID!
+ $supportSecret: String!
+ ) {
+ removeLayerFromItem(
+ layerId: $layerId
+ itemId: $itemId
+ supportSecret: $supportSecret
+ ) {
+ # This mutation returns the affected layer, and the affected item.
+ # Fetch the updated appearance for the current outfit, which should
+ # no longer include this layer. This means you should be able to see
+ # your changes immediately!
+ item {
+ id
+ appearanceOn(speciesId: $outfitSpeciesId, colorId: $outfitColorId) {
+ ...ItemAppearanceForOutfitPreview
+ }
+ }
- # The layer's item should be null now, fetch to confirm and update!
- layer {
- id
- item {
- id
- }
- }
- }
- }
- ${itemAppearanceFragment}
- `,
- {
- variables: {
- layerId: layer.id,
- itemId: item.id,
- outfitSpeciesId: outfitState.speciesId,
- outfitColorId: outfitState.colorId,
- supportSecret,
- },
- onCompleted: () => {
- onClose();
- onRemoveSuccess();
- toast({
- status: "success",
- title: `Removed layer ${layer.id} from ${item.name}`,
- });
- },
- },
- );
+ # The layer's item should be null now, fetch to confirm and update!
+ layer {
+ id
+ item {
+ id
+ }
+ }
+ }
+ }
+ ${itemAppearanceFragment}
+ `,
+ {
+ variables: {
+ layerId: layer.id,
+ itemId: item.id,
+ outfitSpeciesId: outfitState.speciesId,
+ outfitColorId: outfitState.colorId,
+ supportSecret,
+ },
+ onCompleted: () => {
+ onClose();
+ onRemoveSuccess();
+ toast({
+ status: "success",
+ title: `Removed layer ${layer.id} from ${item.name}`,
+ });
+ },
+ },
+ );
- return (
- <>
-
- Remove
-
-
-
-
-
-
- Remove Layer {layer.id} ({layer.zone.label}) from {item.name}?
-
-
-
- This will permanently-ish remove Layer {layer.id} (
- {layer.zone.label}) from this item.
-
-
- If you remove a correct layer by mistake, re-modeling should fix
- it, or Matchu can restore it if you write down the layer ID
- before proceeding!
-
-
- Are you sure you want to remove Layer {layer.id} from this item?
-
-
-
-
- Close
-
-
- {error && (
-
- {error.message}
-
- )}
-
- mutate().catch((e) => {
- /* Discard errors here; we'll show them in the UI! */
- })
- }
- isLoading={loading}
- >
- Yes, remove permanently
-
-
-
-
-
- >
- );
+ return (
+ <>
+
+ Remove
+
+
+
+
+
+
+ Remove Layer {layer.id} ({layer.zone.label}) from {item.name}?
+
+
+
+ This will permanently-ish remove Layer {layer.id} (
+ {layer.zone.label}) from this item.
+
+
+ If you remove a correct layer by mistake, re-modeling should fix
+ it, or Matchu can restore it if you write down the layer ID
+ before proceeding!
+
+
+ Are you sure you want to remove Layer {layer.id} from this item?
+
+
+
+
+ Close
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+ mutate().catch((e) => {
+ /* Discard errors here; we'll show them in the UI! */
+ })
+ }
+ isLoading={loading}
+ >
+ Yes, remove permanently
+
+
+
+
+
+ >
+ );
}
const SWF_URL_PATTERN =
- /^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
+ /^https?:\/\/images\.neopets\.com\/cp\/(bio|items)\/swf\/(.+?)_([a-z0-9]+)\.swf$/;
function convertSwfUrlToPossibleManifestUrls(swfUrl) {
- const match = new URL(swfUrl, "https://images.neopets.com")
- .toString()
- .match(SWF_URL_PATTERN);
- if (!match) {
- throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
- }
+ const match = new URL(swfUrl, "https://images.neopets.com")
+ .toString()
+ .match(SWF_URL_PATTERN);
+ if (!match) {
+ throw new Error(`unexpected SWF URL format: ${JSON.stringify(swfUrl)}`);
+ }
- const type = match[1];
- const folders = match[2];
- const hash = match[3];
+ const type = match[1];
+ const folders = match[2];
+ const hash = match[3];
- // TODO: There are a few potential manifest URLs in play! Long-term, we
- // should get this from modeling data. But these are some good guesses!
- return [
- `https://images.neopets.com/cp/${type}/data/${folders}/manifest.json`,
- `https://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`,
- ];
+ // TODO: There are a few potential manifest URLs in play! Long-term, we
+ // should get this from modeling data. But these are some good guesses!
+ return [
+ `https://images.neopets.com/cp/${type}/data/${folders}/manifest.json`,
+ `https://images.neopets.com/cp/${type}/data/${folders}_${hash}/manifest.json`,
+ ];
}
export default AppearanceLayerSupportModal;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js
index 02d1aa6e..ae9bfafe 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/AppearanceLayerSupportUploadModal.js
@@ -1,17 +1,17 @@
import * as React from "react";
import { useApolloClient } from "@apollo/client";
import {
- Button,
- Box,
- Modal,
- ModalBody,
- ModalCloseButton,
- ModalContent,
- ModalFooter,
- ModalHeader,
- ModalOverlay,
- Select,
- useToast,
+ Button,
+ Box,
+ Modal,
+ ModalBody,
+ ModalCloseButton,
+ ModalContent,
+ ModalFooter,
+ ModalHeader,
+ ModalOverlay,
+ Select,
+ useToast,
} from "@chakra-ui/react";
import { ExternalLinkIcon } from "@chakra-ui/icons";
@@ -24,529 +24,529 @@ import useSupport from "./useSupport";
* e.g. the SWF uses a color filter our server-side Flash player can't support!
*/
function AppearanceLayerSupportUploadModal({ item, layer, isOpen, onClose }) {
- const [step, setStep] = React.useState(1);
- const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
- const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);
+ const [step, setStep] = React.useState(1);
+ const [imageOnBlackUrl, setImageOnBlackUrl] = React.useState(null);
+ const [imageOnWhiteUrl, setImageOnWhiteUrl] = React.useState(null);
- const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
- const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
- const [numWarnings, setNumWarnings] = React.useState(null);
+ const [imageWithAlphaUrl, setImageWithAlphaUrl] = React.useState(null);
+ const [imageWithAlphaBlob, setImageWithAlphaBlob] = React.useState(null);
+ const [numWarnings, setNumWarnings] = React.useState(null);
- const [isUploading, setIsUploading] = React.useState(false);
- const [uploadError, setUploadError] = React.useState(null);
+ const [isUploading, setIsUploading] = React.useState(false);
+ const [uploadError, setUploadError] = React.useState(null);
- const [conflictMode, setConflictMode] = React.useState("onBlack");
+ const [conflictMode, setConflictMode] = React.useState("onBlack");
- const { supportSecret } = useSupport();
- const toast = useToast();
- const apolloClient = useApolloClient();
+ const { supportSecret } = useSupport();
+ const toast = useToast();
+ const apolloClient = useApolloClient();
- // Once both images are ready, merge them!
- React.useEffect(() => {
- if (!imageOnBlackUrl || !imageOnWhiteUrl) {
- return;
- }
+ // Once both images are ready, merge them!
+ React.useEffect(() => {
+ if (!imageOnBlackUrl || !imageOnWhiteUrl) {
+ return;
+ }
- setImageWithAlphaUrl(null);
- setNumWarnings(null);
- setIsUploading(false);
+ setImageWithAlphaUrl(null);
+ setNumWarnings(null);
+ setIsUploading(false);
- mergeIntoImageWithAlpha(
- imageOnBlackUrl,
- imageOnWhiteUrl,
- conflictMode,
- ).then(([url, blob, numWarnings]) => {
- setImageWithAlphaUrl(url);
- setImageWithAlphaBlob(blob);
- setNumWarnings(numWarnings);
- });
- }, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);
+ mergeIntoImageWithAlpha(
+ imageOnBlackUrl,
+ imageOnWhiteUrl,
+ conflictMode,
+ ).then(([url, blob, numWarnings]) => {
+ setImageWithAlphaUrl(url);
+ setImageWithAlphaBlob(blob);
+ setNumWarnings(numWarnings);
+ });
+ }, [imageOnBlackUrl, imageOnWhiteUrl, conflictMode]);
- const onUpload = React.useCallback(
- (e) => {
- const file = e.target.files[0];
- if (!file) {
- return;
- }
+ const onUpload = React.useCallback(
+ (e) => {
+ const file = e.target.files[0];
+ if (!file) {
+ return;
+ }
- const reader = new FileReader();
- reader.onload = (re) => {
- switch (step) {
- case 1:
- setImageOnBlackUrl(re.target.result);
- setStep(2);
- return;
- case 2:
- setImageOnWhiteUrl(re.target.result);
- setStep(3);
- return;
- default:
- throw new Error(`unexpected step ${step}`);
- }
- };
- reader.readAsDataURL(file);
- },
- [step],
- );
+ const reader = new FileReader();
+ reader.onload = (re) => {
+ switch (step) {
+ case 1:
+ setImageOnBlackUrl(re.target.result);
+ setStep(2);
+ return;
+ case 2:
+ setImageOnWhiteUrl(re.target.result);
+ setStep(3);
+ return;
+ default:
+ throw new Error(`unexpected step ${step}`);
+ }
+ };
+ reader.readAsDataURL(file);
+ },
+ [step],
+ );
- const onSubmitFinalImage = React.useCallback(async () => {
- setIsUploading(true);
- setUploadError(null);
- try {
- const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
- method: "POST",
- headers: {
- "DTI-Support-Secret": supportSecret,
- },
- body: imageWithAlphaBlob,
- });
+ const onSubmitFinalImage = React.useCallback(async () => {
+ setIsUploading(true);
+ setUploadError(null);
+ try {
+ const res = await fetch(`/api/uploadLayerImage?layerId=${layer.id}`, {
+ method: "POST",
+ headers: {
+ "DTI-Support-Secret": supportSecret,
+ },
+ body: imageWithAlphaBlob,
+ });
- if (!res.ok) {
- setIsUploading(false);
- setUploadError(
- new Error(`Network error: ${res.status} ${res.statusText}`),
- );
- return;
- }
+ if (!res.ok) {
+ setIsUploading(false);
+ setUploadError(
+ new Error(`Network error: ${res.status} ${res.statusText}`),
+ );
+ return;
+ }
- setIsUploading(false);
- onClose();
- toast({
- status: "success",
- title: "Image successfully uploaded",
- description: "It might take a few seconds to update in the app!",
- });
+ setIsUploading(false);
+ onClose();
+ toast({
+ status: "success",
+ title: "Image successfully uploaded",
+ description: "It might take a few seconds to update in the app!",
+ });
- // NOTE: I tried to do this as a cache update, but I couldn't ever get
- // the fragment with size parameters to work :/ (Other fields would
- // update, but not these!) Ultimately the eviction is the only
- // reliable method I found :/
- apolloClient.cache.evict({
- id: `AppearanceLayer:${layer.id}`,
- fieldName: "imageUrl",
- });
- apolloClient.cache.evict({
- id: `AppearanceLayer:${layer.id}`,
- fieldName: "imageUrlV2",
- });
- } catch (e) {
- setIsUploading(false);
- setUploadError(e);
- }
- }, [
- imageWithAlphaBlob,
- supportSecret,
- layer.id,
- toast,
- onClose,
- apolloClient.cache,
- ]);
+ // NOTE: I tried to do this as a cache update, but I couldn't ever get
+ // the fragment with size parameters to work :/ (Other fields would
+ // update, but not these!) Ultimately the eviction is the only
+ // reliable method I found :/
+ apolloClient.cache.evict({
+ id: `AppearanceLayer:${layer.id}`,
+ fieldName: "imageUrl",
+ });
+ apolloClient.cache.evict({
+ id: `AppearanceLayer:${layer.id}`,
+ fieldName: "imageUrlV2",
+ });
+ } catch (e) {
+ setIsUploading(false);
+ setUploadError(e);
+ }
+ }, [
+ imageWithAlphaBlob,
+ supportSecret,
+ layer.id,
+ toast,
+ onClose,
+ apolloClient.cache,
+ ]);
- return (
-
-
-
-
- Upload PNG for {item.name}
-
-
-
- {(step === 1 || step === 2) && (
-
- )}
- {step === 3 && (
-
- )}
-
-
- setStep(1)}>
- Restart
-
-
- {uploadError && (
-
- {uploadError.message}
-
- )}
- Close
- {step === 3 && (
-
- Upload
-
- )}
-
-
-
-
- );
+ return (
+
+
+
+
+ Upload PNG for {item.name}
+
+
+
+ {(step === 1 || step === 2) && (
+
+ )}
+ {step === 3 && (
+
+ )}
+
+
+ setStep(1)}>
+ Restart
+
+
+ {uploadError && (
+
+ {uploadError.message}
+
+ )}
+ Close
+ {step === 3 && (
+
+ Upload
+
+ )}
+
+
+
+
+ );
}
function AppearanceLayerSupportScreenshotStep({ layer, step, onUpload }) {
- return (
- <>
-
- Step {step}: Take a screenshot of exactly the 600×600 Flash
- region, then upload it below.
-
- The border will turn green once the entire region is in view.
-
-
-
-
-
- Firefox help
-
-
- Chrome help
-
-
-
- >
- );
+ return (
+ <>
+
+ Step {step}: Take a screenshot of exactly the 600×600 Flash
+ region, then upload it below.
+
+ The border will turn green once the entire region is in view.
+
+
+
+
+
+ Firefox help
+
+
+ Chrome help
+
+
+
+ >
+ );
}
function AppearanceLayerSupportReviewStep({
- imageWithAlphaUrl,
- numWarnings,
- conflictMode,
- onChangeConflictMode,
+ imageWithAlphaUrl,
+ numWarnings,
+ conflictMode,
+ onChangeConflictMode,
}) {
- if (imageWithAlphaUrl == null) {
- return Generating image…;
- }
+ if (imageWithAlphaUrl == null) {
+ return Generating image…;
+ }
- const ratioBad = numWarnings / (600 * 600);
- const ratioGood = 1 - ratioBad;
+ const ratioBad = numWarnings / (600 * 600);
+ const ratioGood = 1 - ratioBad;
- return (
- <>
-
- Step 3: Does this look correct? If so, let's upload it!
-
-
- ({Math.floor(ratioGood * 10000) / 100}% match,{" "}
- {Math.floor(ratioBad * 10000) / 100}% mismatch.)
-
-
- {imageWithAlphaUrl && (
-
- )}
-
-
- {numWarnings > 0 && (
-
-
- When pixels conflict, we use…
-
-
-
- )}
-
- >
- );
+ return (
+ <>
+
+ Step 3: Does this look correct? If so, let's upload it!
+
+
+ ({Math.floor(ratioGood * 10000) / 100}% match,{" "}
+ {Math.floor(ratioBad * 10000) / 100}% mismatch.)
+
+
+ {imageWithAlphaUrl && (
+
+ )}
+
+
+ {numWarnings > 0 && (
+
+
+ When pixels conflict, we use…
+
+
+
+ )}
+
+ >
+ );
}
function AppearanceLayerSupportFlashPlayer({ swfUrl, backgroundColor }) {
- const [isVisible, setIsVisible] = React.useState(null);
- const regionRef = React.useRef(null);
+ const [isVisible, setIsVisible] = React.useState(null);
+ const regionRef = React.useRef(null);
- // We detect whether the entire SWF region is visible, because Flash only
- // bothers to render in visible places. So, screenshotting a SWF container
- // that isn't fully visible will fill the not-visible space with black,
- // instead of the actual SWF content. We change the border color to hint this
- // to the user!
- React.useLayoutEffect(() => {
- const region = regionRef.current;
- if (!region) {
- return;
- }
+ // We detect whether the entire SWF region is visible, because Flash only
+ // bothers to render in visible places. So, screenshotting a SWF container
+ // that isn't fully visible will fill the not-visible space with black,
+ // instead of the actual SWF content. We change the border color to hint this
+ // to the user!
+ React.useLayoutEffect(() => {
+ const region = regionRef.current;
+ if (!region) {
+ return;
+ }
- const scrollParent = region.closest(".chakra-modal__overlay");
- if (!scrollParent) {
- throw new Error(`could not find .chakra-modal__overlay scroll parent`);
- }
+ const scrollParent = region.closest(".chakra-modal__overlay");
+ if (!scrollParent) {
+ throw new Error(`could not find .chakra-modal__overlay scroll parent`);
+ }
- const onMountOrScrollOrResize = () => {
- const regionBox = region.getBoundingClientRect();
- const scrollParentBox = scrollParent.getBoundingClientRect();
- const isVisible =
- regionBox.left > scrollParentBox.left &&
- regionBox.right < scrollParentBox.right &&
- regionBox.top > scrollParentBox.top &&
- regionBox.bottom < scrollParentBox.bottom;
- setIsVisible(isVisible);
- };
+ const onMountOrScrollOrResize = () => {
+ const regionBox = region.getBoundingClientRect();
+ const scrollParentBox = scrollParent.getBoundingClientRect();
+ const isVisible =
+ regionBox.left > scrollParentBox.left &&
+ regionBox.right < scrollParentBox.right &&
+ regionBox.top > scrollParentBox.top &&
+ regionBox.bottom < scrollParentBox.bottom;
+ setIsVisible(isVisible);
+ };
- onMountOrScrollOrResize();
+ onMountOrScrollOrResize();
- scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
- window.addEventListener("resize", onMountOrScrollOrResize);
+ scrollParent.addEventListener("scroll", onMountOrScrollOrResize);
+ window.addEventListener("resize", onMountOrScrollOrResize);
- return () => {
- scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
- window.removeEventListener("resize", onMountOrScrollOrResize);
- };
- }, []);
+ return () => {
+ scrollParent.removeEventListener("scroll", onMountOrScrollOrResize);
+ window.removeEventListener("resize", onMountOrScrollOrResize);
+ };
+ }, []);
- let borderColor;
- if (isVisible === null) {
- borderColor = "gray.400";
- } else if (isVisible === false) {
- borderColor = "red.400";
- } else if (isVisible === true) {
- borderColor = "green.400";
- }
+ let borderColor;
+ if (isVisible === null) {
+ borderColor = "gray.400";
+ } else if (isVisible === false) {
+ borderColor = "red.400";
+ } else if (isVisible === true) {
+ borderColor = "green.400";
+ }
- return (
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+
+
+ );
}
async function mergeIntoImageWithAlpha(
- imageOnBlackUrl,
- imageOnWhiteUrl,
- conflictMode,
+ imageOnBlackUrl,
+ imageOnWhiteUrl,
+ conflictMode,
) {
- const [imageOnBlack, imageOnWhite] = await Promise.all([
- readImageDataFromUrl(imageOnBlackUrl),
- readImageDataFromUrl(imageOnWhiteUrl),
- ]);
+ const [imageOnBlack, imageOnWhite] = await Promise.all([
+ readImageDataFromUrl(imageOnBlackUrl),
+ readImageDataFromUrl(imageOnWhiteUrl),
+ ]);
- const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
- imageOnBlack,
- imageOnWhite,
- conflictMode,
- );
- const [imageWithAlphaUrl, imageWithAlphaBlob] =
- await writeImageDataToUrlAndBlob(imageWithAlphaData);
+ const [imageWithAlphaData, numWarnings] = mergeDataIntoImageWithAlpha(
+ imageOnBlack,
+ imageOnWhite,
+ conflictMode,
+ );
+ const [imageWithAlphaUrl, imageWithAlphaBlob] =
+ await writeImageDataToUrlAndBlob(imageWithAlphaData);
- return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
+ return [imageWithAlphaUrl, imageWithAlphaBlob, numWarnings];
}
function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
- const imageWithAlpha = new ImageData(600, 600);
- let numWarnings = 0;
+ const imageWithAlpha = new ImageData(600, 600);
+ let numWarnings = 0;
- for (let x = 0; x < 600; x++) {
- for (let y = 0; y < 600; y++) {
- const pixelIndex = (600 * y + x) << 2;
+ for (let x = 0; x < 600; x++) {
+ for (let y = 0; y < 600; y++) {
+ const pixelIndex = (600 * y + x) << 2;
- const rOnBlack = imageOnBlack.data[pixelIndex];
- const gOnBlack = imageOnBlack.data[pixelIndex + 1];
- const bOnBlack = imageOnBlack.data[pixelIndex + 2];
- const rOnWhite = imageOnWhite.data[pixelIndex];
- const gOnWhite = imageOnWhite.data[pixelIndex + 1];
- const bOnWhite = imageOnWhite.data[pixelIndex + 2];
- if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
- if (numWarnings < 100) {
- console.warn(
- `[${x}x${y}] color on white should be lighter than color on ` +
- `black, see pixel ${x}x${y}: ` +
- `#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
- `${gOnWhite.toString(16)}` +
- ` vs ` +
- `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
- `${gOnWhite.toString(16)}. ` +
- `Using conflict mode ${conflictMode} to fall back.`,
- );
- }
+ const rOnBlack = imageOnBlack.data[pixelIndex];
+ const gOnBlack = imageOnBlack.data[pixelIndex + 1];
+ const bOnBlack = imageOnBlack.data[pixelIndex + 2];
+ const rOnWhite = imageOnWhite.data[pixelIndex];
+ const gOnWhite = imageOnWhite.data[pixelIndex + 1];
+ const bOnWhite = imageOnWhite.data[pixelIndex + 2];
+ if (rOnWhite < rOnBlack || gOnWhite < gOnBlack || bOnWhite < bOnBlack) {
+ if (numWarnings < 100) {
+ console.warn(
+ `[${x}x${y}] color on white should be lighter than color on ` +
+ `black, see pixel ${x}x${y}: ` +
+ `#${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
+ `${gOnWhite.toString(16)}` +
+ ` vs ` +
+ `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
+ `${gOnWhite.toString(16)}. ` +
+ `Using conflict mode ${conflictMode} to fall back.`,
+ );
+ }
- const [r, g, b, a] = resolveConflict(
- [rOnBlack, gOnBlack, bOnBlack],
- [rOnWhite, gOnWhite, bOnWhite],
- conflictMode,
- );
- imageWithAlpha.data[pixelIndex] = r;
- imageWithAlpha.data[pixelIndex + 1] = g;
- imageWithAlpha.data[pixelIndex + 2] = b;
- imageWithAlpha.data[pixelIndex + 3] = a;
+ const [r, g, b, a] = resolveConflict(
+ [rOnBlack, gOnBlack, bOnBlack],
+ [rOnWhite, gOnWhite, bOnWhite],
+ conflictMode,
+ );
+ imageWithAlpha.data[pixelIndex] = r;
+ imageWithAlpha.data[pixelIndex + 1] = g;
+ imageWithAlpha.data[pixelIndex + 2] = b;
+ imageWithAlpha.data[pixelIndex + 3] = a;
- numWarnings++;
- continue;
- }
+ numWarnings++;
+ continue;
+ }
- // The true alpha is how close together the on-white and on-black colors
- // are. If they're totally the same, it's 255 opacity. If they're totally
- // different, it's 0 opacity. In between, it scales linearly with the
- // difference!
- const alpha = 255 - (rOnWhite - rOnBlack);
+ // The true alpha is how close together the on-white and on-black colors
+ // are. If they're totally the same, it's 255 opacity. If they're totally
+ // different, it's 0 opacity. In between, it scales linearly with the
+ // difference!
+ const alpha = 255 - (rOnWhite - rOnBlack);
- // Check that the alpha derived from other channels makes sense too.
- const alphaByB = 255 - (bOnWhite - bOnBlack);
- const alphaByG = 255 - (gOnWhite - gOnBlack);
- const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
- const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
- if (highestAlpha - lowestAlpha > 2) {
- if (numWarnings < 100) {
- console.warn(
- `[${x}x${y}] derived alpha values don't match: ` +
- `${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
- `Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
- `${gOnWhite.toString(16)}` +
- ` vs ` +
- `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
- `${gOnWhite.toString(16)}. ` +
- `Using conflict mode ${conflictMode} to fall back.`,
- );
- }
+ // Check that the alpha derived from other channels makes sense too.
+ const alphaByB = 255 - (bOnWhite - bOnBlack);
+ const alphaByG = 255 - (gOnWhite - gOnBlack);
+ const highestAlpha = Math.max(Math.max(alpha, alphaByB), alphaByG);
+ const lowestAlpha = Math.min(Math.min(alpha, alphaByB, alphaByG));
+ if (highestAlpha - lowestAlpha > 2) {
+ if (numWarnings < 100) {
+ console.warn(
+ `[${x}x${y}] derived alpha values don't match: ` +
+ `${alpha} vs ${alphaByB} vs ${alphaByG}. ` +
+ `Colors: #${rOnWhite.toString(16)}${bOnWhite.toString(16)}` +
+ `${gOnWhite.toString(16)}` +
+ ` vs ` +
+ `#${rOnBlack.toString(16)}${bOnBlack.toString(16)}` +
+ `${gOnWhite.toString(16)}. ` +
+ `Using conflict mode ${conflictMode} to fall back.`,
+ );
+ }
- const [r, g, b, a] = resolveConflict(
- [rOnBlack, gOnBlack, bOnBlack],
- [rOnWhite, gOnWhite, bOnWhite],
- conflictMode,
- );
- imageWithAlpha.data[pixelIndex] = r;
- imageWithAlpha.data[pixelIndex + 1] = g;
- imageWithAlpha.data[pixelIndex + 2] = b;
- imageWithAlpha.data[pixelIndex + 3] = a;
+ const [r, g, b, a] = resolveConflict(
+ [rOnBlack, gOnBlack, bOnBlack],
+ [rOnWhite, gOnWhite, bOnWhite],
+ conflictMode,
+ );
+ imageWithAlpha.data[pixelIndex] = r;
+ imageWithAlpha.data[pixelIndex + 1] = g;
+ imageWithAlpha.data[pixelIndex + 2] = b;
+ imageWithAlpha.data[pixelIndex + 3] = a;
- numWarnings++;
- continue;
- }
+ numWarnings++;
+ continue;
+ }
- // And the true color is the color on black, divided by the true alpha.
- // We can derive this from the definition of the color on black, which is
- // simply the true color times the true alpha. Divide to undo!
- const alphaRatio = alpha / 255;
- const rOnAlpha = Math.round(rOnBlack / alphaRatio);
- const gOnAlpha = Math.round(gOnBlack / alphaRatio);
- const bOnAlpha = Math.round(bOnBlack / alphaRatio);
+ // And the true color is the color on black, divided by the true alpha.
+ // We can derive this from the definition of the color on black, which is
+ // simply the true color times the true alpha. Divide to undo!
+ const alphaRatio = alpha / 255;
+ const rOnAlpha = Math.round(rOnBlack / alphaRatio);
+ const gOnAlpha = Math.round(gOnBlack / alphaRatio);
+ const bOnAlpha = Math.round(bOnBlack / alphaRatio);
- imageWithAlpha.data[pixelIndex] = rOnAlpha;
- imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
- imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
- imageWithAlpha.data[pixelIndex + 3] = alpha;
- }
- }
+ imageWithAlpha.data[pixelIndex] = rOnAlpha;
+ imageWithAlpha.data[pixelIndex + 1] = gOnAlpha;
+ imageWithAlpha.data[pixelIndex + 2] = bOnAlpha;
+ imageWithAlpha.data[pixelIndex + 3] = alpha;
+ }
+ }
- return [imageWithAlpha, numWarnings];
+ return [imageWithAlpha, numWarnings];
}
/**
@@ -554,21 +554,21 @@ function mergeDataIntoImageWithAlpha(imageOnBlack, imageOnWhite, conflictMode) {
* canvas and reading ImageData back from it.
*/
async function readImageDataFromUrl(url) {
- const image = new Image();
+ const image = new Image();
- await new Promise((resolve, reject) => {
- image.onload = resolve;
- image.onerror = reject;
- image.src = url;
- });
+ await new Promise((resolve, reject) => {
+ image.onload = resolve;
+ image.onerror = reject;
+ image.src = url;
+ });
- const canvas = document.createElement("canvas");
- canvas.width = 600;
- canvas.height = 600;
+ const canvas = document.createElement("canvas");
+ canvas.width = 600;
+ canvas.height = 600;
- const ctx = canvas.getContext("2d");
- ctx.drawImage(image, 0, 0, 600, 600);
- return ctx.getImageData(0, 0, 600, 600);
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(image, 0, 0, 600, 600);
+ return ctx.getImageData(0, 0, 600, 600);
}
/**
@@ -576,42 +576,42 @@ async function readImageDataFromUrl(url) {
* it on a canvas and reading the URL and Blob back from it.
*/
async function writeImageDataToUrlAndBlob(imageData) {
- const canvas = document.createElement("canvas");
- canvas.width = 600;
- canvas.height = 600;
+ const canvas = document.createElement("canvas");
+ canvas.width = 600;
+ canvas.height = 600;
- const ctx = canvas.getContext("2d");
- ctx.putImageData(imageData, 0, 0);
+ const ctx = canvas.getContext("2d");
+ ctx.putImageData(imageData, 0, 0);
- const dataUrl = canvas.toDataURL("image/png");
- const blob = await new Promise((resolve) =>
- canvas.toBlob(resolve, "image/png"),
- );
- return [dataUrl, blob];
+ const dataUrl = canvas.toDataURL("image/png");
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, "image/png"),
+ );
+ return [dataUrl, blob];
}
function resolveConflict(
- [rOnBlack, gOnBlack, bOnBlack],
- [rOnWhite, gOnWhite, bOnWhite],
- conflictMode,
+ [rOnBlack, gOnBlack, bOnBlack],
+ [rOnWhite, gOnWhite, bOnWhite],
+ conflictMode,
) {
- if (conflictMode === "onBlack") {
- return [rOnBlack, gOnBlack, bOnBlack, 255];
- } else if (conflictMode === "onWhite") {
- return [rOnWhite, gOnWhite, bOnWhite, 255];
- } else if (conflictMode === "transparent") {
- return [0, 0, 0, 0];
- } else if (conflictMode === "moreColorful") {
- const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
- const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
- if (sOnBlack > sOnWhite) {
- return [rOnBlack, gOnBlack, bOnBlack, 255];
- } else {
- return [rOnWhite, gOnWhite, bOnWhite, 255];
- }
- } else {
- throw new Error(`unexpected conflict mode ${conflictMode}`);
- }
+ if (conflictMode === "onBlack") {
+ return [rOnBlack, gOnBlack, bOnBlack, 255];
+ } else if (conflictMode === "onWhite") {
+ return [rOnWhite, gOnWhite, bOnWhite, 255];
+ } else if (conflictMode === "transparent") {
+ return [0, 0, 0, 0];
+ } else if (conflictMode === "moreColorful") {
+ const sOnBlack = computeSaturation(rOnBlack, gOnBlack, bOnBlack);
+ const sOnWhite = computeSaturation(rOnWhite, gOnWhite, bOnWhite);
+ if (sOnBlack > sOnWhite) {
+ return [rOnBlack, gOnBlack, bOnBlack, 255];
+ } else {
+ return [rOnWhite, gOnWhite, bOnWhite, 255];
+ }
+ } else {
+ throw new Error(`unexpected conflict mode ${conflictMode}`);
+ }
}
/**
@@ -619,18 +619,18 @@ function resolveConflict(
* Adapted from https://css-tricks.com/converting-color-spaces-in-javascript/
*/
function computeSaturation(r, g, b) {
- r /= 255;
- g /= 255;
- b /= 255;
+ r /= 255;
+ g /= 255;
+ b /= 255;
- const cmin = Math.min(r, g, b);
- const cmax = Math.max(r, g, b);
- const delta = cmax - cmin;
+ const cmin = Math.min(r, g, b);
+ const cmax = Math.max(r, g, b);
+ const delta = cmax - cmin;
- const l = (cmax + cmin) / 2;
- const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
+ const l = (cmax + cmin) / 2;
+ const s = delta === 0 ? 0 : delta / (1 - Math.abs(2 * l - 1));
- return s;
+ return s;
}
export default AppearanceLayerSupportUploadModal;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js
index 9812223b..cbc1b176 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportAppearanceLayer.js
@@ -6,94 +6,94 @@ import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
import { OutfitLayers } from "../../components/OutfitPreview";
function ItemSupportAppearanceLayer({
- item,
- itemLayer,
- biologyLayers,
- outfitState,
+ item,
+ itemLayer,
+ biologyLayers,
+ outfitState,
}) {
- const { isOpen, onOpen, onClose } = useDisclosure();
+ const { isOpen, onOpen, onClose } = useDisclosure();
- const iconButtonBgColor = useColorModeValue("green.100", "green.300");
- const iconButtonColor = useColorModeValue("green.800", "gray.900");
+ const iconButtonBgColor = useColorModeValue("green.100", "green.300");
+ const iconButtonColor = useColorModeValue("green.800", "gray.900");
- return (
-
- {({ css }) => (
-
-
-
-
+ {({ css }) => (
+
+
+
+
-
-
-
-
-
- {itemLayer.zone.label}
- {" "}
-
- (Zone {itemLayer.zone.id})
-
-
- Neopets ID: {itemLayer.remoteId}
- DTI ID: {itemLayer.id}
-
-
- )}
-
- );
+ @media (hover: none) {
+ opacity: 1;
+ }
+ `}
+ background={iconButtonBgColor}
+ color={iconButtonColor}
+ borderRadius="full"
+ boxShadow="sm"
+ position="absolute"
+ bottom="2"
+ right="2"
+ padding="2"
+ alignItems="center"
+ justifyContent="center"
+ width="32px"
+ height="32px"
+ >
+
+
+
+
+
+ {itemLayer.zone.label}
+ {" "}
+
+ (Zone {itemLayer.zone.id})
+
+
+ Neopets ID: {itemLayer.remoteId}
+ DTI ID: {itemLayer.id}
+
+
+ )}
+
+ );
}
export default ItemSupportAppearanceLayer;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js
index 3e6f299f..eed98da7 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/ItemSupportDrawer.js
@@ -2,34 +2,34 @@ import * as React from "react";
import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client";
import {
- Badge,
- Box,
- Button,
- Drawer,
- DrawerBody,
- DrawerCloseButton,
- DrawerContent,
- DrawerHeader,
- DrawerOverlay,
- Flex,
- FormControl,
- FormErrorMessage,
- FormHelperText,
- FormLabel,
- HStack,
- Link,
- Select,
- Spinner,
- Stack,
- Text,
- useBreakpointValue,
- useColorModeValue,
- useDisclosure,
+ Badge,
+ Box,
+ Button,
+ Drawer,
+ DrawerBody,
+ DrawerCloseButton,
+ DrawerContent,
+ DrawerHeader,
+ DrawerOverlay,
+ Flex,
+ FormControl,
+ FormErrorMessage,
+ FormHelperText,
+ FormLabel,
+ HStack,
+ Link,
+ Select,
+ Spinner,
+ Stack,
+ Text,
+ useBreakpointValue,
+ useColorModeValue,
+ useDisclosure,
} from "@chakra-ui/react";
import {
- CheckCircleIcon,
- ChevronRightIcon,
- ExternalLinkIcon,
+ CheckCircleIcon,
+ ChevronRightIcon,
+ ExternalLinkIcon,
} from "@chakra-ui/icons";
import AllItemLayersSupportModal from "./AllItemLayersSupportModal";
@@ -46,362 +46,362 @@ import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
* from another lazy-loaded component!
*/
function ItemSupportDrawer({ item, isOpen, onClose }) {
- const placement = useBreakpointValue({ base: "bottom", lg: "right" });
+ const placement = useBreakpointValue({ base: "bottom", lg: "right" });
- return (
-
-
-
-
-
- {item.name}
-
- Support 💖
-
-
-
-
- Item ID:
- {item.id}
- Restricted zones:
-
-
-
-
-
-
-
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+ {item.name}
+
+ Support 💖
+
+
+
+
+ Item ID:
+ {item.id}
+ Restricted zones:
+
+
+
+
+
+
+
+
+
+
+
+
+ );
}
function ItemSupportRestrictedZones({ item }) {
- const { speciesId, colorId } = React.useContext(OutfitStateContext);
+ const { speciesId, colorId } = React.useContext(OutfitStateContext);
- // NOTE: It would be a better reflection of the data to just query restricted
- // zones right off the item... but we already have them in cache from
- // the appearance, so query them that way to be instant in practice!
- const { loading, error, data } = useQuery(
- gql`
- query ItemSupportRestrictedZones(
- $itemId: ID!
- $speciesId: ID!
- $colorId: ID!
- ) {
- item(id: $itemId) {
- id
- appearanceOn(speciesId: $speciesId, colorId: $colorId) {
- restrictedZones {
- id
- label
- }
- }
- }
- }
- `,
- { variables: { itemId: item.id, speciesId, colorId } },
- );
+ // NOTE: It would be a better reflection of the data to just query restricted
+ // zones right off the item... but we already have them in cache from
+ // the appearance, so query them that way to be instant in practice!
+ const { loading, error, data } = useQuery(
+ gql`
+ query ItemSupportRestrictedZones(
+ $itemId: ID!
+ $speciesId: ID!
+ $colorId: ID!
+ ) {
+ item(id: $itemId) {
+ id
+ appearanceOn(speciesId: $speciesId, colorId: $colorId) {
+ restrictedZones {
+ id
+ label
+ }
+ }
+ }
+ }
+ `,
+ { variables: { itemId: item.id, speciesId, colorId } },
+ );
- if (loading) {
- return ;
- }
+ if (loading) {
+ return ;
+ }
- if (error) {
- return {error.message};
- }
+ if (error) {
+ return {error.message};
+ }
- const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
- if (restrictedZones.length === 0) {
- return "None";
- }
+ const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
+ if (restrictedZones.length === 0) {
+ return "None";
+ }
- return restrictedZones
- .map((z) => `${z.label} (${z.id})`)
- .sort()
- .join(", ");
+ return restrictedZones
+ .map((z) => `${z.label} (${z.id})`)
+ .sort()
+ .join(", ");
}
function ItemSupportFields({ item }) {
- const { loading, error, data } = useQuery(
- gql`
- query ItemSupportFields($itemId: ID!) {
- item(id: $itemId) {
- id
- manualSpecialColor {
- id
- }
- explicitlyBodySpecific
- }
- }
- `,
- {
- variables: { itemId: item.id },
+ const { loading, error, data } = useQuery(
+ gql`
+ query ItemSupportFields($itemId: ID!) {
+ item(id: $itemId) {
+ id
+ manualSpecialColor {
+ id
+ }
+ explicitlyBodySpecific
+ }
+ }
+ `,
+ {
+ variables: { itemId: item.id },
- // HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
- // optimistic response sets `manualSpecialColor` to null, the query
- // doesn't update, even though its cache has updated :/
- //
- // This cheap trick of changing the display name every re-render
- // persuades Apollo that this is a different query, so it re-checks
- // its cache and finds the empty `manualSpecialColor`. Weird!
- displayName: `ItemSupportFields-${new Date()}`,
- },
- );
+ // HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
+ // optimistic response sets `manualSpecialColor` to null, the query
+ // doesn't update, even though its cache has updated :/
+ //
+ // This cheap trick of changing the display name every re-render
+ // persuades Apollo that this is a different query, so it re-checks
+ // its cache and finds the empty `manualSpecialColor`. Weird!
+ displayName: `ItemSupportFields-${new Date()}`,
+ },
+ );
- const errorColor = useColorModeValue("red.500", "red.300");
+ const errorColor = useColorModeValue("red.500", "red.300");
- return (
- <>
- {error && {error.message}}
-
-
- >
- );
+ return (
+ <>
+ {error && {error.message}}
+
+
+ >
+ );
}
function ItemSupportSpecialColorFields({
- loading,
- error,
- item,
- manualSpecialColor,
+ loading,
+ error,
+ item,
+ manualSpecialColor,
}) {
- const { supportSecret } = useSupport();
+ const { supportSecret } = useSupport();
- const {
- loading: colorsLoading,
- error: colorsError,
- data: colorsData,
- } = useQuery(gql`
- query ItemSupportDrawerAllColors {
- allColors {
- id
- name
- isStandard
- }
- }
- `);
+ const {
+ loading: colorsLoading,
+ error: colorsError,
+ data: colorsData,
+ } = useQuery(gql`
+ query ItemSupportDrawerAllColors {
+ allColors {
+ id
+ name
+ isStandard
+ }
+ }
+ `);
- const [
- mutate,
- { loading: mutationLoading, error: mutationError, data: mutationData },
- ] = useMutation(gql`
- mutation ItemSupportDrawerSetManualSpecialColor(
- $itemId: ID!
- $colorId: ID
- $supportSecret: String!
- ) {
- setManualSpecialColor(
- itemId: $itemId
- colorId: $colorId
- supportSecret: $supportSecret
- ) {
- id
- manualSpecialColor {
- id
- }
- }
- }
- `);
+ const [
+ mutate,
+ { loading: mutationLoading, error: mutationError, data: mutationData },
+ ] = useMutation(gql`
+ mutation ItemSupportDrawerSetManualSpecialColor(
+ $itemId: ID!
+ $colorId: ID
+ $supportSecret: String!
+ ) {
+ setManualSpecialColor(
+ itemId: $itemId
+ colorId: $colorId
+ supportSecret: $supportSecret
+ ) {
+ id
+ manualSpecialColor {
+ id
+ }
+ }
+ }
+ `);
- const onChange = React.useCallback(
- (e) => {
- const colorId = e.target.value || null;
- const color =
- colorId != null ? { __typename: "Color", id: colorId } : null;
- mutate({
- variables: {
- itemId: item.id,
- colorId,
- supportSecret,
- },
- optimisticResponse: {
- __typename: "Mutation",
- setManualSpecialColor: {
- __typename: "Item",
- id: item.id,
- manualSpecialColor: color,
- },
- },
- }).catch((e) => {
- // Ignore errors from the promise, because we'll handle them on render!
- });
- },
- [item.id, mutate, supportSecret],
- );
+ const onChange = React.useCallback(
+ (e) => {
+ const colorId = e.target.value || null;
+ const color =
+ colorId != null ? { __typename: "Color", id: colorId } : null;
+ mutate({
+ variables: {
+ itemId: item.id,
+ colorId,
+ supportSecret,
+ },
+ optimisticResponse: {
+ __typename: "Mutation",
+ setManualSpecialColor: {
+ __typename: "Item",
+ id: item.id,
+ manualSpecialColor: color,
+ },
+ },
+ }).catch((e) => {
+ // Ignore errors from the promise, because we'll handle them on render!
+ });
+ },
+ [item.id, mutate, supportSecret],
+ );
- const nonStandardColors =
- colorsData?.allColors?.filter((c) => !c.isStandard) || [];
- nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
+ const nonStandardColors =
+ colorsData?.allColors?.filter((c) => !c.isStandard) || [];
+ nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
- const linkColor = useColorModeValue("green.500", "green.300");
+ const linkColor = useColorModeValue("green.500", "green.300");
- return (
-
- Special color
-
- ) : mutationData ? (
-
- ) : undefined
- }
- onChange={onChange}
- >
- {nonStandardColors.map((color) => (
-
- ))}
-
- {colorsError && (
- {colorsError.message}
- )}
- {mutationError && (
- {mutationError.message}
- )}
- {!colorsError && !mutationError && (
-
- This controls which previews we show on the{" "}
-
- classic item page
-
- .
-
- )}
-
- );
+ return (
+
+ Special color
+
+ ) : mutationData ? (
+
+ ) : undefined
+ }
+ onChange={onChange}
+ >
+ {nonStandardColors.map((color) => (
+
+ ))}
+
+ {colorsError && (
+ {colorsError.message}
+ )}
+ {mutationError && (
+ {mutationError.message}
+ )}
+ {!colorsError && !mutationError && (
+
+ This controls which previews we show on the{" "}
+
+ classic item page
+
+ .
+
+ )}
+
+ );
}
function ItemSupportPetCompatibilityRuleFields({
- loading,
- error,
- item,
- explicitlyBodySpecific,
+ loading,
+ error,
+ item,
+ explicitlyBodySpecific,
}) {
- const { supportSecret } = useSupport();
+ const { supportSecret } = useSupport();
- const [
- mutate,
- { loading: mutationLoading, error: mutationError, data: mutationData },
- ] = useMutation(gql`
- mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
- $itemId: ID!
- $explicitlyBodySpecific: Boolean!
- $supportSecret: String!
- ) {
- setItemExplicitlyBodySpecific(
- itemId: $itemId
- explicitlyBodySpecific: $explicitlyBodySpecific
- supportSecret: $supportSecret
- ) {
- id
- explicitlyBodySpecific
- }
- }
- `);
+ const [
+ mutate,
+ { loading: mutationLoading, error: mutationError, data: mutationData },
+ ] = useMutation(gql`
+ mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
+ $itemId: ID!
+ $explicitlyBodySpecific: Boolean!
+ $supportSecret: String!
+ ) {
+ setItemExplicitlyBodySpecific(
+ itemId: $itemId
+ explicitlyBodySpecific: $explicitlyBodySpecific
+ supportSecret: $supportSecret
+ ) {
+ id
+ explicitlyBodySpecific
+ }
+ }
+ `);
- const onChange = React.useCallback(
- (e) => {
- const explicitlyBodySpecific = e.target.value === "true";
- mutate({
- variables: {
- itemId: item.id,
- explicitlyBodySpecific,
- supportSecret,
- },
- optimisticResponse: {
- __typename: "Mutation",
- setItemExplicitlyBodySpecific: {
- __typename: "Item",
- id: item.id,
- explicitlyBodySpecific,
- },
- },
- }).catch((e) => {
- // Ignore errors from the promise, because we'll handle them on render!
- });
- },
- [item.id, mutate, supportSecret],
- );
+ const onChange = React.useCallback(
+ (e) => {
+ const explicitlyBodySpecific = e.target.value === "true";
+ mutate({
+ variables: {
+ itemId: item.id,
+ explicitlyBodySpecific,
+ supportSecret,
+ },
+ optimisticResponse: {
+ __typename: "Mutation",
+ setItemExplicitlyBodySpecific: {
+ __typename: "Item",
+ id: item.id,
+ explicitlyBodySpecific,
+ },
+ },
+ }).catch((e) => {
+ // Ignore errors from the promise, because we'll handle them on render!
+ });
+ },
+ [item.id, mutate, supportSecret],
+ );
- return (
-
- Pet compatibility rule
-
- ) : mutationData ? (
-
- ) : undefined
- }
- onChange={onChange}
- >
- {loading ? (
-
- ) : (
- <>
-
-
- >
- )}
-
- {mutationError && (
- {mutationError.message}
- )}
- {!mutationError && (
-
- By default, we assume Background-y zones fit all pets the same. When
- items don't follow that rule, we can override it.
-
- )}
-
- );
+ return (
+
+ Pet compatibility rule
+
+ ) : mutationData ? (
+
+ ) : undefined
+ }
+ onChange={onChange}
+ >
+ {loading ? (
+
+ ) : (
+ <>
+
+
+ >
+ )}
+
+ {mutationError && (
+ {mutationError.message}
+ )}
+ {!mutationError && (
+
+ By default, we assume Background-y zones fit all pets the same. When
+ items don't follow that rule, we can override it.
+
+ )}
+
+ );
}
/**
@@ -412,51 +412,51 @@ function ItemSupportPetCompatibilityRuleFields({
* it here, only when the drawer is open!
*/
function ItemSupportAppearanceLayers({ item }) {
- const outfitState = React.useContext(OutfitStateContext);
- const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
- const { error, visibleLayers } = useOutfitAppearance({
- speciesId,
- colorId,
- pose,
- altStyleId,
- appearanceId,
- wornItemIds: [item.id],
- });
+ const outfitState = React.useContext(OutfitStateContext);
+ const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
+ const { error, visibleLayers } = useOutfitAppearance({
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ appearanceId,
+ wornItemIds: [item.id],
+ });
- const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
- const itemLayers = visibleLayers.filter((l) => l.source === "item");
- itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
+ const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
+ const itemLayers = visibleLayers.filter((l) => l.source === "item");
+ itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
- const modalState = useDisclosure();
+ const modalState = useDisclosure();
- return (
-
-
- Appearance layers
-
-
- View on all pets
-
-
-
-
- {itemLayers.map((itemLayer) => (
-
- ))}
-
- {error && {error.message}}
-
- );
+ return (
+
+
+ Appearance layers
+
+
+ View on all pets
+
+
+
+
+ {itemLayers.map((itemLayer) => (
+
+ ))}
+
+ {error && {error.message}}
+
+ );
}
export default ItemSupportDrawer;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js b/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js
index 30262948..1696ee0f 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/Metadata.js
@@ -6,34 +6,34 @@ import { Box } from "@chakra-ui/react";
* and their values.
*/
function Metadata({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
function MetadataLabel({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
function MetadataValue({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
export default Metadata;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js b/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js
index 62b95918..89a99b41 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/PosePickerSupport.js
@@ -2,23 +2,23 @@ import React from "react";
import gql from "graphql-tag";
import { useMutation, useQuery } from "@apollo/client";
import {
- Box,
- Button,
- IconButton,
- Select,
- Spinner,
- Switch,
- Wrap,
- WrapItem,
- useDisclosure,
- UnorderedList,
- ListItem,
+ Box,
+ Button,
+ IconButton,
+ Select,
+ Spinner,
+ Switch,
+ Wrap,
+ WrapItem,
+ useDisclosure,
+ UnorderedList,
+ ListItem,
} from "@chakra-ui/react";
import {
- ArrowBackIcon,
- ArrowForwardIcon,
- CheckCircleIcon,
- EditIcon,
+ ArrowBackIcon,
+ ArrowForwardIcon,
+ CheckCircleIcon,
+ EditIcon,
} from "@chakra-ui/icons";
import HangerSpinner from "../../components/HangerSpinner";
@@ -28,555 +28,555 @@ import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
import { petAppearanceForPosePickerFragment } from "../PosePicker";
function PosePickerSupport({
- speciesId,
- colorId,
- pose,
- appearanceId,
- initialFocusRef,
- dispatchToOutfit,
+ speciesId,
+ colorId,
+ pose,
+ appearanceId,
+ initialFocusRef,
+ dispatchToOutfit,
}) {
- const { loading, error, data } = useQuery(
- gql`
- query PosePickerSupport($speciesId: ID!, $colorId: ID!) {
- petAppearances(speciesId: $speciesId, colorId: $colorId) {
- id
- pose
- isGlitched
- layers {
- id
- zone {
- id
- label
- }
+ const { loading, error, data } = useQuery(
+ gql`
+ query PosePickerSupport($speciesId: ID!, $colorId: ID!) {
+ petAppearances(speciesId: $speciesId, colorId: $colorId) {
+ id
+ pose
+ isGlitched
+ layers {
+ id
+ zone {
+ id
+ label
+ }
- # For AppearanceLayerSupportModal
- remoteId
- bodyId
- swfUrl
- svgUrl
- imageUrl: imageUrlV2(idealSize: SIZE_600)
- canvasMovieLibraryUrl
- }
- restrictedZones {
- id
- label
- }
+ # For AppearanceLayerSupportModal
+ remoteId
+ bodyId
+ swfUrl
+ svgUrl
+ imageUrl: imageUrlV2(idealSize: SIZE_600)
+ canvasMovieLibraryUrl
+ }
+ restrictedZones {
+ id
+ label
+ }
- # For AppearanceLayerSupportModal to know the name
- species {
- id
- name
- }
- color {
- id
- name
- }
+ # For AppearanceLayerSupportModal to know the name
+ species {
+ id
+ name
+ }
+ color {
+ id
+ name
+ }
- # Also, anything the PosePicker wants that isn't here, so that we
- # don't have to refetch anything when we change the canonical poses.
- ...PetAppearanceForPosePicker
- }
+ # Also, anything the PosePicker wants that isn't here, so that we
+ # don't have to refetch anything when we change the canonical poses.
+ ...PetAppearanceForPosePicker
+ }
- ...CanonicalPetAppearances
- }
- ${canonicalPetAppearancesFragment}
- ${petAppearanceForPosePickerFragment}
- `,
- { variables: { speciesId, colorId } },
- );
+ ...CanonicalPetAppearances
+ }
+ ${canonicalPetAppearancesFragment}
+ ${petAppearanceForPosePickerFragment}
+ `,
+ { variables: { speciesId, colorId } },
+ );
- // Resize the Popover when we toggle loading state, because it probably will
- // affect the content size. appearanceId might also affect content size, if
- // it occupies different zones.
- //
- // NOTE: This also triggers an additional necessary resize when the component
- // first mounts, because PosePicker lazy-loads it, so it actually
- // mounting affects size too.
- React.useLayoutEffect(() => {
- // HACK: To trigger a Popover resize, we simulate a window resize event,
- // because Popover listens for window resizes to reposition itself.
- // I've also filed an issue requesting an official API!
- // https://github.com/chakra-ui/chakra-ui/issues/1853
- window.dispatchEvent(new Event("resize"));
- }, [loading, appearanceId]);
+ // Resize the Popover when we toggle loading state, because it probably will
+ // affect the content size. appearanceId might also affect content size, if
+ // it occupies different zones.
+ //
+ // NOTE: This also triggers an additional necessary resize when the component
+ // first mounts, because PosePicker lazy-loads it, so it actually
+ // mounting affects size too.
+ React.useLayoutEffect(() => {
+ // HACK: To trigger a Popover resize, we simulate a window resize event,
+ // because Popover listens for window resizes to reposition itself.
+ // I've also filed an issue requesting an official API!
+ // https://github.com/chakra-ui/chakra-ui/issues/1853
+ window.dispatchEvent(new Event("resize"));
+ }, [loading, appearanceId]);
- const canonicalAppearanceIdsByPose = {
- HAPPY_MASC: data?.happyMasc?.id,
- SAD_MASC: data?.sadMasc?.id,
- SICK_MASC: data?.sickMasc?.id,
- HAPPY_FEM: data?.happyFem?.id,
- SAD_FEM: data?.sadFem?.id,
- SICK_FEM: data?.sickFem?.id,
- UNCONVERTED: data?.unconverted?.id,
- UNKNOWN: data?.unknown?.id,
- };
- const canonicalAppearanceIds = Object.values(
- canonicalAppearanceIdsByPose,
- ).filter((id) => id);
+ const canonicalAppearanceIdsByPose = {
+ HAPPY_MASC: data?.happyMasc?.id,
+ SAD_MASC: data?.sadMasc?.id,
+ SICK_MASC: data?.sickMasc?.id,
+ HAPPY_FEM: data?.happyFem?.id,
+ SAD_FEM: data?.sadFem?.id,
+ SICK_FEM: data?.sickFem?.id,
+ UNCONVERTED: data?.unconverted?.id,
+ UNKNOWN: data?.unknown?.id,
+ };
+ const canonicalAppearanceIds = Object.values(
+ canonicalAppearanceIdsByPose,
+ ).filter((id) => id);
- const providedAppearanceId = appearanceId;
- if (!providedAppearanceId) {
- appearanceId = canonicalAppearanceIdsByPose[pose];
- }
+ const providedAppearanceId = appearanceId;
+ if (!providedAppearanceId) {
+ appearanceId = canonicalAppearanceIdsByPose[pose];
+ }
- // If you don't already have `appearanceId` in the outfit state, opening up
- // PosePickerSupport adds it! That way, if you make changes that affect the
- // canonical poses, we'll still stay navigated to this one.
- React.useEffect(() => {
- if (!providedAppearanceId && appearanceId) {
- dispatchToOutfit({
- type: "setPose",
- pose,
- appearanceId,
- });
- }
- }, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]);
+ // If you don't already have `appearanceId` in the outfit state, opening up
+ // PosePickerSupport adds it! That way, if you make changes that affect the
+ // canonical poses, we'll still stay navigated to this one.
+ React.useEffect(() => {
+ if (!providedAppearanceId && appearanceId) {
+ dispatchToOutfit({
+ type: "setPose",
+ pose,
+ appearanceId,
+ });
+ }
+ }, [providedAppearanceId, appearanceId, pose, dispatchToOutfit]);
- if (loading) {
- return (
-
-
-
- );
- }
+ if (loading) {
+ return (
+
+
+
+ );
+ }
- if (error) {
- return (
-
- {error.message}
-
- );
- }
+ if (error) {
+ return (
+
+ {error.message}
+
+ );
+ }
- const currentPetAppearance = data.petAppearances.find(
- (pa) => pa.id === appearanceId,
- );
- if (!currentPetAppearance) {
- return (
-
- Pet appearance with ID {JSON.stringify(appearanceId)} not found
-
- );
- }
+ const currentPetAppearance = data.petAppearances.find(
+ (pa) => pa.id === appearanceId,
+ );
+ if (!currentPetAppearance) {
+ return (
+
+ Pet appearance with ID {JSON.stringify(appearanceId)} not found
+
+ );
+ }
- return (
-
-
-
- DTI ID:
- {appearanceId}
- Pose:
-
-
-
- Layers:
-
-
- {currentPetAppearance.layers
- .map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer])
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map(([text, layer]) => (
-
-
- {text}
-
-
-
- ))}
-
-
- Restricts:
-
- {currentPetAppearance.restrictedZones.length > 0 ? (
-
- {currentPetAppearance.restrictedZones
- .map((zone) => `${zone.label} (${zone.id})`)
- .sort((a, b) => a[0].localeCompare(b[0]))
- .map((zoneText) => (
- {zoneText}
- ))}
-
- ) : (
-
- None
-
- )}
-
-
-
- );
+ return (
+
+
+
+ DTI ID:
+ {appearanceId}
+ Pose:
+
+
+
+ Layers:
+
+
+ {currentPetAppearance.layers
+ .map((layer) => [`${layer.zone.label} (${layer.zone.id})`, layer])
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map(([text, layer]) => (
+
+
+ {text}
+
+
+
+ ))}
+
+
+ Restricts:
+
+ {currentPetAppearance.restrictedZones.length > 0 ? (
+
+ {currentPetAppearance.restrictedZones
+ .map((zone) => `${zone.label} (${zone.id})`)
+ .sort((a, b) => a[0].localeCompare(b[0]))
+ .map((zoneText) => (
+ {zoneText}
+ ))}
+
+ ) : (
+
+ None
+
+ )}
+
+
+
+ );
}
function PetLayerSupportLink({ outfitState, petAppearance, layer, children }) {
- const { isOpen, onOpen, onClose } = useDisclosure();
- return (
- <>
-
- {children}
-
-
- >
- );
+ const { isOpen, onOpen, onClose } = useDisclosure();
+ return (
+ <>
+
+ {children}
+
+
+ >
+ );
}
function PosePickerSupportNavigator({
- petAppearances,
- currentPetAppearance,
- canonicalAppearanceIds,
- dropdownRef,
- dispatchToOutfit,
+ petAppearances,
+ currentPetAppearance,
+ canonicalAppearanceIds,
+ dropdownRef,
+ dispatchToOutfit,
}) {
- const currentIndex = petAppearances.indexOf(currentPetAppearance);
- const prevPetAppearance = petAppearances[currentIndex - 1];
- const nextPetAppearance = petAppearances[currentIndex + 1];
+ const currentIndex = petAppearances.indexOf(currentPetAppearance);
+ const prevPetAppearance = petAppearances[currentIndex - 1];
+ const nextPetAppearance = petAppearances[currentIndex + 1];
- return (
-
- }
- size="sm"
- marginRight="2"
- isDisabled={prevPetAppearance == null}
- onClick={() =>
- dispatchToOutfit({
- type: "setPose",
- pose: prevPetAppearance.pose,
- appearanceId: prevPetAppearance.id,
- })
- }
- />
-
- }
- size="sm"
- marginLeft="2"
- isDisabled={nextPetAppearance == null}
- onClick={() =>
- dispatchToOutfit({
- type: "setPose",
- pose: nextPetAppearance.pose,
- appearanceId: nextPetAppearance.id,
- })
- }
- />
-
- );
+ return (
+
+ }
+ size="sm"
+ marginRight="2"
+ isDisabled={prevPetAppearance == null}
+ onClick={() =>
+ dispatchToOutfit({
+ type: "setPose",
+ pose: prevPetAppearance.pose,
+ appearanceId: prevPetAppearance.id,
+ })
+ }
+ />
+
+ }
+ size="sm"
+ marginLeft="2"
+ isDisabled={nextPetAppearance == null}
+ onClick={() =>
+ dispatchToOutfit({
+ type: "setPose",
+ pose: nextPetAppearance.pose,
+ appearanceId: nextPetAppearance.id,
+ })
+ }
+ />
+
+ );
}
function PosePickerSupportPoseFields({ petAppearance, speciesId, colorId }) {
- const { supportSecret } = useSupport();
+ const { supportSecret } = useSupport();
- const [mutatePose, poseMutation] = useMutation(
- gql`
- mutation PosePickerSupportSetPetAppearancePose(
- $appearanceId: ID!
- $pose: Pose!
- $supportSecret: String!
- ) {
- setPetAppearancePose(
- appearanceId: $appearanceId
- pose: $pose
- supportSecret: $supportSecret
- ) {
- id
- pose
- }
- }
- `,
- {
- refetchQueries: [
- {
- query: gql`
- query PosePickerSupportRefetchCanonicalAppearances(
- $speciesId: ID!
- $colorId: ID!
- ) {
- ...CanonicalPetAppearances
- }
- ${canonicalPetAppearancesFragment}
- `,
- variables: { speciesId, colorId },
- },
- ],
- },
- );
+ const [mutatePose, poseMutation] = useMutation(
+ gql`
+ mutation PosePickerSupportSetPetAppearancePose(
+ $appearanceId: ID!
+ $pose: Pose!
+ $supportSecret: String!
+ ) {
+ setPetAppearancePose(
+ appearanceId: $appearanceId
+ pose: $pose
+ supportSecret: $supportSecret
+ ) {
+ id
+ pose
+ }
+ }
+ `,
+ {
+ refetchQueries: [
+ {
+ query: gql`
+ query PosePickerSupportRefetchCanonicalAppearances(
+ $speciesId: ID!
+ $colorId: ID!
+ ) {
+ ...CanonicalPetAppearances
+ }
+ ${canonicalPetAppearancesFragment}
+ `,
+ variables: { speciesId, colorId },
+ },
+ ],
+ },
+ );
- const [mutateIsGlitched, isGlitchedMutation] = useMutation(
- gql`
- mutation PosePickerSupportSetPetAppearanceIsGlitched(
- $appearanceId: ID!
- $isGlitched: Boolean!
- $supportSecret: String!
- ) {
- setPetAppearanceIsGlitched(
- appearanceId: $appearanceId
- isGlitched: $isGlitched
- supportSecret: $supportSecret
- ) {
- id
- isGlitched
- }
- }
- `,
- {
- refetchQueries: [
- {
- query: gql`
- query PosePickerSupportRefetchCanonicalAppearances(
- $speciesId: ID!
- $colorId: ID!
- ) {
- ...CanonicalPetAppearances
- }
- ${canonicalPetAppearancesFragment}
- `,
- variables: { speciesId, colorId },
- },
- ],
- },
- );
+ const [mutateIsGlitched, isGlitchedMutation] = useMutation(
+ gql`
+ mutation PosePickerSupportSetPetAppearanceIsGlitched(
+ $appearanceId: ID!
+ $isGlitched: Boolean!
+ $supportSecret: String!
+ ) {
+ setPetAppearanceIsGlitched(
+ appearanceId: $appearanceId
+ isGlitched: $isGlitched
+ supportSecret: $supportSecret
+ ) {
+ id
+ isGlitched
+ }
+ }
+ `,
+ {
+ refetchQueries: [
+ {
+ query: gql`
+ query PosePickerSupportRefetchCanonicalAppearances(
+ $speciesId: ID!
+ $colorId: ID!
+ ) {
+ ...CanonicalPetAppearances
+ }
+ ${canonicalPetAppearancesFragment}
+ `,
+ variables: { speciesId, colorId },
+ },
+ ],
+ },
+ );
- return (
-
-
-
- ) : poseMutation.data ? (
-
- ) : undefined
- }
- onChange={(e) => {
- const pose = e.target.value;
- mutatePose({
- variables: {
- appearanceId: petAppearance.id,
- pose,
- supportSecret,
- },
- optimisticResponse: {
- __typename: "Mutation",
- setPetAppearancePose: {
- __typename: "PetAppearance",
- id: petAppearance.id,
- pose,
- },
- },
- }).catch((e) => {
- /* Discard errors here; we'll show them in the UI! */
- });
- }}
- isInvalid={poseMutation.error != null}
- >
- {Object.entries(POSE_NAMES).map(([pose, name]) => (
-
- ))}
-
-
- ) : isGlitchedMutation.data ? (
-
- ) : undefined
- }
- onChange={(e) => {
- const isGlitched = e.target.value === "true";
- mutateIsGlitched({
- variables: {
- appearanceId: petAppearance.id,
- isGlitched,
- supportSecret,
- },
- optimisticResponse: {
- __typename: "Mutation",
- setPetAppearanceIsGlitched: {
- __typename: "PetAppearance",
- id: petAppearance.id,
- isGlitched,
- },
- },
- }).catch((e) => {
- /* Discard errors here; we'll show them in the UI! */
- });
- }}
- isInvalid={isGlitchedMutation.error != null}
- >
-
-
-
-
- {poseMutation.error && (
- {poseMutation.error.message}
- )}
- {isGlitchedMutation.error && (
- {isGlitchedMutation.error.message}
- )}
-
- );
+ return (
+
+
+
+ ) : poseMutation.data ? (
+
+ ) : undefined
+ }
+ onChange={(e) => {
+ const pose = e.target.value;
+ mutatePose({
+ variables: {
+ appearanceId: petAppearance.id,
+ pose,
+ supportSecret,
+ },
+ optimisticResponse: {
+ __typename: "Mutation",
+ setPetAppearancePose: {
+ __typename: "PetAppearance",
+ id: petAppearance.id,
+ pose,
+ },
+ },
+ }).catch((e) => {
+ /* Discard errors here; we'll show them in the UI! */
+ });
+ }}
+ isInvalid={poseMutation.error != null}
+ >
+ {Object.entries(POSE_NAMES).map(([pose, name]) => (
+
+ ))}
+
+
+ ) : isGlitchedMutation.data ? (
+
+ ) : undefined
+ }
+ onChange={(e) => {
+ const isGlitched = e.target.value === "true";
+ mutateIsGlitched({
+ variables: {
+ appearanceId: petAppearance.id,
+ isGlitched,
+ supportSecret,
+ },
+ optimisticResponse: {
+ __typename: "Mutation",
+ setPetAppearanceIsGlitched: {
+ __typename: "PetAppearance",
+ id: petAppearance.id,
+ isGlitched,
+ },
+ },
+ }).catch((e) => {
+ /* Discard errors here; we'll show them in the UI! */
+ });
+ }}
+ isInvalid={isGlitchedMutation.error != null}
+ >
+
+
+
+
+ {poseMutation.error && (
+ {poseMutation.error.message}
+ )}
+ {isGlitchedMutation.error && (
+ {isGlitchedMutation.error.message}
+ )}
+
+ );
}
export function PosePickerSupportSwitch({ isChecked, onChange }) {
- return (
-
-
-
- 💖
-
-
-
-
- );
+ return (
+
+
+
+ 💖
+
+
+
+
+ );
}
const POSE_NAMES = {
- HAPPY_MASC: "Happy Masc",
- HAPPY_FEM: "Happy Fem",
- SAD_MASC: "Sad Masc",
- SAD_FEM: "Sad Fem",
- SICK_MASC: "Sick Masc",
- SICK_FEM: "Sick Fem",
- UNCONVERTED: "Unconverted",
- UNKNOWN: "Unknown",
+ HAPPY_MASC: "Happy Masc",
+ HAPPY_FEM: "Happy Fem",
+ SAD_MASC: "Sad Masc",
+ SAD_FEM: "Sad Fem",
+ SICK_MASC: "Sick Masc",
+ SICK_FEM: "Sick Fem",
+ UNCONVERTED: "Unconverted",
+ UNKNOWN: "Unknown",
};
const canonicalPetAppearancesFragment = gql`
- fragment CanonicalPetAppearances on Query {
- happyMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: HAPPY_MASC
- ) {
- id
- }
+ fragment CanonicalPetAppearances on Query {
+ happyMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: HAPPY_MASC
+ ) {
+ id
+ }
- sadMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SAD_MASC
- ) {
- id
- }
+ sadMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SAD_MASC
+ ) {
+ id
+ }
- sickMasc: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SICK_MASC
- ) {
- id
- }
+ sickMasc: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SICK_MASC
+ ) {
+ id
+ }
- happyFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: HAPPY_FEM
- ) {
- id
- }
+ happyFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: HAPPY_FEM
+ ) {
+ id
+ }
- sadFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SAD_FEM
- ) {
- id
- }
+ sadFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SAD_FEM
+ ) {
+ id
+ }
- sickFem: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: SICK_FEM
- ) {
- id
- }
+ sickFem: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: SICK_FEM
+ ) {
+ id
+ }
- unconverted: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: UNCONVERTED
- ) {
- id
- }
+ unconverted: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: UNCONVERTED
+ ) {
+ id
+ }
- unknown: petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: UNKNOWN
- ) {
- id
- }
- }
+ unknown: petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: UNKNOWN
+ ) {
+ id
+ }
+ }
`;
export default PosePickerSupport;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js b/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js
index a1702bfc..24cc6511 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/SupportOnly.js
@@ -12,8 +12,8 @@ import useSupport from "./useSupport";
* the server checks the provided secret for each Support request.
*/
function SupportOnly({ children }) {
- const { isSupportUser } = useSupport();
- return isSupportUser ? children : null;
+ const { isSupportUser } = useSupport();
+ return isSupportUser ? children : null;
}
export default SupportOnly;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js b/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js
index ef4a076b..b9c661d0 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/support/useSupport.js
@@ -23,11 +23,11 @@ import { getSupportSecret } from "../../impress-2020-config";
* the server checks the provided secret for each Support request.
*/
function useSupport() {
- const supportSecret = getSupportSecret();
+ const supportSecret = getSupportSecret();
- const isSupportUser = supportSecret != null;
+ const isSupportUser = supportSecret != null;
- return { isSupportUser, supportSecret };
+ return { isSupportUser, supportSecret };
}
export default useSupport;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
index cc384d3f..901e6af2 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
@@ -7,166 +7,166 @@ import { outfitStatesAreEqual } from "./useOutfitState";
import { useSaveOutfitMutation } from "../loaders/outfits";
function useOutfitSaving(outfitState, dispatchToOutfit) {
- const { isLoggedIn, id: currentUserId } = useCurrentUser();
- const { pathname } = useLocation();
- const navigate = useNavigate();
- const toast = useToast();
+ const { isLoggedIn, id: currentUserId } = useCurrentUser();
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+ const toast = useToast();
- // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
- // to the server.
- const isNewOutfit = outfitState.id == null;
+ // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
+ // to the server.
+ const isNewOutfit = outfitState.id == null;
- // Whether this outfit's latest local changes have been saved to the server.
- // And log it to the console!
- const latestVersionIsSaved =
- outfitState.savedOutfitState &&
- outfitStatesAreEqual(
- outfitState.outfitStateWithoutExtras,
- outfitState.savedOutfitState,
- );
- React.useEffect(() => {
- console.debug(
- "[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
- latestVersionIsSaved,
- outfitState.outfitStateWithoutExtras,
- outfitState.savedOutfitState,
- );
- }, [
- latestVersionIsSaved,
- outfitState.outfitStateWithoutExtras,
- outfitState.savedOutfitState,
- ]);
+ // Whether this outfit's latest local changes have been saved to the server.
+ // And log it to the console!
+ const latestVersionIsSaved =
+ outfitState.savedOutfitState &&
+ outfitStatesAreEqual(
+ outfitState.outfitStateWithoutExtras,
+ outfitState.savedOutfitState,
+ );
+ React.useEffect(() => {
+ console.debug(
+ "[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
+ latestVersionIsSaved,
+ outfitState.outfitStateWithoutExtras,
+ outfitState.savedOutfitState,
+ );
+ }, [
+ latestVersionIsSaved,
+ outfitState.outfitStateWithoutExtras,
+ outfitState.savedOutfitState,
+ ]);
- // Only logged-in users can save outfits - and they can only save new outfits,
- // or outfits they created.
- const canSaveOutfit =
- isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
+ // Only logged-in users can save outfits - and they can only save new outfits,
+ // or outfits they created.
+ const canSaveOutfit =
+ isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
- // Users can delete their own outfits too. The logic is slightly different
- // than for saving, because you can save an outfit that hasn't been saved
- // yet, but you can't delete it.
- const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
+ // Users can delete their own outfits too. The logic is slightly different
+ // than for saving, because you can save an outfit that hasn't been saved
+ // yet, but you can't delete it.
+ const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
- const saveOutfitMutation = useSaveOutfitMutation({
- onSuccess: (outfit) => {
- dispatchToOutfit({
- type: "handleOutfitSaveResponse",
- outfitData: outfit,
- });
- },
- });
- const isSaving = saveOutfitMutation.isPending;
- const saveError = saveOutfitMutation.error;
+ const saveOutfitMutation = useSaveOutfitMutation({
+ onSuccess: (outfit) => {
+ dispatchToOutfit({
+ type: "handleOutfitSaveResponse",
+ outfitData: outfit,
+ });
+ },
+ });
+ const isSaving = saveOutfitMutation.isPending;
+ const saveError = saveOutfitMutation.error;
- const saveOutfitFromProvidedState = React.useCallback(
- (outfitState) => {
- saveOutfitMutation
- .mutateAsync({
- id: outfitState.id,
- name: outfitState.name,
- speciesId: outfitState.speciesId,
- colorId: outfitState.colorId,
- pose: outfitState.pose,
- appearanceId: outfitState.appearanceId,
- altStyleId: outfitState.altStyleId,
- wornItemIds: [...outfitState.wornItemIds],
- closetedItemIds: [...outfitState.closetedItemIds],
- })
- .then((outfit) => {
- // Navigate to the new saved outfit URL. Our Apollo cache should pick
- // up the data from this mutation response, and combine it with the
- // existing cached data, to make this smooth without any loading UI.
- if (pathname !== `/outfits/[outfitId]`) {
- navigate(`/outfits/${outfit.id}`);
- }
- })
- .catch((e) => {
- console.error(e);
- toast({
- status: "error",
- title: "Sorry, there was an error saving this outfit!",
- description: "Maybe check your connection and try again.",
- });
- });
- },
- // It's important that this callback _doesn't_ change when the outfit
- // changes, so that the auto-save effect is only responding to the
- // debounced state!
- [saveOutfitMutation, pathname, navigate, toast],
- );
+ const saveOutfitFromProvidedState = React.useCallback(
+ (outfitState) => {
+ saveOutfitMutation
+ .mutateAsync({
+ id: outfitState.id,
+ name: outfitState.name,
+ speciesId: outfitState.speciesId,
+ colorId: outfitState.colorId,
+ pose: outfitState.pose,
+ appearanceId: outfitState.appearanceId,
+ altStyleId: outfitState.altStyleId,
+ wornItemIds: [...outfitState.wornItemIds],
+ closetedItemIds: [...outfitState.closetedItemIds],
+ })
+ .then((outfit) => {
+ // Navigate to the new saved outfit URL. Our Apollo cache should pick
+ // up the data from this mutation response, and combine it with the
+ // existing cached data, to make this smooth without any loading UI.
+ if (pathname !== `/outfits/[outfitId]`) {
+ navigate(`/outfits/${outfit.id}`);
+ }
+ })
+ .catch((e) => {
+ console.error(e);
+ toast({
+ status: "error",
+ title: "Sorry, there was an error saving this outfit!",
+ description: "Maybe check your connection and try again.",
+ });
+ });
+ },
+ // It's important that this callback _doesn't_ change when the outfit
+ // changes, so that the auto-save effect is only responding to the
+ // debounced state!
+ [saveOutfitMutation, pathname, navigate, toast],
+ );
- const saveOutfit = React.useCallback(
- () => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
- [saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras],
- );
+ const saveOutfit = React.useCallback(
+ () => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
+ [saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras],
+ );
- // Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
- // which only contains the basic fields, and will keep a stable object
- // identity until actual changes occur. Then, save the outfit after the user
- // has left it alone for long enough, so long as it's actually different
- // than the saved state.
- const debouncedOutfitState = useDebounce(
- outfitState.outfitStateWithoutExtras,
- 2000,
- {
- // When the outfit ID changes, update the debounced state immediately!
- forceReset: (debouncedOutfitState, newOutfitState) =>
- debouncedOutfitState.id !== newOutfitState.id,
- },
- );
- // HACK: This prevents us from auto-saving the outfit state that's still
- // loading. I worry that this might not catch other loading scenarios
- // though, like if the species/color/pose is in the GQL cache, but the
- // items are still loading in... not sure where this would happen tho!
- const debouncedOutfitStateIsSaveable =
- debouncedOutfitState.speciesId &&
- debouncedOutfitState.colorId &&
- debouncedOutfitState.pose;
- React.useEffect(() => {
- if (
- !isNewOutfit &&
- canSaveOutfit &&
- !isSaving &&
- !saveError &&
- debouncedOutfitStateIsSaveable &&
- !outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
- ) {
- console.info(
- "[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
- outfitState.savedOutfitState,
- debouncedOutfitState,
- );
- saveOutfitFromProvidedState(debouncedOutfitState);
- }
- }, [
- isNewOutfit,
- canSaveOutfit,
- isSaving,
- saveError,
- debouncedOutfitState,
- debouncedOutfitStateIsSaveable,
- outfitState.savedOutfitState,
- saveOutfitFromProvidedState,
- ]);
+ // Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
+ // which only contains the basic fields, and will keep a stable object
+ // identity until actual changes occur. Then, save the outfit after the user
+ // has left it alone for long enough, so long as it's actually different
+ // than the saved state.
+ const debouncedOutfitState = useDebounce(
+ outfitState.outfitStateWithoutExtras,
+ 2000,
+ {
+ // When the outfit ID changes, update the debounced state immediately!
+ forceReset: (debouncedOutfitState, newOutfitState) =>
+ debouncedOutfitState.id !== newOutfitState.id,
+ },
+ );
+ // HACK: This prevents us from auto-saving the outfit state that's still
+ // loading. I worry that this might not catch other loading scenarios
+ // though, like if the species/color/pose is in the GQL cache, but the
+ // items are still loading in... not sure where this would happen tho!
+ const debouncedOutfitStateIsSaveable =
+ debouncedOutfitState.speciesId &&
+ debouncedOutfitState.colorId &&
+ debouncedOutfitState.pose;
+ React.useEffect(() => {
+ if (
+ !isNewOutfit &&
+ canSaveOutfit &&
+ !isSaving &&
+ !saveError &&
+ debouncedOutfitStateIsSaveable &&
+ !outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
+ ) {
+ console.info(
+ "[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
+ outfitState.savedOutfitState,
+ debouncedOutfitState,
+ );
+ saveOutfitFromProvidedState(debouncedOutfitState);
+ }
+ }, [
+ isNewOutfit,
+ canSaveOutfit,
+ isSaving,
+ saveError,
+ debouncedOutfitState,
+ debouncedOutfitStateIsSaveable,
+ outfitState.savedOutfitState,
+ saveOutfitFromProvidedState,
+ ]);
- // When the outfit changes, clear out the error state from previous saves.
- // We'll send the mutation again after the debounce, and we don't want to
- // show the error UI in the meantime!
- const resetMutation = saveOutfitMutation.reset;
- React.useEffect(
- () => resetMutation(),
- [outfitState.outfitStateWithoutExtras, resetMutation],
- );
+ // When the outfit changes, clear out the error state from previous saves.
+ // We'll send the mutation again after the debounce, and we don't want to
+ // show the error UI in the meantime!
+ const resetMutation = saveOutfitMutation.reset;
+ React.useEffect(
+ () => resetMutation(),
+ [outfitState.outfitStateWithoutExtras, resetMutation],
+ );
- return {
- canSaveOutfit,
- canDeleteOutfit,
- isNewOutfit,
- isSaving,
- latestVersionIsSaved,
- saveError,
- saveOutfit,
- };
+ return {
+ canSaveOutfit,
+ canDeleteOutfit,
+ isNewOutfit,
+ isSaving,
+ latestVersionIsSaved,
+ saveError,
+ saveOutfit,
+ };
}
export default useOutfitSaving;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
index 972927bb..62b29659 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
@@ -12,584 +12,584 @@ enableMapSet();
export const OutfitStateContext = React.createContext(null);
function useOutfitState() {
- const apolloClient = useApolloClient();
- const navigate = useNavigate();
+ const apolloClient = useApolloClient();
+ const navigate = useNavigate();
- const urlOutfitState = useParseOutfitUrl();
- const [localOutfitState, dispatchToOutfit] = React.useReducer(
- outfitStateReducer(apolloClient),
- urlOutfitState,
- );
+ const urlOutfitState = useParseOutfitUrl();
+ const [localOutfitState, dispatchToOutfit] = React.useReducer(
+ outfitStateReducer(apolloClient),
+ urlOutfitState,
+ );
- // If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
- // about the outfit. We'll use it to initialize the local state.
- const {
- isLoading: outfitLoading,
- error: outfitError,
- data: outfitData,
- status: outfitStatus,
- } = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null });
+ // If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
+ // about the outfit. We'll use it to initialize the local state.
+ const {
+ isLoading: outfitLoading,
+ error: outfitError,
+ data: outfitData,
+ status: outfitStatus,
+ } = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null });
- const creator = outfitData?.creator;
- const updatedAt = outfitData?.updatedAt;
+ const creator = outfitData?.creator;
+ const updatedAt = outfitData?.updatedAt;
- // We memoize this to make `outfitStateWithoutExtras` an even more reliable
- // stable object!
- const savedOutfitState = React.useMemo(
- () => getOutfitStateFromOutfitData(outfitData),
- [outfitData],
- );
+ // We memoize this to make `outfitStateWithoutExtras` an even more reliable
+ // stable object!
+ const savedOutfitState = React.useMemo(
+ () => getOutfitStateFromOutfitData(outfitData),
+ [outfitData],
+ );
- // When the saved outfit data comes in for the first time, we reset the local
- // outfit state to match. (We don't reset it on subsequent outfit data
- // updates, e.g. when an outfit saves and the response comes back from the
- // server, because then we could be in a loop of replacing the local state
- // with the persisted state if the user makes changes in the meantime!)
- //
- // HACK: I feel like not having species/color is one of the best ways to tell
- // if we're replacing an incomplete outfit state… but it feels a bit fragile
- // and not-quite-what-we-mean.
- //
- // TODO: I forget the details of why we have both resetting the local state,
- // and a thing where we fallback between the different kinds of outfit state.
- // Probably something about SSR when we were on Next.js? Could be simplified?
- React.useEffect(() => {
- if (
- outfitStatus === "success" &&
- localOutfitState.speciesId == null &&
- localOutfitState.colorId == null
- ) {
- dispatchToOutfit({
- type: "resetToSavedOutfitData",
- savedOutfitData: outfitData,
- });
- }
- }, [outfitStatus, outfitData, localOutfitState]);
+ // When the saved outfit data comes in for the first time, we reset the local
+ // outfit state to match. (We don't reset it on subsequent outfit data
+ // updates, e.g. when an outfit saves and the response comes back from the
+ // server, because then we could be in a loop of replacing the local state
+ // with the persisted state if the user makes changes in the meantime!)
+ //
+ // HACK: I feel like not having species/color is one of the best ways to tell
+ // if we're replacing an incomplete outfit state… but it feels a bit fragile
+ // and not-quite-what-we-mean.
+ //
+ // TODO: I forget the details of why we have both resetting the local state,
+ // and a thing where we fallback between the different kinds of outfit state.
+ // Probably something about SSR when we were on Next.js? Could be simplified?
+ React.useEffect(() => {
+ if (
+ outfitStatus === "success" &&
+ localOutfitState.speciesId == null &&
+ localOutfitState.colorId == null
+ ) {
+ dispatchToOutfit({
+ type: "resetToSavedOutfitData",
+ savedOutfitData: outfitData,
+ });
+ }
+ }, [outfitStatus, outfitData, localOutfitState]);
- // Choose which customization state to use. We want it to match the outfit in
- // the URL immediately, without having to wait for any effects, to avoid race
- // conditions!
- //
- // The reducer is generally the main source of truth for live changes!
- //
- // But if:
- // - it's not initialized yet (e.g. the first frame of navigating to an
- // outfit from Your Outfits), or
- // - it's for a different outfit than the URL says (e.g. clicking Back
- // or Forward to switch between saved outfits),
- //
- // Then use saved outfit data or the URL query string instead, because that's
- // a better representation of the outfit in the URL. (If the saved outfit
- // data isn't loaded yet, then this will be a customization state with
- // partial data, and that's okay.)
- console.debug(
- `[useOutfitState] Outfit states:\n- Local: %o\n- Saved: %o\n- URL: %o`,
- localOutfitState,
- savedOutfitState,
- urlOutfitState,
- );
- let outfitState;
- if (
- urlOutfitState.id === localOutfitState.id &&
- localOutfitState.speciesId != null &&
- localOutfitState.colorId != null
- ) {
- // Use the reducer state: they're both for the same saved outfit, or both
- // for an unsaved outfit (null === null). But we don't use it when it's
- // *only* got the ID, and no other fields yet.
- console.debug(
- "[useOutfitState] Choosing local outfit state",
- localOutfitState,
- );
- outfitState = localOutfitState;
- } else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
- // Use the saved outfit state: it's for the saved outfit the URL points to.
- console.debug(
- "[useOutfitState] Choosing saved outfit state",
- savedOutfitState,
- );
- outfitState = savedOutfitState;
- } else {
- // Use the URL state: it's more up-to-date than any of the others. (Worst
- // case, it's empty except for ID, which is fine while the saved outfit
- // data loads!)
- console.debug(
- "[useOutfitState] Choosing URL outfit state",
- urlOutfitState,
- savedOutfitState,
- );
- outfitState = urlOutfitState;
- }
+ // Choose which customization state to use. We want it to match the outfit in
+ // the URL immediately, without having to wait for any effects, to avoid race
+ // conditions!
+ //
+ // The reducer is generally the main source of truth for live changes!
+ //
+ // But if:
+ // - it's not initialized yet (e.g. the first frame of navigating to an
+ // outfit from Your Outfits), or
+ // - it's for a different outfit than the URL says (e.g. clicking Back
+ // or Forward to switch between saved outfits),
+ //
+ // Then use saved outfit data or the URL query string instead, because that's
+ // a better representation of the outfit in the URL. (If the saved outfit
+ // data isn't loaded yet, then this will be a customization state with
+ // partial data, and that's okay.)
+ console.debug(
+ `[useOutfitState] Outfit states:\n- Local: %o\n- Saved: %o\n- URL: %o`,
+ localOutfitState,
+ savedOutfitState,
+ urlOutfitState,
+ );
+ let outfitState;
+ if (
+ urlOutfitState.id === localOutfitState.id &&
+ localOutfitState.speciesId != null &&
+ localOutfitState.colorId != null
+ ) {
+ // Use the reducer state: they're both for the same saved outfit, or both
+ // for an unsaved outfit (null === null). But we don't use it when it's
+ // *only* got the ID, and no other fields yet.
+ console.debug(
+ "[useOutfitState] Choosing local outfit state",
+ localOutfitState,
+ );
+ outfitState = localOutfitState;
+ } else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
+ // Use the saved outfit state: it's for the saved outfit the URL points to.
+ console.debug(
+ "[useOutfitState] Choosing saved outfit state",
+ savedOutfitState,
+ );
+ outfitState = savedOutfitState;
+ } else {
+ // Use the URL state: it's more up-to-date than any of the others. (Worst
+ // case, it's empty except for ID, which is fine while the saved outfit
+ // data loads!)
+ console.debug(
+ "[useOutfitState] Choosing URL outfit state",
+ urlOutfitState,
+ savedOutfitState,
+ );
+ outfitState = urlOutfitState;
+ }
- // When unpacking the customization state, we call `Array.from` on our item
- // IDs. It's more convenient to manage them as a Set in state, but most
- // callers will find it more convenient to access them as arrays! e.g. for
- // `.map()`.
- const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } =
- outfitState;
- const wornItemIds = Array.from(outfitState.wornItemIds);
- const closetedItemIds = Array.from(outfitState.closetedItemIds);
- const allItemIds = [...wornItemIds, ...closetedItemIds];
+ // When unpacking the customization state, we call `Array.from` on our item
+ // IDs. It's more convenient to manage them as a Set in state, but most
+ // callers will find it more convenient to access them as arrays! e.g. for
+ // `.map()`.
+ const { id, name, speciesId, colorId, pose, altStyleId, appearanceId } =
+ outfitState;
+ const wornItemIds = Array.from(outfitState.wornItemIds);
+ const closetedItemIds = Array.from(outfitState.closetedItemIds);
+ const allItemIds = [...wornItemIds, ...closetedItemIds];
- const {
- loading: itemsLoading,
- error: itemsError,
- data: itemsData,
- } = useQuery(
- gql`
- query OutfitStateItems(
- $allItemIds: [ID!]!
- $speciesId: ID!
- $colorId: ID!
- $altStyleId: ID
- ) {
- items(ids: $allItemIds) {
- # TODO: De-dupe this from SearchPanel?
- id
- name
- thumbnailUrl
- isNc
- isPb
- currentUserOwnsThis
- currentUserWantsThis
+ const {
+ loading: itemsLoading,
+ error: itemsError,
+ data: itemsData,
+ } = useQuery(
+ gql`
+ query OutfitStateItems(
+ $allItemIds: [ID!]!
+ $speciesId: ID!
+ $colorId: ID!
+ $altStyleId: ID
+ ) {
+ items(ids: $allItemIds) {
+ # TODO: De-dupe this from SearchPanel?
+ id
+ name
+ thumbnailUrl
+ isNc
+ isPb
+ currentUserOwnsThis
+ currentUserWantsThis
- appearanceOn(
- speciesId: $speciesId
- colorId: $colorId
- altStyleId: $altStyleId
- ) {
- # This enables us to quickly show the item when the user clicks it!
- ...ItemAppearanceForOutfitPreview
+ appearanceOn(
+ speciesId: $speciesId
+ colorId: $colorId
+ altStyleId: $altStyleId
+ ) {
+ # This enables us to quickly show the item when the user clicks it!
+ ...ItemAppearanceForOutfitPreview
- # This is used to group items by zone, and to detect conflicts when
- # wearing a new item.
- layers {
- zone {
- id
- label
- }
- }
- restrictedZones {
- id
- label
- isCommonlyUsedByItems
- }
- }
- }
+ # This is used to group items by zone, and to detect conflicts when
+ # wearing a new item.
+ layers {
+ zone {
+ id
+ label
+ }
+ }
+ restrictedZones {
+ id
+ label
+ isCommonlyUsedByItems
+ }
+ }
+ }
- # NOTE: We skip this query if items is empty for perf reasons. If
- # you're adding more fields, consider changing that condition!
- }
- ${itemAppearanceFragment}
- `,
- {
- variables: { allItemIds, speciesId, colorId, altStyleId },
- context: { sendAuth: true },
- // Skip if this outfit has no items, as an optimization; or if we don't
- // have the species/color ID loaded yet because we're waiting on the
- // saved outfit to load.
- skip: allItemIds.length === 0 || speciesId == null || colorId == null,
- },
- );
+ # NOTE: We skip this query if items is empty for perf reasons. If
+ # you're adding more fields, consider changing that condition!
+ }
+ ${itemAppearanceFragment}
+ `,
+ {
+ variables: { allItemIds, speciesId, colorId, altStyleId },
+ context: { sendAuth: true },
+ // Skip if this outfit has no items, as an optimization; or if we don't
+ // have the species/color ID loaded yet because we're waiting on the
+ // saved outfit to load.
+ skip: allItemIds.length === 0 || speciesId == null || colorId == null,
+ },
+ );
- const resultItems = itemsData?.items || [];
+ const resultItems = itemsData?.items || [];
- // Okay, time for some big perf hacks! Lower down in the app, we use
- // React.memo to avoid re-rendering Item components if the items haven't
- // updated. In simpler cases, we just make the component take the individual
- // item fields as props... but items are complex and that makes it annoying
- // :p Instead, we do these tricks to reuse physical item objects if they're
- // still deep-equal to the previous version. This is because React.memo uses
- // object identity to compare its props, so now when it checks whether
- // `oldItem === newItem`, the answer will be `true`, unless the item really
- // _did_ change!
- const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
- let items = resultItems.map((item) => {
- const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
- if (
- cachedItemObject &&
- JSON.stringify(cachedItemObject) === JSON.stringify(item)
- ) {
- return cachedItemObject;
- }
- return item;
- });
- if (
- items.length === cachedItemObjects.length &&
- items.every((_, index) => items[index] === cachedItemObjects[index])
- ) {
- // Even reuse the entire array if none of the items changed!
- items = cachedItemObjects;
- }
- React.useEffect(() => {
- setCachedItemObjects(items);
- }, [items, setCachedItemObjects]);
+ // Okay, time for some big perf hacks! Lower down in the app, we use
+ // React.memo to avoid re-rendering Item components if the items haven't
+ // updated. In simpler cases, we just make the component take the individual
+ // item fields as props... but items are complex and that makes it annoying
+ // :p Instead, we do these tricks to reuse physical item objects if they're
+ // still deep-equal to the previous version. This is because React.memo uses
+ // object identity to compare its props, so now when it checks whether
+ // `oldItem === newItem`, the answer will be `true`, unless the item really
+ // _did_ change!
+ const [cachedItemObjects, setCachedItemObjects] = React.useState([]);
+ let items = resultItems.map((item) => {
+ const cachedItemObject = cachedItemObjects.find((i) => i.id === item.id);
+ if (
+ cachedItemObject &&
+ JSON.stringify(cachedItemObject) === JSON.stringify(item)
+ ) {
+ return cachedItemObject;
+ }
+ return item;
+ });
+ if (
+ items.length === cachedItemObjects.length &&
+ items.every((_, index) => items[index] === cachedItemObjects[index])
+ ) {
+ // Even reuse the entire array if none of the items changed!
+ items = cachedItemObjects;
+ }
+ React.useEffect(() => {
+ setCachedItemObjects(items);
+ }, [items, setCachedItemObjects]);
- const itemsById = {};
- for (const item of items) {
- itemsById[item.id] = item;
- }
+ const itemsById = {};
+ for (const item of items) {
+ itemsById[item.id] = item;
+ }
- const zonesAndItems = getZonesAndItems(
- itemsById,
- wornItemIds,
- closetedItemIds,
- );
- const incompatibleItems = items
- .filter((i) => i.appearanceOn.layers.length === 0)
- .sort((a, b) => a.name.localeCompare(b.name));
+ const zonesAndItems = getZonesAndItems(
+ itemsById,
+ wornItemIds,
+ closetedItemIds,
+ );
+ const incompatibleItems = items
+ .filter((i) => i.appearanceOn.layers.length === 0)
+ .sort((a, b) => a.name.localeCompare(b.name));
- const url = buildOutfitUrl(outfitState);
+ const url = buildOutfitUrl(outfitState);
- const outfitStateWithExtras = {
- id,
- creator,
- updatedAt,
- zonesAndItems,
- incompatibleItems,
- name,
- wornItemIds,
- closetedItemIds,
- allItemIds,
- speciesId,
- colorId,
- pose,
- altStyleId,
- appearanceId,
- url,
+ const outfitStateWithExtras = {
+ id,
+ creator,
+ updatedAt,
+ zonesAndItems,
+ incompatibleItems,
+ name,
+ wornItemIds,
+ closetedItemIds,
+ allItemIds,
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ appearanceId,
+ url,
- // We use this plain outfit state objects in `useOutfitSaving`! Unlike the
- // full `outfitState` object, which we rebuild each render,
- // `outfitStateWithoutExtras` will mostly only change when there is an
- // actual change to outfit state.
- outfitStateWithoutExtras: outfitState,
- savedOutfitState,
- };
+ // We use this plain outfit state objects in `useOutfitSaving`! Unlike the
+ // full `outfitState` object, which we rebuild each render,
+ // `outfitStateWithoutExtras` will mostly only change when there is an
+ // actual change to outfit state.
+ outfitStateWithoutExtras: outfitState,
+ savedOutfitState,
+ };
- // Keep the URL up-to-date.
- const path = buildOutfitPath(outfitState);
- React.useEffect(() => {
- console.debug(`[useOutfitState] Navigating to latest outfit path:`, path);
- navigate(path, { replace: true });
- }, [path, navigate]);
+ // Keep the URL up-to-date.
+ const path = buildOutfitPath(outfitState);
+ React.useEffect(() => {
+ console.debug(`[useOutfitState] Navigating to latest outfit path:`, path);
+ navigate(path, { replace: true });
+ }, [path, navigate]);
- return {
- loading: outfitLoading || itemsLoading,
- error: outfitError || itemsError,
- outfitState: outfitStateWithExtras,
- dispatchToOutfit,
- };
+ return {
+ loading: outfitLoading || itemsLoading,
+ error: outfitError || itemsError,
+ outfitState: outfitStateWithExtras,
+ dispatchToOutfit,
+ };
}
const outfitStateReducer = (apolloClient) => (baseState, action) => {
- console.info("[useOutfitState] Action:", action);
- switch (action.type) {
- case "rename":
- return produce(baseState, (state) => {
- state.name = action.outfitName;
- });
- case "setSpeciesAndColor":
- return produce(baseState, (state) => {
- state.speciesId = action.speciesId;
- state.colorId = action.colorId;
- state.pose = action.pose;
- state.altStyleId = null;
- state.appearanceId = null;
- });
- case "wearItem":
- return produce(baseState, (state) => {
- const { wornItemIds, closetedItemIds } = state;
- const { itemId, itemIdsToReconsider = [] } = action;
+ console.info("[useOutfitState] Action:", action);
+ switch (action.type) {
+ case "rename":
+ return produce(baseState, (state) => {
+ state.name = action.outfitName;
+ });
+ case "setSpeciesAndColor":
+ return produce(baseState, (state) => {
+ state.speciesId = action.speciesId;
+ state.colorId = action.colorId;
+ state.pose = action.pose;
+ state.altStyleId = null;
+ state.appearanceId = null;
+ });
+ case "wearItem":
+ return produce(baseState, (state) => {
+ const { wornItemIds, closetedItemIds } = state;
+ const { itemId, itemIdsToReconsider = [] } = action;
- // Move conflicting items to the closet.
- //
- // We do this by looking them up in the Apollo Cache, which is going to
- // include the relevant item data because the `useOutfitState` hook
- // queries for it!
- //
- // (It could be possible to mess up the timing by taking an action
- // while worn items are still partially loading, but I think it would
- // require a pretty weird action sequence to make that happen... like,
- // doing a search and it loads before the worn item data does? Anyway,
- // Apollo will throw in that case, which should just essentially reject
- // the action.)
- let conflictingIds;
- try {
- conflictingIds = findItemConflicts(itemId, state, apolloClient);
- } catch (e) {
- console.error(e);
- return;
- }
- for (const conflictingId of conflictingIds) {
- wornItemIds.delete(conflictingId);
- closetedItemIds.add(conflictingId);
- }
+ // Move conflicting items to the closet.
+ //
+ // We do this by looking them up in the Apollo Cache, which is going to
+ // include the relevant item data because the `useOutfitState` hook
+ // queries for it!
+ //
+ // (It could be possible to mess up the timing by taking an action
+ // while worn items are still partially loading, but I think it would
+ // require a pretty weird action sequence to make that happen... like,
+ // doing a search and it loads before the worn item data does? Anyway,
+ // Apollo will throw in that case, which should just essentially reject
+ // the action.)
+ let conflictingIds;
+ try {
+ conflictingIds = findItemConflicts(itemId, state, apolloClient);
+ } catch (e) {
+ console.error(e);
+ return;
+ }
+ for (const conflictingId of conflictingIds) {
+ wornItemIds.delete(conflictingId);
+ closetedItemIds.add(conflictingId);
+ }
- // Move this item from the closet to the worn set.
- closetedItemIds.delete(itemId);
- wornItemIds.add(itemId);
+ // Move this item from the closet to the worn set.
+ closetedItemIds.delete(itemId);
+ wornItemIds.add(itemId);
- reconsiderItems(itemIdsToReconsider, state, apolloClient);
- });
- case "unwearItem":
- return produce(baseState, (state) => {
- const { wornItemIds, closetedItemIds } = state;
- const { itemId, itemIdsToReconsider = [] } = action;
+ reconsiderItems(itemIdsToReconsider, state, apolloClient);
+ });
+ case "unwearItem":
+ return produce(baseState, (state) => {
+ const { wornItemIds, closetedItemIds } = state;
+ const { itemId, itemIdsToReconsider = [] } = action;
- // Move this item from the worn set to the closet.
- wornItemIds.delete(itemId);
- closetedItemIds.add(itemId);
+ // Move this item from the worn set to the closet.
+ wornItemIds.delete(itemId);
+ closetedItemIds.add(itemId);
- reconsiderItems(
- // Don't include the unworn item in items to reconsider!
- itemIdsToReconsider.filter((x) => x !== itemId),
- state,
- apolloClient,
- );
- });
- case "removeItem":
- return produce(baseState, (state) => {
- const { wornItemIds, closetedItemIds } = state;
- const { itemId, itemIdsToReconsider = [] } = action;
+ reconsiderItems(
+ // Don't include the unworn item in items to reconsider!
+ itemIdsToReconsider.filter((x) => x !== itemId),
+ state,
+ apolloClient,
+ );
+ });
+ case "removeItem":
+ return produce(baseState, (state) => {
+ const { wornItemIds, closetedItemIds } = state;
+ const { itemId, itemIdsToReconsider = [] } = action;
- // Remove this item from both the worn set and the closet.
- wornItemIds.delete(itemId);
- closetedItemIds.delete(itemId);
+ // Remove this item from both the worn set and the closet.
+ wornItemIds.delete(itemId);
+ closetedItemIds.delete(itemId);
- reconsiderItems(
- // Don't include the removed item in items to reconsider!
- itemIdsToReconsider.filter((x) => x !== itemId),
- state,
- apolloClient,
- );
- });
- case "setPose":
- return produce(baseState, (state) => {
- state.pose = action.pose;
- // Usually only the `pose` is specified, but `PosePickerSupport` can
- // also specify a corresponding `appearanceId`, to get even more
- // particular about which version of the pose to show if more than one.
- state.appearanceId = action.appearanceId || null;
- });
- case "setStyle":
- return produce(baseState, (state) => {
- state.altStyleId = action.altStyleId;
- });
- case "resetToSavedOutfitData":
- return getOutfitStateFromOutfitData(action.savedOutfitData);
- case "handleOutfitSaveResponse":
- return produce(baseState, (state) => {
- const { outfitData } = action;
+ reconsiderItems(
+ // Don't include the removed item in items to reconsider!
+ itemIdsToReconsider.filter((x) => x !== itemId),
+ state,
+ apolloClient,
+ );
+ });
+ case "setPose":
+ return produce(baseState, (state) => {
+ state.pose = action.pose;
+ // Usually only the `pose` is specified, but `PosePickerSupport` can
+ // also specify a corresponding `appearanceId`, to get even more
+ // particular about which version of the pose to show if more than one.
+ state.appearanceId = action.appearanceId || null;
+ });
+ case "setStyle":
+ return produce(baseState, (state) => {
+ state.altStyleId = action.altStyleId;
+ });
+ case "resetToSavedOutfitData":
+ return getOutfitStateFromOutfitData(action.savedOutfitData);
+ case "handleOutfitSaveResponse":
+ return produce(baseState, (state) => {
+ const { outfitData } = action;
- // If this is a save result for a different outfit, ignore it.
- if (state.id != null && outfitData.id != state.id) {
- return;
- }
+ // If this is a save result for a different outfit, ignore it.
+ if (state.id != null && outfitData.id != state.id) {
+ return;
+ }
- // Otherwise, update the local outfit to match the fields the server
- // controls: it chooses the ID for new outfits, and it can choose a
- // different name if ours was already in use.
- state.id = outfitData.id;
- state.name = outfitData.name;
+ // Otherwise, update the local outfit to match the fields the server
+ // controls: it chooses the ID for new outfits, and it can choose a
+ // different name if ours was already in use.
+ state.id = outfitData.id;
+ state.name = outfitData.name;
- // The server also tries to lock the outfit to a specific appearanceId
- // for the given species/color/pose. Accept that change too—but only if
- // we haven't already changed species/color/pose since then!
- if (
- state.speciesId == outfitData.speciesId &&
- state.colorId == outfitData.colorId &&
- state.pose == outfitData.pose
- ) {
- state.appearanceId = outfitData.appearanceId;
- }
- });
- default:
- throw new Error(`unexpected action ${JSON.stringify(action)}`);
- }
+ // The server also tries to lock the outfit to a specific appearanceId
+ // for the given species/color/pose. Accept that change too—but only if
+ // we haven't already changed species/color/pose since then!
+ if (
+ state.speciesId == outfitData.speciesId &&
+ state.colorId == outfitData.colorId &&
+ state.pose == outfitData.pose
+ ) {
+ state.appearanceId = outfitData.appearanceId;
+ }
+ });
+ default:
+ throw new Error(`unexpected action ${JSON.stringify(action)}`);
+ }
};
const EMPTY_CUSTOMIZATION_STATE = {
- id: null,
- name: null,
- speciesId: null,
- colorId: null,
- pose: null,
- appearanceId: null,
- wornItemIds: [],
- closetedItemIds: [],
+ id: null,
+ name: null,
+ speciesId: null,
+ colorId: null,
+ pose: null,
+ appearanceId: null,
+ wornItemIds: [],
+ closetedItemIds: [],
};
function useParseOutfitUrl() {
- // Get params from both the `?a=1` and `#a=1` parts of the URL, because DTI
- // has historically used both!
- const location = useLocation();
- const [justSearchParams] = useSearchParams();
- const hashParams = new URLSearchParams(location.hash.slice(1));
+ // Get params from both the `?a=1` and `#a=1` parts of the URL, because DTI
+ // has historically used both!
+ const location = useLocation();
+ const [justSearchParams] = useSearchParams();
+ const hashParams = new URLSearchParams(location.hash.slice(1));
- // Merge them into one URLSearchParams object.
- const mergedParams = new URLSearchParams();
- for (const [key, value] of justSearchParams) {
- mergedParams.append(key, value);
- }
- for (const [key, value] of hashParams) {
- mergedParams.append(key, value);
- }
+ // Merge them into one URLSearchParams object.
+ const mergedParams = new URLSearchParams();
+ for (const [key, value] of justSearchParams) {
+ mergedParams.append(key, value);
+ }
+ for (const [key, value] of hashParams) {
+ mergedParams.append(key, value);
+ }
- // We memoize this to make `outfitStateWithoutExtras` an even more reliable
- // stable object!
- const memoizedOutfitState = React.useMemo(
- () => readOutfitStateFromSearchParams(location.pathname, mergedParams),
- // TODO: This hook is reliable as-is, I think… but is there a simpler way
- // to make it obvious that it is?
- // eslint-disable-next-line react-hooks/exhaustive-deps
- [location.pathname, mergedParams.toString()],
- );
+ // We memoize this to make `outfitStateWithoutExtras` an even more reliable
+ // stable object!
+ const memoizedOutfitState = React.useMemo(
+ () => readOutfitStateFromSearchParams(location.pathname, mergedParams),
+ // TODO: This hook is reliable as-is, I think… but is there a simpler way
+ // to make it obvious that it is?
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [location.pathname, mergedParams.toString()],
+ );
- return memoizedOutfitState;
+ return memoizedOutfitState;
}
function readOutfitStateFromSearchParams(pathname, searchParams) {
- // For the /outfits/:id page, ignore the query string, and just wait for the
- // outfit data to load in!
- const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/);
- if (pathnameMatch) {
- return {
- ...EMPTY_CUSTOMIZATION_STATE,
- id: pathnameMatch[1],
- };
- }
+ // For the /outfits/:id page, ignore the query string, and just wait for the
+ // outfit data to load in!
+ const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/);
+ if (pathnameMatch) {
+ return {
+ ...EMPTY_CUSTOMIZATION_STATE,
+ id: pathnameMatch[1],
+ };
+ }
- // Otherwise, parse the query string, and fill in default values for anything
- // not specified.
- return {
- id: searchParams.get("outfit"),
- name: searchParams.get("name"),
- speciesId: searchParams.get("species") || "1",
- colorId: searchParams.get("color") || "8",
- pose: searchParams.get("pose") || "HAPPY_FEM",
- altStyleId: searchParams.get("style") || null,
- appearanceId: searchParams.get("state") || null,
- wornItemIds: new Set(searchParams.getAll("objects[]")),
- closetedItemIds: new Set(searchParams.getAll("closet[]")),
- };
+ // Otherwise, parse the query string, and fill in default values for anything
+ // not specified.
+ return {
+ id: searchParams.get("outfit"),
+ name: searchParams.get("name"),
+ speciesId: searchParams.get("species") || "1",
+ colorId: searchParams.get("color") || "8",
+ pose: searchParams.get("pose") || "HAPPY_FEM",
+ altStyleId: searchParams.get("style") || null,
+ appearanceId: searchParams.get("state") || null,
+ wornItemIds: new Set(searchParams.getAll("objects[]")),
+ closetedItemIds: new Set(searchParams.getAll("closet[]")),
+ };
}
function getOutfitStateFromOutfitData(outfit) {
- if (!outfit) {
- return EMPTY_CUSTOMIZATION_STATE;
- }
+ if (!outfit) {
+ return EMPTY_CUSTOMIZATION_STATE;
+ }
- return {
- id: outfit.id,
- name: outfit.name,
- speciesId: outfit.speciesId,
- colorId: outfit.colorId,
- pose: outfit.pose,
- appearanceId: outfit.appearanceId,
- altStyleId: outfit.altStyleId,
- wornItemIds: new Set(outfit.wornItemIds),
- closetedItemIds: new Set(outfit.closetedItemIds),
- };
+ return {
+ id: outfit.id,
+ name: outfit.name,
+ speciesId: outfit.speciesId,
+ colorId: outfit.colorId,
+ pose: outfit.pose,
+ appearanceId: outfit.appearanceId,
+ altStyleId: outfit.altStyleId,
+ wornItemIds: new Set(outfit.wornItemIds),
+ closetedItemIds: new Set(outfit.closetedItemIds),
+ };
}
function findItemConflicts(itemIdToAdd, state, apolloClient) {
- const { wornItemIds, speciesId, colorId, altStyleId } = state;
+ const { wornItemIds, speciesId, colorId, altStyleId } = state;
- const itemIds = [itemIdToAdd, ...wornItemIds];
- const data = apolloClient.readQuery({
- query: gql`
- query OutfitStateItemConflicts(
- $itemIds: [ID!]!
- $speciesId: ID!
- $colorId: ID!
- $altStyleId: ID
- ) {
- items(ids: $itemIds) {
- id
- appearanceOn(
- speciesId: $speciesId
- colorId: $colorId
- altStyleId: $altStyleId
- ) {
- layers {
- zone {
- id
- }
- }
+ const itemIds = [itemIdToAdd, ...wornItemIds];
+ const data = apolloClient.readQuery({
+ query: gql`
+ query OutfitStateItemConflicts(
+ $itemIds: [ID!]!
+ $speciesId: ID!
+ $colorId: ID!
+ $altStyleId: ID
+ ) {
+ items(ids: $itemIds) {
+ id
+ appearanceOn(
+ speciesId: $speciesId
+ colorId: $colorId
+ altStyleId: $altStyleId
+ ) {
+ layers {
+ zone {
+ id
+ }
+ }
- restrictedZones {
- id
- }
- }
- }
- }
- `,
- variables: {
- itemIds,
- speciesId,
- colorId,
- altStyleId,
- },
- });
- if (data == null) {
- throw new Error(
- `[findItemConflicts] Cache lookup failed for: ` +
- `items=${itemIds.join(",")}, speciesId=${speciesId}, ` +
- `colorId=${colorId}, altStyleId=${altStyleId}`,
- );
- }
+ restrictedZones {
+ id
+ }
+ }
+ }
+ }
+ `,
+ variables: {
+ itemIds,
+ speciesId,
+ colorId,
+ altStyleId,
+ },
+ });
+ if (data == null) {
+ throw new Error(
+ `[findItemConflicts] Cache lookup failed for: ` +
+ `items=${itemIds.join(",")}, speciesId=${speciesId}, ` +
+ `colorId=${colorId}, altStyleId=${altStyleId}`,
+ );
+ }
- const { items } = data;
- const itemToAdd = items.find((i) => i.id === itemIdToAdd);
- if (!itemToAdd.appearanceOn) {
- return [];
- }
- const wornItems = Array.from(wornItemIds).map((id) =>
- items.find((i) => i.id === id),
- );
+ const { items } = data;
+ const itemToAdd = items.find((i) => i.id === itemIdToAdd);
+ if (!itemToAdd.appearanceOn) {
+ return [];
+ }
+ const wornItems = Array.from(wornItemIds).map((id) =>
+ items.find((i) => i.id === id),
+ );
- const itemToAddZoneSets = getItemZones(itemToAdd);
+ const itemToAddZoneSets = getItemZones(itemToAdd);
- const conflictingIds = [];
- for (const wornItem of wornItems) {
- if (!wornItem.appearanceOn) {
- continue;
- }
+ const conflictingIds = [];
+ for (const wornItem of wornItems) {
+ if (!wornItem.appearanceOn) {
+ continue;
+ }
- const wornItemZoneSets = getItemZones(wornItem);
+ const wornItemZoneSets = getItemZones(wornItem);
- const itemsConflict =
- setsIntersect(
- itemToAddZoneSets.occupies,
- wornItemZoneSets.occupiesOrRestricts,
- ) ||
- setsIntersect(
- wornItemZoneSets.occupies,
- itemToAddZoneSets.occupiesOrRestricts,
- );
+ const itemsConflict =
+ setsIntersect(
+ itemToAddZoneSets.occupies,
+ wornItemZoneSets.occupiesOrRestricts,
+ ) ||
+ setsIntersect(
+ wornItemZoneSets.occupies,
+ itemToAddZoneSets.occupiesOrRestricts,
+ );
- if (itemsConflict) {
- conflictingIds.push(wornItem.id);
- }
- }
+ if (itemsConflict) {
+ conflictingIds.push(wornItem.id);
+ }
+ }
- return conflictingIds;
+ return conflictingIds;
}
function getItemZones(item) {
- const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
- const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
- const occupiesOrRestricts = new Set([...occupies, ...restricts]);
- return { occupies, occupiesOrRestricts };
+ const occupies = new Set(item.appearanceOn.layers.map((l) => l.zone.id));
+ const restricts = new Set(item.appearanceOn.restrictedZones.map((z) => z.id));
+ const occupiesOrRestricts = new Set([...occupies, ...restricts]);
+ return { occupies, occupiesOrRestricts };
}
function setsIntersect(a, b) {
- for (const el of a) {
- if (b.has(el)) {
- return true;
- }
- }
- return false;
+ for (const el of a) {
+ if (b.has(el)) {
+ return true;
+ }
+ }
+ return false;
}
/**
@@ -602,180 +602,180 @@ function setsIntersect(a, b) {
* immer block, in which case mutation is the simplest API!
*/
function reconsiderItems(itemIdsToReconsider, state, apolloClient) {
- for (const itemIdToReconsider of itemIdsToReconsider) {
- const conflictingIds = findItemConflicts(
- itemIdToReconsider,
- state,
- apolloClient,
- );
- if (conflictingIds.length === 0) {
- state.wornItemIds.add(itemIdToReconsider);
- }
- }
+ for (const itemIdToReconsider of itemIdsToReconsider) {
+ const conflictingIds = findItemConflicts(
+ itemIdToReconsider,
+ state,
+ apolloClient,
+ );
+ if (conflictingIds.length === 0) {
+ state.wornItemIds.add(itemIdToReconsider);
+ }
+ }
}
// TODO: Get this out of here, tbh...
function getZonesAndItems(itemsById, wornItemIds, closetedItemIds) {
- const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
- const closetedItems = closetedItemIds
- .map((id) => itemsById[id])
- .filter((i) => i);
+ const wornItems = wornItemIds.map((id) => itemsById[id]).filter((i) => i);
+ const closetedItems = closetedItemIds
+ .map((id) => itemsById[id])
+ .filter((i) => i);
- // Loop over all the items, grouping them by zone, and also gathering all the
- // zone metadata.
- const allItems = [...wornItems, ...closetedItems];
- const itemsByZone = new Map();
- const zonesById = new Map();
- for (const item of allItems) {
- if (!item.appearanceOn) {
- continue;
- }
+ // Loop over all the items, grouping them by zone, and also gathering all the
+ // zone metadata.
+ const allItems = [...wornItems, ...closetedItems];
+ const itemsByZone = new Map();
+ const zonesById = new Map();
+ for (const item of allItems) {
+ if (!item.appearanceOn) {
+ continue;
+ }
- for (const layer of item.appearanceOn.layers) {
- const zoneId = layer.zone.id;
- zonesById.set(zoneId, layer.zone);
+ for (const layer of item.appearanceOn.layers) {
+ const zoneId = layer.zone.id;
+ zonesById.set(zoneId, layer.zone);
- if (!itemsByZone.has(zoneId)) {
- itemsByZone.set(zoneId, []);
- }
- itemsByZone.get(zoneId).push(item);
- }
- }
+ if (!itemsByZone.has(zoneId)) {
+ itemsByZone.set(zoneId, []);
+ }
+ itemsByZone.get(zoneId).push(item);
+ }
+ }
- // Convert `itemsByZone` into an array of item groups.
- let zonesAndItems = Array.from(itemsByZone.entries()).map(
- ([zoneId, items]) => ({
- zoneId,
- zoneLabel: zonesById.get(zoneId).label,
- items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
- }),
- );
+ // Convert `itemsByZone` into an array of item groups.
+ let zonesAndItems = Array.from(itemsByZone.entries()).map(
+ ([zoneId, items]) => ({
+ zoneId,
+ zoneLabel: zonesById.get(zoneId).label,
+ items: [...items].sort((a, b) => a.name.localeCompare(b.name)),
+ }),
+ );
- // Sort groups by the zone label's alphabetically, and tiebreak by the zone
- // ID. (That way, "Markings (#6)" sorts before "Markings (#16)".) We do this
- // before the data simplification step, because it's useful to have
- // consistent ordering for the algorithm that might choose to skip zones!
- zonesAndItems.sort((a, b) => {
- if (a.zoneLabel !== b.zoneLabel) {
- return a.zoneLabel.localeCompare(b.zoneLabel);
- } else {
- return a.zoneId - b.zoneId;
- }
- });
+ // Sort groups by the zone label's alphabetically, and tiebreak by the zone
+ // ID. (That way, "Markings (#6)" sorts before "Markings (#16)".) We do this
+ // before the data simplification step, because it's useful to have
+ // consistent ordering for the algorithm that might choose to skip zones!
+ zonesAndItems.sort((a, b) => {
+ if (a.zoneLabel !== b.zoneLabel) {
+ return a.zoneLabel.localeCompare(b.zoneLabel);
+ } else {
+ return a.zoneId - b.zoneId;
+ }
+ });
- // Data simplification step! Try to remove zone groups that aren't helpful.
- const groupsWithConflicts = zonesAndItems.filter(
- ({ items }) => items.length > 1,
- );
- const itemIdsWithConflicts = new Set(
- groupsWithConflicts
- .map(({ items }) => items)
- .flat()
- .map((item) => item.id),
- );
- const itemIdsWeHaveSeen = new Set();
- zonesAndItems = zonesAndItems.filter(({ items }) => {
- // We need all groups with more than one item. If there's only one, we get
- // to think harder :)
- if (items.length > 1) {
- items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
- return true;
- }
+ // Data simplification step! Try to remove zone groups that aren't helpful.
+ const groupsWithConflicts = zonesAndItems.filter(
+ ({ items }) => items.length > 1,
+ );
+ const itemIdsWithConflicts = new Set(
+ groupsWithConflicts
+ .map(({ items }) => items)
+ .flat()
+ .map((item) => item.id),
+ );
+ const itemIdsWeHaveSeen = new Set();
+ zonesAndItems = zonesAndItems.filter(({ items }) => {
+ // We need all groups with more than one item. If there's only one, we get
+ // to think harder :)
+ if (items.length > 1) {
+ items.forEach((item) => itemIdsWeHaveSeen.add(item.id));
+ return true;
+ }
- const item = items[0];
+ const item = items[0];
- // Has the item been seen a group we kept, or an upcoming group with
- // multiple conflicting items? If so, skip this group. If not, keep it.
- if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
- return false;
- } else {
- itemIdsWeHaveSeen.add(item.id);
- return true;
- }
- });
+ // Has the item been seen a group we kept, or an upcoming group with
+ // multiple conflicting items? If so, skip this group. If not, keep it.
+ if (itemIdsWeHaveSeen.has(item.id) || itemIdsWithConflicts.has(item.id)) {
+ return false;
+ } else {
+ itemIdsWeHaveSeen.add(item.id);
+ return true;
+ }
+ });
- // Finally, for groups with the same label, append the ID number.
- //
- // First, loop over the groups, to count how many times each zone label is
- // used. Then, loop over them again, appending the ID number if count > 1.
- const labelCounts = new Map();
- for (const itemZoneGroup of zonesAndItems) {
- const { zoneLabel } = itemZoneGroup;
+ // Finally, for groups with the same label, append the ID number.
+ //
+ // First, loop over the groups, to count how many times each zone label is
+ // used. Then, loop over them again, appending the ID number if count > 1.
+ const labelCounts = new Map();
+ for (const itemZoneGroup of zonesAndItems) {
+ const { zoneLabel } = itemZoneGroup;
- const count = labelCounts.get(zoneLabel) ?? 0;
- labelCounts.set(zoneLabel, count + 1);
- }
- for (const itemZoneGroup of zonesAndItems) {
- const { zoneId, zoneLabel } = itemZoneGroup;
+ const count = labelCounts.get(zoneLabel) ?? 0;
+ labelCounts.set(zoneLabel, count + 1);
+ }
+ for (const itemZoneGroup of zonesAndItems) {
+ const { zoneId, zoneLabel } = itemZoneGroup;
- if (labelCounts.get(zoneLabel) > 1) {
- itemZoneGroup.zoneLabel += ` (#${zoneId})`;
- }
- }
+ if (labelCounts.get(zoneLabel) > 1) {
+ itemZoneGroup.zoneLabel += ` (#${zoneId})`;
+ }
+ }
- return zonesAndItems;
+ return zonesAndItems;
}
function buildOutfitPath(outfitState, { withoutOutfitId = false } = {}) {
- const { id } = outfitState;
+ const { id } = outfitState;
- if (id && !withoutOutfitId) {
- return `/outfits/${id}`;
- }
+ if (id && !withoutOutfitId) {
+ return `/outfits/${id}`;
+ }
- return "/outfits/new?" + buildOutfitQueryString(outfitState);
+ return "/outfits/new?" + buildOutfitQueryString(outfitState);
}
export function buildOutfitUrl(outfitState, options = {}) {
- const origin =
- typeof window !== "undefined"
- ? window.location.origin
- : "https://impress.openneo.net";
+ const origin =
+ typeof window !== "undefined"
+ ? window.location.origin
+ : "https://impress.openneo.net";
- return origin + buildOutfitPath(outfitState, options);
+ return origin + buildOutfitPath(outfitState, options);
}
function buildOutfitQueryString(outfitState) {
- const {
- name,
- speciesId,
- colorId,
- pose,
- altStyleId,
- appearanceId,
- wornItemIds,
- closetedItemIds,
- } = outfitState;
+ const {
+ name,
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ appearanceId,
+ wornItemIds,
+ closetedItemIds,
+ } = outfitState;
- const params = new URLSearchParams({
- name: name || "",
- species: speciesId || "",
- color: colorId || "",
- pose: pose || "",
- });
- if (altStyleId != null) {
- params.append("style", altStyleId);
- }
- if (appearanceId != null) {
- // `state` is an old name for compatibility with old-style DTI URLs. It
- // refers to "PetState", the database table name for pet appearances.
- params.append("state", appearanceId);
- }
- for (const itemId of wornItemIds) {
- params.append("objects[]", itemId);
- }
- for (const itemId of closetedItemIds) {
- params.append("closet[]", itemId);
- }
+ const params = new URLSearchParams({
+ name: name || "",
+ species: speciesId || "",
+ color: colorId || "",
+ pose: pose || "",
+ });
+ if (altStyleId != null) {
+ params.append("style", altStyleId);
+ }
+ if (appearanceId != null) {
+ // `state` is an old name for compatibility with old-style DTI URLs. It
+ // refers to "PetState", the database table name for pet appearances.
+ params.append("state", appearanceId);
+ }
+ for (const itemId of wornItemIds) {
+ params.append("objects[]", itemId);
+ }
+ for (const itemId of closetedItemIds) {
+ params.append("closet[]", itemId);
+ }
- return params.toString();
+ return params.toString();
}
/**
* Whether the two given outfit states represent identical customizations.
*/
export function outfitStatesAreEqual(a, b) {
- return buildOutfitQueryString(a) === buildOutfitQueryString(b);
+ return buildOutfitQueryString(a) === buildOutfitQueryString(b);
}
export default useOutfitState;
diff --git a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js
index d7bb6dfd..57654d15 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js
@@ -7,76 +7,76 @@ import { SEARCH_PER_PAGE } from "./SearchPanel";
* useSearchResults manages the actual querying and state management of search!
*/
export function useSearchResults(
- query,
- outfitState,
- currentPageNumber,
- { skip = false } = {},
+ query,
+ outfitState,
+ currentPageNumber,
+ { skip = false } = {},
) {
- const { speciesId, colorId, altStyleId } = outfitState;
+ const { speciesId, colorId, altStyleId } = outfitState;
- // We debounce the search query, so that we don't resend a new query whenever
- // the user types anything.
- const debouncedQuery = useDebounce(query, 300, {
- waitForFirstPause: true,
- initialValue: emptySearchQuery,
- });
+ // We debounce the search query, so that we don't resend a new query whenever
+ // the user types anything.
+ const debouncedQuery = useDebounce(query, 300, {
+ waitForFirstPause: true,
+ initialValue: emptySearchQuery,
+ });
- const { isLoading, error, data } = useItemSearch(
- {
- filters: buildSearchFilters(debouncedQuery, outfitState),
- withAppearancesFor: { speciesId, colorId, altStyleId },
- page: currentPageNumber,
- perPage: SEARCH_PER_PAGE,
- },
- {
- enabled: !skip && !searchQueryIsEmpty(debouncedQuery),
- },
- );
+ const { isLoading, error, data } = useItemSearch(
+ {
+ filters: buildSearchFilters(debouncedQuery, outfitState),
+ withAppearancesFor: { speciesId, colorId, altStyleId },
+ page: currentPageNumber,
+ perPage: SEARCH_PER_PAGE,
+ },
+ {
+ enabled: !skip && !searchQueryIsEmpty(debouncedQuery),
+ },
+ );
- const loading = debouncedQuery !== query || isLoading;
- const items = data?.items ?? [];
- const numTotalPages = data?.numTotalPages ?? 0;
+ const loading = debouncedQuery !== query || isLoading;
+ const items = data?.items ?? [];
+ const numTotalPages = data?.numTotalPages ?? 0;
- return { loading, error, items, numTotalPages };
+ return { loading, error, items, numTotalPages };
}
function buildSearchFilters(query, { speciesId, colorId, altStyleId }) {
- const filters = [];
+ const filters = [];
- // TODO: We're missing quote support, like `background "Dyeworks White"`.
- // It might be good to, rather than parse this out here and send it as
- // filters, include a text-based part of the query as well, and have
- // the server merge them? That'd support text-based `is:nc` etc too.
- const words = query.value.split(/\s+/);
- for (const word of words) {
- filters.push({ key: "name", value: word });
- }
+ // TODO: We're missing quote support, like `background "Dyeworks White"`.
+ // It might be good to, rather than parse this out here and send it as
+ // filters, include a text-based part of the query as well, and have
+ // the server merge them? That'd support text-based `is:nc` etc too.
+ const words = query.value.split(/\s+/);
+ for (const word of words) {
+ filters.push({ key: "name", value: word });
+ }
- if (query.filterToItemKind === "NC") {
- filters.push({ key: "is_nc" });
- } else if (query.filterToItemKind === "PB") {
- filters.push({ key: "is_pb" });
- } else if (query.filterToItemKind === "NP") {
- filters.push({ key: "is_np" });
- }
+ if (query.filterToItemKind === "NC") {
+ filters.push({ key: "is_nc" });
+ } else if (query.filterToItemKind === "PB") {
+ filters.push({ key: "is_pb" });
+ } else if (query.filterToItemKind === "NP") {
+ filters.push({ key: "is_np" });
+ }
- if (query.filterToZoneLabel != null) {
- filters.push({
- key: "occupied_zone_set_name",
- value: query.filterToZoneLabel,
- });
- }
+ if (query.filterToZoneLabel != null) {
+ filters.push({
+ key: "occupied_zone_set_name",
+ value: query.filterToZoneLabel,
+ });
+ }
- if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
- filters.push({ key: "user_closet_hanger_ownership", value: "true" });
- } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
- filters.push({ key: "user_closet_hanger_ownership", value: "false" });
- }
+ if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
+ filters.push({ key: "user_closet_hanger_ownership", value: "true" });
+ } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
+ filters.push({ key: "user_closet_hanger_ownership", value: "false" });
+ }
- filters.push({
- key: "fits",
- value: { speciesId, colorId, altStyleId },
- });
+ filters.push({
+ key: "fits",
+ value: { speciesId, colorId, altStyleId },
+ });
- return filters;
+ return filters;
}
diff --git a/app/javascript/wardrobe-2020/apolloClient.js b/app/javascript/wardrobe-2020/apolloClient.js
index c00d75fe..03824978 100644
--- a/app/javascript/wardrobe-2020/apolloClient.js
+++ b/app/javascript/wardrobe-2020/apolloClient.js
@@ -6,175 +6,175 @@ import { buildImpress2020Url } from "./impress-2020-config";
// Use Apollo's error messages in development.
if (process.env["NODE_ENV"] === "development") {
- loadErrorMessages();
- loadDevMessages();
+ loadErrorMessages();
+ loadDevMessages();
}
// Teach Apollo to load certain fields from the cache, to avoid extra network
// requests. This happens a lot - e.g. reusing data from item search on the
// outfit immediately!
const typePolicies = {
- Query: {
- fields: {
- closetList: (_, { args, toReference }) => {
- return toReference({ __typename: "ClosetList", id: args.id }, true);
- },
- items: (_, { args, toReference }) => {
- return args.ids.map((id) =>
- toReference({ __typename: "Item", id }, true),
- );
- },
- item: (_, { args, toReference }) => {
- return toReference({ __typename: "Item", id: args.id }, true);
- },
- petAppearanceById: (_, { args, toReference }) => {
- return toReference({ __typename: "PetAppearance", id: args.id }, true);
- },
- species: (_, { args, toReference }) => {
- return toReference({ __typename: "Species", id: args.id }, true);
- },
- color: (_, { args, toReference }) => {
- return toReference({ __typename: "Color", id: args.id }, true);
- },
- outfit: (_, { args, toReference }) => {
- return toReference({ __typename: "Outfit", id: args.id }, true);
- },
- user: (_, { args, toReference }) => {
- return toReference({ __typename: "User", id: args.id }, true);
- },
- },
- },
+ Query: {
+ fields: {
+ closetList: (_, { args, toReference }) => {
+ return toReference({ __typename: "ClosetList", id: args.id }, true);
+ },
+ items: (_, { args, toReference }) => {
+ return args.ids.map((id) =>
+ toReference({ __typename: "Item", id }, true),
+ );
+ },
+ item: (_, { args, toReference }) => {
+ return toReference({ __typename: "Item", id: args.id }, true);
+ },
+ petAppearanceById: (_, { args, toReference }) => {
+ return toReference({ __typename: "PetAppearance", id: args.id }, true);
+ },
+ species: (_, { args, toReference }) => {
+ return toReference({ __typename: "Species", id: args.id }, true);
+ },
+ color: (_, { args, toReference }) => {
+ return toReference({ __typename: "Color", id: args.id }, true);
+ },
+ outfit: (_, { args, toReference }) => {
+ return toReference({ __typename: "Outfit", id: args.id }, true);
+ },
+ user: (_, { args, toReference }) => {
+ return toReference({ __typename: "User", id: args.id }, true);
+ },
+ },
+ },
- Item: {
- fields: {
- appearanceOn: (appearance, { args, readField, toReference }) => {
- // If we already have this exact appearance in the cache, serve it!
- if (appearance) {
- return appearance;
- }
+ Item: {
+ fields: {
+ appearanceOn: (appearance, { args, readField, toReference }) => {
+ // If we already have this exact appearance in the cache, serve it!
+ if (appearance) {
+ return appearance;
+ }
- const { speciesId, colorId, altStyleId } = args;
- console.debug(
- "[appearanceOn] seeking cached appearance",
- speciesId,
- colorId,
- altStyleId,
- readField("id"),
- );
+ const { speciesId, colorId, altStyleId } = args;
+ console.debug(
+ "[appearanceOn] seeking cached appearance",
+ speciesId,
+ colorId,
+ altStyleId,
+ readField("id"),
+ );
- // If this is an alt style, don't try to mess with clever caching.
- // (Note that, if it's already in the cache, the first condition will
- // catch that! This won't *always* force a fresh load!)
- if (altStyleId != null) {
- return undefined;
- }
+ // If this is an alt style, don't try to mess with clever caching.
+ // (Note that, if it's already in the cache, the first condition will
+ // catch that! This won't *always* force a fresh load!)
+ if (altStyleId != null) {
+ return undefined;
+ }
- // Otherwise, we're going to see if this is a standard color, in which
- // case we can reuse the standard color appearance if we already have
- // it! This helps for fast loading when switching between standard
- // colors.
- const speciesStandardBodyId = readField(
- "standardBodyId",
- toReference({ __typename: "Species", id: speciesId }),
- );
- const colorIsStandard = readField(
- "isStandard",
- toReference({ __typename: "Color", id: colorId }),
- );
- if (speciesStandardBodyId == null || colorIsStandard == null) {
- // We haven't loaded all the species/colors into cache yet. We might
- // be loading them, depending on the page? Either way, return
- // `undefined`, meaning we don't know how to serve this from cache.
- // This will cause us to start loading it from the server.
- console.debug("[appearanceOn] species/colors not loaded yet");
- return undefined;
- }
+ // Otherwise, we're going to see if this is a standard color, in which
+ // case we can reuse the standard color appearance if we already have
+ // it! This helps for fast loading when switching between standard
+ // colors.
+ const speciesStandardBodyId = readField(
+ "standardBodyId",
+ toReference({ __typename: "Species", id: speciesId }),
+ );
+ const colorIsStandard = readField(
+ "isStandard",
+ toReference({ __typename: "Color", id: colorId }),
+ );
+ if (speciesStandardBodyId == null || colorIsStandard == null) {
+ // We haven't loaded all the species/colors into cache yet. We might
+ // be loading them, depending on the page? Either way, return
+ // `undefined`, meaning we don't know how to serve this from cache.
+ // This will cause us to start loading it from the server.
+ console.debug("[appearanceOn] species/colors not loaded yet");
+ return undefined;
+ }
- if (colorIsStandard) {
- const itemId = readField("id");
- console.debug(
- "[appearanceOn] standard color, will read:",
- `item-${itemId}-body-${speciesStandardBodyId}`,
- );
- return toReference({
- __typename: "ItemAppearance",
- id: `item-${itemId}-body-${speciesStandardBodyId}`,
- });
- } else {
- console.debug("[appearanceOn] non-standard color, failure");
- // This isn't a standard color, so we don't support special
- // cross-color caching for it. Return `undefined`, meaning we don't
- // know how to serve this from cache. This will cause us to start
- // loading it from the server.
- return undefined;
- }
- },
+ if (colorIsStandard) {
+ const itemId = readField("id");
+ console.debug(
+ "[appearanceOn] standard color, will read:",
+ `item-${itemId}-body-${speciesStandardBodyId}`,
+ );
+ return toReference({
+ __typename: "ItemAppearance",
+ id: `item-${itemId}-body-${speciesStandardBodyId}`,
+ });
+ } else {
+ console.debug("[appearanceOn] non-standard color, failure");
+ // This isn't a standard color, so we don't support special
+ // cross-color caching for it. Return `undefined`, meaning we don't
+ // know how to serve this from cache. This will cause us to start
+ // loading it from the server.
+ return undefined;
+ }
+ },
- currentUserOwnsThis: (cachedValue, { readField }) => {
- if (cachedValue != null) {
- return cachedValue;
- }
+ currentUserOwnsThis: (cachedValue, { readField }) => {
+ if (cachedValue != null) {
+ return cachedValue;
+ }
- // Do we know what items this user owns? If so, scan for this item.
- const currentUserRef = readField("currentUser", {
- __ref: "ROOT_QUERY",
- });
- if (!currentUserRef) {
- return undefined;
- }
- const thisItemId = readField("id");
- const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef);
- if (!itemsTheyOwn) {
- return undefined;
- }
+ // Do we know what items this user owns? If so, scan for this item.
+ const currentUserRef = readField("currentUser", {
+ __ref: "ROOT_QUERY",
+ });
+ if (!currentUserRef) {
+ return undefined;
+ }
+ const thisItemId = readField("id");
+ const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef);
+ if (!itemsTheyOwn) {
+ return undefined;
+ }
- const theyOwnThisItem = itemsTheyOwn.some(
- (itemRef) => readField("id", itemRef) === thisItemId,
- );
- return theyOwnThisItem;
- },
- currentUserWantsThis: (cachedValue, { readField }) => {
- if (cachedValue != null) {
- return cachedValue;
- }
+ const theyOwnThisItem = itemsTheyOwn.some(
+ (itemRef) => readField("id", itemRef) === thisItemId,
+ );
+ return theyOwnThisItem;
+ },
+ currentUserWantsThis: (cachedValue, { readField }) => {
+ if (cachedValue != null) {
+ return cachedValue;
+ }
- // Do we know what items this user owns? If so, scan for this item.
- const currentUserRef = readField("currentUser", {
- __ref: "ROOT_QUERY",
- });
- if (!currentUserRef) {
- return undefined;
- }
- const thisItemId = readField("id");
- const itemsTheyWant = readField("itemsTheyWant", currentUserRef);
- if (!itemsTheyWant) {
- return undefined;
- }
+ // Do we know what items this user owns? If so, scan for this item.
+ const currentUserRef = readField("currentUser", {
+ __ref: "ROOT_QUERY",
+ });
+ if (!currentUserRef) {
+ return undefined;
+ }
+ const thisItemId = readField("id");
+ const itemsTheyWant = readField("itemsTheyWant", currentUserRef);
+ if (!itemsTheyWant) {
+ return undefined;
+ }
- const theyWantThisItem = itemsTheyWant.some(
- (itemRef) => readField("id", itemRef) === thisItemId,
- );
- return theyWantThisItem;
- },
- },
- },
+ const theyWantThisItem = itemsTheyWant.some(
+ (itemRef) => readField("id", itemRef) === thisItemId,
+ );
+ return theyWantThisItem;
+ },
+ },
+ },
- ClosetList: {
- fields: {
- // When loading the updated contents of a list, replace it entirely.
- items: { merge: false },
- },
- },
+ ClosetList: {
+ fields: {
+ // When loading the updated contents of a list, replace it entirely.
+ items: { merge: false },
+ },
+ },
};
const cache = new InMemoryCache({ typePolicies });
const httpLink = createHttpLink({
- uri: buildImpress2020Url("/api/graphql"),
+ uri: buildImpress2020Url("/api/graphql"),
});
const link = createPersistedQueryLink({
- useGETForHashedQueries: true,
+ useGETForHashedQueries: true,
}).concat(httpLink);
/**
@@ -182,9 +182,9 @@ const link = createPersistedQueryLink({
* queries. This is how we communicate with the server!
*/
const apolloClient = new ApolloClient({
- link,
- cache,
- connectToDevTools: true,
+ link,
+ cache,
+ connectToDevTools: true,
});
export default apolloClient;
diff --git a/app/javascript/wardrobe-2020/components/HTML5Badge.js b/app/javascript/wardrobe-2020/components/HTML5Badge.js
index 77c6aea2..364a5380 100644
--- a/app/javascript/wardrobe-2020/components/HTML5Badge.js
+++ b/app/javascript/wardrobe-2020/components/HTML5Badge.js
@@ -3,145 +3,145 @@ import { Tooltip, useColorModeValue, Flex, Icon } from "@chakra-ui/react";
import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons";
function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) {
- // `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
- // `isLoading` was `false`. This enables us to keep showing the badge, even
- // when loading a new appearance - because it's unlikely the badge will
- // change between different appearances for the same item, and the flicker is
- // annoying!
- const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
- React.useEffect(() => {
- if (!isLoading) {
- setDelayedUsesHTML5(usesHTML5);
- }
- }, [usesHTML5, isLoading]);
+ // `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
+ // `isLoading` was `false`. This enables us to keep showing the badge, even
+ // when loading a new appearance - because it's unlikely the badge will
+ // change between different appearances for the same item, and the flicker is
+ // annoying!
+ const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
+ React.useEffect(() => {
+ if (!isLoading) {
+ setDelayedUsesHTML5(usesHTML5);
+ }
+ }, [usesHTML5, isLoading]);
- if (delayedUsesHTML5 === true) {
- return (
-
-
-
- {/* From Twemoji Keycap 5 */}
-
-
-
- );
- } else if (delayedUsesHTML5 === false) {
- return (
-
- This item isn't converted to HTML5 yet, so it might not appear in
- Neopets.com customization yet. Once it's ready, it could look a
- bit different than our temporary preview here. It might even be
- animated!
- >
- )
- }
- >
-
-
- {/* From Twemoji Keycap 5 */}
-
+ if (delayedUsesHTML5 === true) {
+ return (
+
+
+
+ {/* From Twemoji Keycap 5 */}
+
+
+
+ );
+ } else if (delayedUsesHTML5 === false) {
+ return (
+
+ This item isn't converted to HTML5 yet, so it might not appear in
+ Neopets.com customization yet. Once it's ready, it could look a
+ bit different than our temporary preview here. It might even be
+ animated!
+ >
+ )
+ }
+ >
+
+
+ {/* From Twemoji Keycap 5 */}
+
- {/* From Twemoji Not Allowed */}
-
-
-
- );
- } else {
- // If no `usesHTML5` value has been provided yet, we're empty for now!
- return null;
- }
+ {/* From Twemoji Not Allowed */}
+
+
+
+ );
+ } else {
+ // If no `usesHTML5` value has been provided yet, we're empty for now!
+ return null;
+ }
}
export function GlitchBadgeLayout({
- hasGlitches = true,
- children,
- tooltipLabel,
- ...props
+ hasGlitches = true,
+ children,
+ tooltipLabel,
+ ...props
}) {
- const [isHovered, setIsHovered] = React.useState(false);
- const [isFocused, setIsFocused] = React.useState(false);
+ const [isHovered, setIsHovered] = React.useState(false);
+ const [isFocused, setIsFocused] = React.useState(false);
- const greenBackground = useColorModeValue("green.100", "green.900");
- const greenBorderColor = useColorModeValue("green.600", "green.500");
- const greenTextColor = useColorModeValue("green.700", "white");
+ const greenBackground = useColorModeValue("green.100", "green.900");
+ const greenBorderColor = useColorModeValue("green.600", "green.500");
+ const greenTextColor = useColorModeValue("green.700", "white");
- const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
- const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
- const yellowTextColor = useColorModeValue("yellow.700", "white");
+ const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
+ const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
+ const yellowTextColor = useColorModeValue("yellow.700", "white");
- return (
-
- setIsHovered(true)}
- onMouseLeave={() => setIsHovered(false)}
- onFocus={() => setIsFocused(true)}
- onBlur={() => setIsFocused(false)}
- {...props}
- >
- {children}
-
-
- );
+ return (
+
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ onFocus={() => setIsFocused(true)}
+ onBlur={() => setIsFocused(false)}
+ {...props}
+ >
+ {children}
+
+
+ );
}
export function layerUsesHTML5(layer) {
- return Boolean(
- layer.svgUrl ||
- layer.canvasMovieLibraryUrl ||
- // If this glitch is applied, then `svgUrl` will be null, but there's still
- // an HTML5 manifest that the official player can render.
- (layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
- );
+ return Boolean(
+ layer.svgUrl ||
+ layer.canvasMovieLibraryUrl ||
+ // If this glitch is applied, then `svgUrl` will be null, but there's still
+ // an HTML5 manifest that the official player can render.
+ (layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
+ );
}
export default HTML5Badge;
diff --git a/app/javascript/wardrobe-2020/components/HangerSpinner.js b/app/javascript/wardrobe-2020/components/HangerSpinner.js
index 4faf9613..2721d88c 100644
--- a/app/javascript/wardrobe-2020/components/HangerSpinner.js
+++ b/app/javascript/wardrobe-2020/components/HangerSpinner.js
@@ -4,94 +4,94 @@ import { Box, useColorModeValue } from "@chakra-ui/react";
import { createIcon } from "@chakra-ui/icons";
const HangerIcon = createIcon({
- displayName: "HangerIcon",
+ displayName: "HangerIcon",
- // https://www.svgrepo.com/svg/108090/clothes-hanger
- viewBox: "0 0 473 473",
- path: (
-
- ),
+ // https://www.svgrepo.com/svg/108090/clothes-hanger
+ viewBox: "0 0 473 473",
+ path: (
+
+ ),
});
function HangerSpinner({ size = "md", ...props }) {
- const boxSize = { sm: "32px", md: "48px" }[size];
- const color = useColorModeValue("green.500", "green.300");
+ const boxSize = { sm: "32px", md: "48px" }[size];
+ const color = useColorModeValue("green.500", "green.300");
- return (
-
- {({ css }) => (
-
+ {({ css }) => (
+
-
-
- )}
-
- );
+ @media (prefers-reduced-motion: reduce) {
+ animation: 1.6s infinite fade-pulse;
+ }
+ `}
+ {...props}
+ >
+
+
+ )}
+
+ );
}
export default HangerSpinner;
diff --git a/app/javascript/wardrobe-2020/components/ItemCard.js b/app/javascript/wardrobe-2020/components/ItemCard.js
index 3d62c549..fa9ca74c 100644
--- a/app/javascript/wardrobe-2020/components/ItemCard.js
+++ b/app/javascript/wardrobe-2020/components/ItemCard.js
@@ -1,20 +1,20 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
- Badge,
- Box,
- SimpleGrid,
- Tooltip,
- Wrap,
- WrapItem,
- useColorModeValue,
- useTheme,
+ Badge,
+ Box,
+ SimpleGrid,
+ Tooltip,
+ Wrap,
+ WrapItem,
+ useColorModeValue,
+ useTheme,
} from "@chakra-ui/react";
import {
- CheckIcon,
- EditIcon,
- NotAllowedIcon,
- StarIcon,
+ CheckIcon,
+ EditIcon,
+ NotAllowedIcon,
+ StarIcon,
} from "@chakra-ui/icons";
import { HiSparkles } from "react-icons/hi";
@@ -23,73 +23,73 @@ import { safeImageUrl, useCommonStyles } from "../util";
import usePreferArchive from "./usePreferArchive";
function ItemCard({ item, badges, variant = "list", ...props }) {
- const { brightBackground } = useCommonStyles();
+ const { brightBackground } = useCommonStyles();
- switch (variant) {
- case "grid":
- return ;
- case "list":
- return (
-
-
-
- );
- default:
- throw new Error(`Unexpected ItemCard variant: ${variant}`);
- }
+ switch (variant) {
+ case "grid":
+ return ;
+ case "list":
+ return (
+
+
+
+ );
+ default:
+ throw new Error(`Unexpected ItemCard variant: ${variant}`);
+ }
}
export function ItemCardContent({
- item,
- badges,
- isWorn,
- isDisabled,
- itemNameId,
- focusSelector,
+ item,
+ badges,
+ isWorn,
+ isDisabled,
+ itemNameId,
+ focusSelector,
}) {
- return (
-
-
-
-
-
-
-
-
- {item.name}
-
+ return (
+
+
+
+
+
+
+
+
+ {item.name}
+
- {badges}
-
-
- );
+ {badges}
+
+
+ );
}
/**
@@ -97,88 +97,88 @@ export function ItemCardContent({
* hover/focus and worn/unworn states.
*/
export function ItemThumbnail({
- item,
- size = "md",
- isActive,
- isDisabled,
- focusSelector,
- ...props
+ item,
+ size = "md",
+ isActive,
+ isDisabled,
+ focusSelector,
+ ...props
}) {
- const [preferArchive] = usePreferArchive();
- const theme = useTheme();
+ const [preferArchive] = usePreferArchive();
+ const theme = useTheme();
- const borderColor = useColorModeValue(
- theme.colors.green["700"],
- "transparent",
- );
+ const borderColor = useColorModeValue(
+ theme.colors.green["700"],
+ "transparent",
+ );
- const focusBorderColor = useColorModeValue(
- theme.colors.green["600"],
- "transparent",
- );
+ const focusBorderColor = useColorModeValue(
+ theme.colors.green["600"],
+ "transparent",
+ );
- return (
-
- {({ css }) => (
-
-
- {/* If the item is still loading, wait with an empty box. */}
- {item && (
-
- )}
-
-
- )}
-
- );
+ return (
+
+ {({ css }) => (
+
+
+ {/* If the item is still loading, wait with an empty box. */}
+ {item && (
+
+ )}
+
+
+ )}
+
+ );
}
/**
@@ -186,245 +186,245 @@ export function ItemThumbnail({
* states.
*/
function ItemName({ children, isDisabled, focusSelector, ...props }) {
- const theme = useTheme();
+ const theme = useTheme();
- return (
-
- {({ css }) => (
-
+ {({ css }) => (
+
- {children}
-
- )}
-
- );
+ input:checked + .item-container & {
+ opacity: 1;
+ font-weight: ${theme.fontWeights.bold};
+ }
+ `
+ }
+ {...props}
+ >
+ {children}
+
+ )}
+
+ );
}
export function ItemCardList({ children }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
export function ItemBadgeList({ children, ...props }) {
- return (
-
- {React.Children.map(
- children,
- (badge) => badge && {badge},
- )}
-
- );
+ return (
+
+ {React.Children.map(
+ children,
+ (badge) => badge && {badge},
+ )}
+
+ );
}
export function ItemBadgeTooltip({ label, children }) {
- return (
- {label}}
- placement="top"
- openDelay={400}
- >
- {children}
-
- );
+ return (
+ {label}}
+ placement="top"
+ openDelay={400}
+ >
+ {children}
+
+ );
}
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
- return (
-
-
- NC
- {isEditButton && }
-
-
- );
+ return (
+
+
+ NC
+ {isEditButton && }
+
+
+ );
});
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
- return (
-
-
- NP
- {isEditButton && }
-
-
- );
+ return (
+
+
+ NP
+ {isEditButton && }
+
+
+ );
});
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
- return (
-
-
- PB
- {isEditButton && }
-
-
- );
+ return (
+
+
+ PB
+ {isEditButton && }
+
+
+ );
});
export const ItemKindBadge = React.forwardRef(
- ({ isNc, isPb, isEditButton, ...props }, ref) => {
- if (isNc) {
- return ;
- } else if (isPb) {
- return ;
- } else {
- return ;
- }
- },
+ ({ isNc, isPb, isEditButton, ...props }, ref) => {
+ if (isNc) {
+ return ;
+ } else if (isPb) {
+ return ;
+ } else {
+ return ;
+ }
+ },
);
export function YouOwnThisBadge({ variant = "long" }) {
- let badge = (
-
-
- {variant === "medium" && Own}
- {variant === "long" && You own this!}
-
- );
+ let badge = (
+
+
+ {variant === "medium" && Own}
+ {variant === "long" && You own this!}
+
+ );
- if (variant === "short" || variant === "medium") {
- badge = (
- {badge}
- );
- }
+ if (variant === "short" || variant === "medium") {
+ badge = (
+ {badge}
+ );
+ }
- return badge;
+ return badge;
}
export function YouWantThisBadge({ variant = "long" }) {
- let badge = (
-
-
- {variant === "medium" && Want}
- {variant === "long" && You want this!}
-
- );
+ let badge = (
+
+
+ {variant === "medium" && Want}
+ {variant === "long" && You want this!}
+
+ );
- if (variant === "short" || variant === "medium") {
- badge = (
- {badge}
- );
- }
+ if (variant === "short" || variant === "medium") {
+ badge = (
+ {badge}
+ );
+ }
- return badge;
+ return badge;
}
function ZoneBadge({ variant, zoneLabel }) {
- // Shorten the label when necessary, to make the badges less bulky
- const shorthand = zoneLabel
- .replace("Background Item", "BG Item")
- .replace("Foreground Item", "FG Item")
- .replace("Lower-body", "Lower")
- .replace("Upper-body", "Upper")
- .replace("Transient", "Trans")
- .replace("Biology", "Bio");
+ // Shorten the label when necessary, to make the badges less bulky
+ const shorthand = zoneLabel
+ .replace("Background Item", "BG Item")
+ .replace("Foreground Item", "FG Item")
+ .replace("Lower-body", "Lower")
+ .replace("Upper-body", "Upper")
+ .replace("Transient", "Trans")
+ .replace("Biology", "Bio");
- if (variant === "restricts") {
- return (
-
-
-
- {shorthand}
-
-
-
- );
- }
+ if (variant === "restricts") {
+ return (
+
+
+
+ {shorthand}
+
+
+
+ );
+ }
- if (shorthand !== zoneLabel) {
- return (
-
- {shorthand}
-
- );
- }
+ if (shorthand !== zoneLabel) {
+ return (
+
+ {shorthand}
+
+ );
+ }
- return {shorthand};
+ return {shorthand};
}
export function getZoneBadges(zones, propsForAllBadges) {
- // Get the sorted zone labels. Sometimes an item occupies multiple zones of
- // the same name, so it's important to de-duplicate them!
- let labels = zones.map((z) => z.label);
- labels = new Set(labels);
- labels = [...labels].sort();
+ // Get the sorted zone labels. Sometimes an item occupies multiple zones of
+ // the same name, so it's important to de-duplicate them!
+ let labels = zones.map((z) => z.label);
+ labels = new Set(labels);
+ labels = [...labels].sort();
- return labels.map((label) => (
-
- ));
+ return labels.map((label) => (
+
+ ));
}
export function MaybeAnimatedBadge() {
- return (
-
-
-
-
-
- );
+ return (
+
+
+
+
+
+ );
}
export default ItemCard;
diff --git a/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js b/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
index 77169b35..0afd76c4 100644
--- a/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
+++ b/app/javascript/wardrobe-2020/components/OutfitMovieLayer.js
@@ -17,471 +17,471 @@ new Function(easelSource).call(window);
new Function(tweenSource).call(window);
function OutfitMovieLayer({
- libraryUrl,
- width,
- height,
- placeholderImageUrl = null,
- isPaused = false,
- onLoad = null,
- onError = null,
- onLowFps = null,
- canvasProps = {},
+ libraryUrl,
+ width,
+ height,
+ placeholderImageUrl = null,
+ isPaused = false,
+ onLoad = null,
+ onError = null,
+ onLowFps = null,
+ canvasProps = {},
}) {
- const [preferArchive] = usePreferArchive();
- const [stage, setStage] = React.useState(null);
- const [library, setLibrary] = React.useState(null);
- const [movieClip, setMovieClip] = React.useState(null);
- const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
- const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
- const canvasRef = React.useRef(null);
- const hasShownErrorMessageRef = React.useRef(false);
- const toast = useToast();
+ const [preferArchive] = usePreferArchive();
+ const [stage, setStage] = React.useState(null);
+ const [library, setLibrary] = React.useState(null);
+ const [movieClip, setMovieClip] = React.useState(null);
+ const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
+ const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
+ const canvasRef = React.useRef(null);
+ const hasShownErrorMessageRef = React.useRef(false);
+ const toast = useToast();
- // Set the canvas's internal dimensions to be higher, if the device has high
- // DPI like retina. But we'll keep the layout width/height as expected!
- const internalWidth = width * window.devicePixelRatio;
- const internalHeight = height * window.devicePixelRatio;
+ // Set the canvas's internal dimensions to be higher, if the device has high
+ // DPI like retina. But we'll keep the layout width/height as expected!
+ const internalWidth = width * window.devicePixelRatio;
+ const internalHeight = height * window.devicePixelRatio;
- const callOnLoadIfNotYetCalled = React.useCallback(() => {
- setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
- if (!alreadyHasCalledOnLoad && onLoad) {
- onLoad();
- }
- return true;
- });
- }, [onLoad]);
+ const callOnLoadIfNotYetCalled = React.useCallback(() => {
+ setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
+ if (!alreadyHasCalledOnLoad && onLoad) {
+ onLoad();
+ }
+ return true;
+ });
+ }, [onLoad]);
- const updateStage = React.useCallback(() => {
- if (!stage) {
- return;
- }
+ const updateStage = React.useCallback(() => {
+ if (!stage) {
+ return;
+ }
- try {
- stage.update();
- } catch (e) {
- // If rendering the frame fails, log it and proceed. If it's an
- // animation, then maybe the next frame will work? Also alert the user,
- // just as an FYI. (This is pretty uncommon, so I'm not worried about
- // being noisy!)
- if (!hasShownErrorMessageRef.current) {
- console.error(`Error rendering movie clip ${libraryUrl}`);
- logAndCapture(e);
- toast({
- status: "warning",
- title:
- "Hmm, we're maybe having trouble playing one of these animations.",
- description:
- "If it looks wrong, try pausing and playing, or reloading the " +
- "page. Sorry!",
- duration: 10000,
- isClosable: true,
- });
- // We do this via a ref, not state, because I want to guarantee that
- // future calls see the new value. With state, React's effects might
- // not happen in the right order for it to work!
- hasShownErrorMessageRef.current = true;
- }
- }
- }, [stage, toast, libraryUrl]);
+ try {
+ stage.update();
+ } catch (e) {
+ // If rendering the frame fails, log it and proceed. If it's an
+ // animation, then maybe the next frame will work? Also alert the user,
+ // just as an FYI. (This is pretty uncommon, so I'm not worried about
+ // being noisy!)
+ if (!hasShownErrorMessageRef.current) {
+ console.error(`Error rendering movie clip ${libraryUrl}`);
+ logAndCapture(e);
+ toast({
+ status: "warning",
+ title:
+ "Hmm, we're maybe having trouble playing one of these animations.",
+ description:
+ "If it looks wrong, try pausing and playing, or reloading the " +
+ "page. Sorry!",
+ duration: 10000,
+ isClosable: true,
+ });
+ // We do this via a ref, not state, because I want to guarantee that
+ // future calls see the new value. With state, React's effects might
+ // not happen in the right order for it to work!
+ hasShownErrorMessageRef.current = true;
+ }
+ }
+ }, [stage, toast, libraryUrl]);
- // This effect gives us a `stage` corresponding to the canvas element.
- React.useLayoutEffect(() => {
- const canvas = canvasRef.current;
+ // This effect gives us a `stage` corresponding to the canvas element.
+ React.useLayoutEffect(() => {
+ const canvas = canvasRef.current;
- if (!canvas) {
- return;
- }
+ if (!canvas) {
+ return;
+ }
- if (canvas.getContext("2d") == null) {
- console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
- toast({
- status: "warning",
- title: "Oops, too many animations!",
- description:
- `Your device is out of memory, so we can't show any more ` +
- `animations. Try removing some items, or using another device.`,
- duration: null,
- isClosable: true,
- });
- return;
- }
+ if (canvas.getContext("2d") == null) {
+ console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
+ toast({
+ status: "warning",
+ title: "Oops, too many animations!",
+ description:
+ `Your device is out of memory, so we can't show any more ` +
+ `animations. Try removing some items, or using another device.`,
+ duration: null,
+ isClosable: true,
+ });
+ return;
+ }
- setStage((stage) => {
- if (stage && stage.canvas === canvas) {
- return stage;
- }
+ setStage((stage) => {
+ if (stage && stage.canvas === canvas) {
+ return stage;
+ }
- return new window.createjs.Stage(canvas);
- });
+ return new window.createjs.Stage(canvas);
+ });
- return () => {
- setStage(null);
+ return () => {
+ setStage(null);
- if (canvas) {
- // There's a Safari bug where it doesn't reliably garbage-collect
- // canvas data. Clean it up ourselves, rather than leaking memory over
- // time! https://stackoverflow.com/a/52586606/107415
- // https://bugs.webkit.org/show_bug.cgi?id=195325
- canvas.width = 0;
- canvas.height = 0;
- }
- };
- }, [libraryUrl, toast]);
+ if (canvas) {
+ // There's a Safari bug where it doesn't reliably garbage-collect
+ // canvas data. Clean it up ourselves, rather than leaking memory over
+ // time! https://stackoverflow.com/a/52586606/107415
+ // https://bugs.webkit.org/show_bug.cgi?id=195325
+ canvas.width = 0;
+ canvas.height = 0;
+ }
+ };
+ }, [libraryUrl, toast]);
- // This effect gives us the `library` and `movieClip`, based on the incoming
- // `libraryUrl`.
- React.useEffect(() => {
- let canceled = false;
+ // This effect gives us the `library` and `movieClip`, based on the incoming
+ // `libraryUrl`.
+ React.useEffect(() => {
+ let canceled = false;
- const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
- movieLibraryPromise
- .then((library) => {
- if (canceled) {
- return;
- }
+ const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
+ movieLibraryPromise
+ .then((library) => {
+ if (canceled) {
+ return;
+ }
- setLibrary(library);
+ setLibrary(library);
- const movieClip = buildMovieClip(library, libraryUrl);
- setMovieClip(movieClip);
- })
- .catch((e) => {
- console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
- if (onError) {
- onError(e);
- }
- });
+ const movieClip = buildMovieClip(library, libraryUrl);
+ setMovieClip(movieClip);
+ })
+ .catch((e) => {
+ console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
+ if (onError) {
+ onError(e);
+ }
+ });
- return () => {
- canceled = true;
- movieLibraryPromise.cancel();
- setLibrary(null);
- setMovieClip(null);
- };
- }, [libraryUrl, preferArchive, onError]);
+ return () => {
+ canceled = true;
+ movieLibraryPromise.cancel();
+ setLibrary(null);
+ setMovieClip(null);
+ };
+ }, [libraryUrl, preferArchive, onError]);
- // This effect puts the `movieClip` on the `stage`, when both are ready.
- React.useEffect(() => {
- if (!stage || !movieClip) {
- return;
- }
+ // This effect puts the `movieClip` on the `stage`, when both are ready.
+ React.useEffect(() => {
+ if (!stage || !movieClip) {
+ return;
+ }
- stage.addChild(movieClip);
+ stage.addChild(movieClip);
- // Render the movie's first frame. If it's animated and we're not paused,
- // then another effect will perform subsequent updates.
- updateStage();
+ // Render the movie's first frame. If it's animated and we're not paused,
+ // then another effect will perform subsequent updates.
+ updateStage();
- // This is when we trigger `onLoad`: once we're actually showing it!
- callOnLoadIfNotYetCalled();
- setMovieIsLoaded(true);
+ // This is when we trigger `onLoad`: once we're actually showing it!
+ callOnLoadIfNotYetCalled();
+ setMovieIsLoaded(true);
- return () => stage.removeChild(movieClip);
- }, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
+ return () => stage.removeChild(movieClip);
+ }, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
- // This effect updates the `stage` according to the `library`'s framerate,
- // but only if there's actual animation to do - i.e., there's more than one
- // frame to show, and we're not paused.
- React.useEffect(() => {
- if (!stage || !movieClip || !library) {
- return;
- }
+ // This effect updates the `stage` according to the `library`'s framerate,
+ // but only if there's actual animation to do - i.e., there's more than one
+ // frame to show, and we're not paused.
+ React.useEffect(() => {
+ if (!stage || !movieClip || !library) {
+ return;
+ }
- if (isPaused || !hasAnimations(movieClip)) {
- return;
- }
+ if (isPaused || !hasAnimations(movieClip)) {
+ return;
+ }
- const targetFps = library.properties.fps;
+ const targetFps = library.properties.fps;
- let lastFpsLoggedAtInMs = performance.now();
- let numFramesSinceLastLogged = 0;
- const intervalId = setInterval(() => {
- const now = performance.now();
- const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
- const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
- const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
- const roundedFps = Math.round(fps * 100) / 100;
+ let lastFpsLoggedAtInMs = performance.now();
+ let numFramesSinceLastLogged = 0;
+ const intervalId = setInterval(() => {
+ const now = performance.now();
+ const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
+ const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
+ const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
+ const roundedFps = Math.round(fps * 100) / 100;
- // If the page is visible, render the next frame, and track that we did.
- // And if it's been 2 seconds since the last time we logged the FPS,
- // compute and log the FPS during those two seconds. (Checking the page
- // visibility is both an optimization to avoid rendering the movie, but
- // also makes "low FPS" tracking more accurate: browsers already throttle
- // intervals when the page is hidden, so a low FPS is *expected*, and
- // wouldn't indicate a performance problem like a low FPS normally would.)
- if (!document.hidden) {
- updateStage();
- numFramesSinceLastLogged++;
+ // If the page is visible, render the next frame, and track that we did.
+ // And if it's been 2 seconds since the last time we logged the FPS,
+ // compute and log the FPS during those two seconds. (Checking the page
+ // visibility is both an optimization to avoid rendering the movie, but
+ // also makes "low FPS" tracking more accurate: browsers already throttle
+ // intervals when the page is hidden, so a low FPS is *expected*, and
+ // wouldn't indicate a performance problem like a low FPS normally would.)
+ if (!document.hidden) {
+ updateStage();
+ numFramesSinceLastLogged++;
- if (timeSinceLastFpsLoggedAtInSec > 2) {
- console.debug(
- `[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
- );
- if (onLowFps && fps < 2) {
- onLowFps(fps);
- }
+ if (timeSinceLastFpsLoggedAtInSec > 2) {
+ console.debug(
+ `[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
+ );
+ if (onLowFps && fps < 2) {
+ onLowFps(fps);
+ }
- lastFpsLoggedAtInMs = now;
- numFramesSinceLastLogged = 0;
- }
- }
- }, 1000 / targetFps);
+ lastFpsLoggedAtInMs = now;
+ numFramesSinceLastLogged = 0;
+ }
+ }
+ }, 1000 / targetFps);
- const onVisibilityChange = () => {
- // When the page switches from hidden to visible, reset the FPS counter
- // state, to start counting from When Visibility Came Back, rather than
- // from when we last counted, which could be a long time ago.
- if (!document.hidden) {
- lastFpsLoggedAtInMs = performance.now();
- numFramesSinceLastLogged = 0;
- console.debug(
- `[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
- );
- } else {
- console.debug(
- `[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
- );
- }
- };
- document.addEventListener("visibilitychange", onVisibilityChange);
+ const onVisibilityChange = () => {
+ // When the page switches from hidden to visible, reset the FPS counter
+ // state, to start counting from When Visibility Came Back, rather than
+ // from when we last counted, which could be a long time ago.
+ if (!document.hidden) {
+ lastFpsLoggedAtInMs = performance.now();
+ numFramesSinceLastLogged = 0;
+ console.debug(
+ `[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
+ );
+ } else {
+ console.debug(
+ `[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
+ );
+ }
+ };
+ document.addEventListener("visibilitychange", onVisibilityChange);
- return () => {
- clearInterval(intervalId);
- document.removeEventListener("visibilitychange", onVisibilityChange);
- };
- }, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
+ return () => {
+ clearInterval(intervalId);
+ document.removeEventListener("visibilitychange", onVisibilityChange);
+ };
+ }, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
- // This effect keeps the `movieClip` scaled correctly, based on the canvas
- // size and the `library`'s natural size declaration. (If the canvas size
- // changes on window resize, then this will keep us responsive, so long as
- // the parent updates our width/height props on window resize!)
- React.useEffect(() => {
- if (!stage || !movieClip || !library) {
- return;
- }
+ // This effect keeps the `movieClip` scaled correctly, based on the canvas
+ // size and the `library`'s natural size declaration. (If the canvas size
+ // changes on window resize, then this will keep us responsive, so long as
+ // the parent updates our width/height props on window resize!)
+ React.useEffect(() => {
+ if (!stage || !movieClip || !library) {
+ return;
+ }
- movieClip.scaleX = internalWidth / library.properties.width;
- movieClip.scaleY = internalHeight / library.properties.height;
+ movieClip.scaleX = internalWidth / library.properties.width;
+ movieClip.scaleY = internalHeight / library.properties.height;
- // Redraw the stage with the new dimensions - but with `tickOnUpdate` set
- // to `false`, so that we don't advance by a frame. This keeps us
- // really-paused if we're paused, and avoids skipping ahead by a frame if
- // we're playing.
- stage.tickOnUpdate = false;
- updateStage();
- stage.tickOnUpdate = true;
- }, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
+ // Redraw the stage with the new dimensions - but with `tickOnUpdate` set
+ // to `false`, so that we don't advance by a frame. This keeps us
+ // really-paused if we're paused, and avoids skipping ahead by a frame if
+ // we're playing.
+ stage.tickOnUpdate = false;
+ updateStage();
+ stage.tickOnUpdate = true;
+ }, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
- return (
-
-
- {/* While the movie is loading, we show our image version as a
- * placeholder, because it generally loads much faster.
- * TODO: Show a loading indicator for this partially-loaded state? */}
- {placeholderImageUrl && (
-
- )}
-
- );
+ return (
+
+
+ {/* While the movie is loading, we show our image version as a
+ * placeholder, because it generally loads much faster.
+ * TODO: Show a loading indicator for this partially-loaded state? */}
+ {placeholderImageUrl && (
+
+ )}
+
+ );
}
function loadScriptTag(src) {
- let script;
- let canceled = false;
- let resolved = false;
+ let script;
+ let canceled = false;
+ let resolved = false;
- const scriptTagPromise = new Promise((resolve, reject) => {
- script = document.createElement("script");
- script.onload = () => {
- if (canceled) return;
- resolved = true;
- resolve(script);
- };
- script.onerror = (e) => {
- if (canceled) return;
- reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
- };
- script.src = src;
- document.body.appendChild(script);
- });
+ const scriptTagPromise = new Promise((resolve, reject) => {
+ script = document.createElement("script");
+ script.onload = () => {
+ if (canceled) return;
+ resolved = true;
+ resolve(script);
+ };
+ script.onerror = (e) => {
+ if (canceled) return;
+ reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
+ };
+ script.src = src;
+ document.body.appendChild(script);
+ });
- scriptTagPromise.cancel = () => {
- if (resolved) return;
- script.src = "";
- canceled = true;
- };
+ scriptTagPromise.cancel = () => {
+ if (resolved) return;
+ script.src = "";
+ canceled = true;
+ };
- return scriptTagPromise;
+ return scriptTagPromise;
}
const MOVIE_LIBRARY_CACHE = new LRU(10);
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
- const cancelableResourcePromises = [];
- const cancelAllResources = () =>
- cancelableResourcePromises.forEach((p) => p.cancel());
+ const cancelableResourcePromises = [];
+ const cancelAllResources = () =>
+ cancelableResourcePromises.forEach((p) => p.cancel());
- // Most of the logic for `loadMovieLibrary` is inside this async function.
- // But we want to attach more fields to the promise before returning it; so
- // we declare this async function separately, then call it, then edit the
- // returned promise!
- const createMovieLibraryPromise = async () => {
- // First, check the LRU cache. This will enable us to quickly return movie
- // libraries, without re-loading and re-parsing and re-executing.
- const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
- if (cachedLibrary) {
- return cachedLibrary;
- }
+ // Most of the logic for `loadMovieLibrary` is inside this async function.
+ // But we want to attach more fields to the promise before returning it; so
+ // we declare this async function separately, then call it, then edit the
+ // returned promise!
+ const createMovieLibraryPromise = async () => {
+ // First, check the LRU cache. This will enable us to quickly return movie
+ // libraries, without re-loading and re-parsing and re-executing.
+ const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
+ if (cachedLibrary) {
+ return cachedLibrary;
+ }
- // Then, load the script tag. (Make sure we set it up to be cancelable!)
- const scriptPromise = loadScriptTag(
- safeImageUrl(librarySrc, { preferArchive }),
- );
- cancelableResourcePromises.push(scriptPromise);
- await scriptPromise;
+ // Then, load the script tag. (Make sure we set it up to be cancelable!)
+ const scriptPromise = loadScriptTag(
+ safeImageUrl(librarySrc, { preferArchive }),
+ );
+ cancelableResourcePromises.push(scriptPromise);
+ await scriptPromise;
- // These library JS files are interesting in their operation. It seems like
- // the idea is, it pushes an object to a global array, and you need to snap
- // it up and see it at the end of the array! And I don't really see a way to
- // like, get by a name or ID that we know by this point. So, here we go, just
- // try to grab it once it arrives!
- //
- // I'm not _sure_ this method is reliable, but it seems to be stable so far
- // in Firefox for me. The things I think I'm observing are:
- // - Script execution order should match insert order,
- // - Onload execution order should match insert order,
- // - BUT, script executions might be batched before onloads.
- // - So, each script grabs the _first_ composition from the list, and
- // deletes it after grabbing. That way, it serves as a FIFO queue!
- // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
- // the race anymore? But fingers crossed!
- if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
- throw new Error(
- `Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
- );
- }
- const [compositionId, composition] = Object.entries(
- window.AdobeAn.compositions,
- )[0];
- if (Object.keys(window.AdobeAn.compositions).length > 1) {
- console.warn(
- `Grabbing composition ${compositionId}, but there are >1 here: `,
- Object.keys(window.AdobeAn.compositions).length,
- );
- }
- delete window.AdobeAn.compositions[compositionId];
- const library = composition.getLibrary();
+ // These library JS files are interesting in their operation. It seems like
+ // the idea is, it pushes an object to a global array, and you need to snap
+ // it up and see it at the end of the array! And I don't really see a way to
+ // like, get by a name or ID that we know by this point. So, here we go, just
+ // try to grab it once it arrives!
+ //
+ // I'm not _sure_ this method is reliable, but it seems to be stable so far
+ // in Firefox for me. The things I think I'm observing are:
+ // - Script execution order should match insert order,
+ // - Onload execution order should match insert order,
+ // - BUT, script executions might be batched before onloads.
+ // - So, each script grabs the _first_ composition from the list, and
+ // deletes it after grabbing. That way, it serves as a FIFO queue!
+ // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
+ // the race anymore? But fingers crossed!
+ if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
+ throw new Error(
+ `Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
+ );
+ }
+ const [compositionId, composition] = Object.entries(
+ window.AdobeAn.compositions,
+ )[0];
+ if (Object.keys(window.AdobeAn.compositions).length > 1) {
+ console.warn(
+ `Grabbing composition ${compositionId}, but there are >1 here: `,
+ Object.keys(window.AdobeAn.compositions).length,
+ );
+ }
+ delete window.AdobeAn.compositions[compositionId];
+ const library = composition.getLibrary();
- // One more loading step as part of loading this library is loading the
- // images it uses for sprites.
- //
- // TODO: I guess the manifest has these too, so if we could use our DB cache
- // to get the manifest to us faster, then we could avoid a network RTT
- // on the critical path by preloading these images before the JS file
- // even gets to us?
- const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
- const manifestImages = new Map(
- library.properties.manifest.map(({ id, src }) => [
- id,
- loadImage(librarySrcDir + "/" + src, {
- crossOrigin: "anonymous",
- preferArchive,
- }),
- ]),
- );
+ // One more loading step as part of loading this library is loading the
+ // images it uses for sprites.
+ //
+ // TODO: I guess the manifest has these too, so if we could use our DB cache
+ // to get the manifest to us faster, then we could avoid a network RTT
+ // on the critical path by preloading these images before the JS file
+ // even gets to us?
+ const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
+ const manifestImages = new Map(
+ library.properties.manifest.map(({ id, src }) => [
+ id,
+ loadImage(librarySrcDir + "/" + src, {
+ crossOrigin: "anonymous",
+ preferArchive,
+ }),
+ ]),
+ );
- // Wait for the images, and make sure they're cancelable while we do.
- const manifestImagePromises = manifestImages.values();
- cancelableResourcePromises.push(...manifestImagePromises);
- await Promise.all(manifestImagePromises);
+ // Wait for the images, and make sure they're cancelable while we do.
+ const manifestImagePromises = manifestImages.values();
+ cancelableResourcePromises.push(...manifestImagePromises);
+ await Promise.all(manifestImagePromises);
- // Finally, once we have the images loaded, the library object expects us to
- // mutate it (!) to give it the actual image and sprite sheet objects from
- // the loaded images. That's how the MovieClip's internal JS objects will
- // access the loaded data!
- const images = composition.getImages();
- for (const [id, image] of manifestImages.entries()) {
- images[id] = await image;
- }
- const spriteSheets = composition.getSpriteSheet();
- for (const { name, frames } of library.ssMetadata) {
- const image = await manifestImages.get(name);
- spriteSheets[name] = new window.createjs.SpriteSheet({
- images: [image],
- frames,
- });
- }
+ // Finally, once we have the images loaded, the library object expects us to
+ // mutate it (!) to give it the actual image and sprite sheet objects from
+ // the loaded images. That's how the MovieClip's internal JS objects will
+ // access the loaded data!
+ const images = composition.getImages();
+ for (const [id, image] of manifestImages.entries()) {
+ images[id] = await image;
+ }
+ const spriteSheets = composition.getSpriteSheet();
+ for (const { name, frames } of library.ssMetadata) {
+ const image = await manifestImages.get(name);
+ spriteSheets[name] = new window.createjs.SpriteSheet({
+ images: [image],
+ frames,
+ });
+ }
- MOVIE_LIBRARY_CACHE.set(librarySrc, library);
+ MOVIE_LIBRARY_CACHE.set(librarySrc, library);
- return library;
- };
+ return library;
+ };
- const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
- // When any part of the movie library fails, we also cancel the other
- // resources ourselves, to avoid stray throws for resources that fail after
- // the parent catches the initial failure. We re-throw the initial failure
- // for the parent to handle, though!
- cancelAllResources();
- throw e;
- });
+ const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
+ // When any part of the movie library fails, we also cancel the other
+ // resources ourselves, to avoid stray throws for resources that fail after
+ // the parent catches the initial failure. We re-throw the initial failure
+ // for the parent to handle, though!
+ cancelAllResources();
+ throw e;
+ });
- // To cancel a `loadMovieLibrary`, cancel all of the resource promises we
- // load as part of it. That should effectively halt the async function above
- // (anything not yet loaded will stop loading), and ensure that stray
- // failures don't trigger uncaught promise rejection warnings.
- movieLibraryPromise.cancel = cancelAllResources;
+ // To cancel a `loadMovieLibrary`, cancel all of the resource promises we
+ // load as part of it. That should effectively halt the async function above
+ // (anything not yet loaded will stop loading), and ensure that stray
+ // failures don't trigger uncaught promise rejection warnings.
+ movieLibraryPromise.cancel = cancelAllResources;
- return movieLibraryPromise;
+ return movieLibraryPromise;
}
export function buildMovieClip(library, libraryUrl) {
- let constructorName;
- try {
- const fileName = decodeURI(libraryUrl).split("/").pop();
- const fileNameWithoutExtension = fileName.split(".")[0];
- constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
- if (constructorName.match(/^[0-9]/)) {
- constructorName = "_" + constructorName;
- }
- } catch (e) {
- throw new Error(
- `Movie libraryUrl ${JSON.stringify(
- libraryUrl,
- )} did not match expected format: ${e.message}`,
- );
- }
+ let constructorName;
+ try {
+ const fileName = decodeURI(libraryUrl).split("/").pop();
+ const fileNameWithoutExtension = fileName.split(".")[0];
+ constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
+ if (constructorName.match(/^[0-9]/)) {
+ constructorName = "_" + constructorName;
+ }
+ } catch (e) {
+ throw new Error(
+ `Movie libraryUrl ${JSON.stringify(
+ libraryUrl,
+ )} did not match expected format: ${e.message}`,
+ );
+ }
- const LibraryMovieClipConstructor = library[constructorName];
- if (!LibraryMovieClipConstructor) {
- throw new Error(
- `Expected JS movie library ${libraryUrl} to contain a constructor ` +
- `named ${constructorName}, but it did not: ${Object.keys(library)}`,
- );
- }
- const movieClip = new LibraryMovieClipConstructor();
+ const LibraryMovieClipConstructor = library[constructorName];
+ if (!LibraryMovieClipConstructor) {
+ throw new Error(
+ `Expected JS movie library ${libraryUrl} to contain a constructor ` +
+ `named ${constructorName}, but it did not: ${Object.keys(library)}`,
+ );
+ }
+ const movieClip = new LibraryMovieClipConstructor();
- return movieClip;
+ return movieClip;
}
/**
@@ -489,15 +489,15 @@ export function buildMovieClip(library, libraryUrl) {
* there are any animated areas.
*/
export function hasAnimations(createjsNode) {
- return (
- // Some nodes have simple animation frames.
- createjsNode.totalFrames > 1 ||
- // Tweens are a form of animation that can happen separately from frames.
- // They expect timer ticks to happen, and they change the scene accordingly.
- createjsNode?.timeline?.tweens?.length >= 1 ||
- // And some nodes have _children_ that are animated.
- (createjsNode.children || []).some(hasAnimations)
- );
+ return (
+ // Some nodes have simple animation frames.
+ createjsNode.totalFrames > 1 ||
+ // Tweens are a form of animation that can happen separately from frames.
+ // They expect timer ticks to happen, and they change the scene accordingly.
+ createjsNode?.timeline?.tweens?.length >= 1 ||
+ // And some nodes have _children_ that are animated.
+ (createjsNode.children || []).some(hasAnimations)
+ );
}
export default OutfitMovieLayer;
diff --git a/app/javascript/wardrobe-2020/components/OutfitPreview.js b/app/javascript/wardrobe-2020/components/OutfitPreview.js
index 2e138025..f7a75c3f 100644
--- a/app/javascript/wardrobe-2020/components/OutfitPreview.js
+++ b/app/javascript/wardrobe-2020/components/OutfitPreview.js
@@ -1,11 +1,11 @@
import React from "react";
import {
- Box,
- DarkMode,
- Flex,
- Text,
- useColorModeValue,
- useToast,
+ Box,
+ DarkMode,
+ Flex,
+ Text,
+ useColorModeValue,
+ useToast,
} from "@chakra-ui/react";
import LRU from "lru-cache";
import { WarningIcon } from "@chakra-ui/icons";
@@ -13,9 +13,9 @@ import { ClassNames } from "@emotion/react";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import OutfitMovieLayer, {
- buildMovieClip,
- hasAnimations,
- loadMovieLibrary,
+ buildMovieClip,
+ hasAnimations,
+ loadMovieLibrary,
} from "./OutfitMovieLayer";
import HangerSpinner from "./HangerSpinner";
import { loadImage, safeImageUrl, useLocalStorage } from "../util";
@@ -37,8 +37,8 @@ import usePreferArchive from "./usePreferArchive";
* useOutfitState both getting appearance data on first load...
*/
function OutfitPreview(props) {
- const { preview } = useOutfitPreview(props);
- return preview;
+ const { preview } = useOutfitPreview(props);
+ return preview;
}
/**
@@ -49,110 +49,110 @@ function OutfitPreview(props) {
* want to show some additional UI that uses the appearance data we loaded!
*/
export function useOutfitPreview({
- speciesId,
- colorId,
- pose,
- altStyleId,
- wornItemIds,
- appearanceId = null,
- isLoading = false,
- placeholder = null,
- loadingDelayMs,
- spinnerVariant,
- onChangeHasAnimations = null,
- ...props
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ wornItemIds,
+ appearanceId = null,
+ isLoading = false,
+ placeholder = null,
+ loadingDelayMs,
+ spinnerVariant,
+ onChangeHasAnimations = null,
+ ...props
}) {
- const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
- const toast = useToast();
+ const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
+ const toast = useToast();
- const appearance = useOutfitAppearance({
- speciesId,
- colorId,
- pose,
- altStyleId,
- appearanceId,
- wornItemIds,
- });
- const { loading, error, visibleLayers } = appearance;
+ const appearance = useOutfitAppearance({
+ speciesId,
+ colorId,
+ pose,
+ altStyleId,
+ appearanceId,
+ wornItemIds,
+ });
+ const { loading, error, visibleLayers } = appearance;
- const {
- loading: loading2,
- error: error2,
- loadedLayers,
- layersHaveAnimations,
- } = usePreloadLayers(visibleLayers);
+ const {
+ loading: loading2,
+ error: error2,
+ loadedLayers,
+ layersHaveAnimations,
+ } = usePreloadLayers(visibleLayers);
- const onMovieError = React.useCallback(() => {
- if (!toast.isActive("outfit-preview-on-movie-error")) {
- toast({
- id: "outfit-preview-on-movie-error",
- status: "warning",
- title: "Oops, we couldn't load one of these animations.",
- description: "We'll show a static image version instead.",
- duration: null,
- isClosable: true,
- });
- }
- }, [toast]);
+ const onMovieError = React.useCallback(() => {
+ if (!toast.isActive("outfit-preview-on-movie-error")) {
+ toast({
+ id: "outfit-preview-on-movie-error",
+ status: "warning",
+ title: "Oops, we couldn't load one of these animations.",
+ description: "We'll show a static image version instead.",
+ duration: null,
+ isClosable: true,
+ });
+ }
+ }, [toast]);
- const onLowFps = React.useCallback(
- (fps) => {
- setIsPaused(true);
- console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
+ const onLowFps = React.useCallback(
+ (fps) => {
+ setIsPaused(true);
+ console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
- if (!toast.isActive("outfit-preview-on-low-fps")) {
- toast({
- id: "outfit-preview-on-low-fps",
- status: "warning",
- title: "Sorry, the animation was lagging, so we paused it! 😖",
- description:
- "We do this to help make sure your machine doesn't lag too much! " +
- "You can unpause the preview to try again.",
- duration: null,
- isClosable: true,
- });
- }
- },
- [setIsPaused, toast],
- );
+ if (!toast.isActive("outfit-preview-on-low-fps")) {
+ toast({
+ id: "outfit-preview-on-low-fps",
+ status: "warning",
+ title: "Sorry, the animation was lagging, so we paused it! 😖",
+ description:
+ "We do this to help make sure your machine doesn't lag too much! " +
+ "You can unpause the preview to try again.",
+ duration: null,
+ isClosable: true,
+ });
+ }
+ },
+ [setIsPaused, toast],
+ );
- React.useEffect(() => {
- if (onChangeHasAnimations) {
- onChangeHasAnimations(layersHaveAnimations);
- }
- }, [layersHaveAnimations, onChangeHasAnimations]);
+ React.useEffect(() => {
+ if (onChangeHasAnimations) {
+ onChangeHasAnimations(layersHaveAnimations);
+ }
+ }, [layersHaveAnimations, onChangeHasAnimations]);
- const textColor = useColorModeValue("green.700", "white");
+ const textColor = useColorModeValue("green.700", "white");
- let preview;
- if (error || error2) {
- preview = (
-
-
-
-
- Could not load preview. Try again?
-
-
- );
- } else {
- preview = (
-
- );
- }
+ let preview;
+ if (error || error2) {
+ preview = (
+
+
+
+
+ Could not load preview. Try again?
+
+
+ );
+ } else {
+ preview = (
+
+ );
+ }
- return { appearance, preview };
+ return { appearance, preview };
}
/**
@@ -160,218 +160,218 @@ export function useOutfitPreview({
* used both in the main outfit preview, and in other minor UIs!
*/
export function OutfitLayers({
- loading,
- visibleLayers,
- placeholder = null,
- loadingDelayMs = 500,
- spinnerVariant = "overlay",
- doTransitions = false,
- isPaused = true,
- onMovieError = null,
- onLowFps = null,
- ...props
+ loading,
+ visibleLayers,
+ placeholder = null,
+ loadingDelayMs = 500,
+ spinnerVariant = "overlay",
+ doTransitions = false,
+ isPaused = true,
+ onMovieError = null,
+ onLowFps = null,
+ ...props
}) {
- const [hiResMode] = useLocalStorage("DTIHiResMode", false);
- const [preferArchive] = usePreferArchive();
+ const [hiResMode] = useLocalStorage("DTIHiResMode", false);
+ const [preferArchive] = usePreferArchive();
- const containerRef = React.useRef(null);
- const [canvasSize, setCanvasSize] = React.useState(0);
- const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
- React.useState(false);
+ const containerRef = React.useRef(null);
+ const [canvasSize, setCanvasSize] = React.useState(0);
+ const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
+ React.useState(false);
- // When we start in a loading state, or re-enter a loading state, start the
- // loading delay timer.
- React.useEffect(() => {
- if (loading) {
- setLoadingDelayHasPassed(false);
- const t = setTimeout(
- () => setLoadingDelayHasPassed(true),
- loadingDelayMs,
- );
- return () => clearTimeout(t);
- }
- }, [loadingDelayMs, loading]);
+ // When we start in a loading state, or re-enter a loading state, start the
+ // loading delay timer.
+ React.useEffect(() => {
+ if (loading) {
+ setLoadingDelayHasPassed(false);
+ const t = setTimeout(
+ () => setLoadingDelayHasPassed(true),
+ loadingDelayMs,
+ );
+ return () => clearTimeout(t);
+ }
+ }, [loadingDelayMs, loading]);
- React.useLayoutEffect(() => {
- function computeAndSaveCanvasSize() {
- setCanvasSize(
- // Follow an algorithm similar to the sizing: a square that
- // covers the available space, without exceeding the natural image size
- // (which is 600px).
- //
- // TODO: Once we're entirely off PNGs, we could drop the 600
- // requirement, and let SVGs and movies scale up as far as they
- // want...
- Math.min(
- containerRef.current.offsetWidth,
- containerRef.current.offsetHeight,
- 600,
- ),
- );
- }
+ React.useLayoutEffect(() => {
+ function computeAndSaveCanvasSize() {
+ setCanvasSize(
+ // Follow an algorithm similar to the sizing: a square that
+ // covers the available space, without exceeding the natural image size
+ // (which is 600px).
+ //
+ // TODO: Once we're entirely off PNGs, we could drop the 600
+ // requirement, and let SVGs and movies scale up as far as they
+ // want...
+ Math.min(
+ containerRef.current.offsetWidth,
+ containerRef.current.offsetHeight,
+ 600,
+ ),
+ );
+ }
- computeAndSaveCanvasSize();
- window.addEventListener("resize", computeAndSaveCanvasSize);
- return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
- }, [setCanvasSize]);
+ computeAndSaveCanvasSize();
+ window.addEventListener("resize", computeAndSaveCanvasSize);
+ return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
+ }, [setCanvasSize]);
- const layersWithAssets = visibleLayers.filter((l) =>
- layerHasUsableAssets(l, { hiResMode }),
- );
+ const layersWithAssets = visibleLayers.filter((l) =>
+ layerHasUsableAssets(l, { hiResMode }),
+ );
- return (
-
- {({ css }) => (
-
- {placeholder && (
-
-
- {placeholder}
-
-
- )}
-
- {layersWithAssets.map((layer) => (
-
-
+ {({ css }) => (
+
+ {placeholder && (
+
+
+ {placeholder}
+
+
+ )}
+
+ {layersWithAssets.map((layer) => (
+
+
- {layer.canvasMovieLibraryUrl ? (
-
- ) : (
-
- )}
-
-
- ))}
-
-
- {spinnerVariant === "overlay" && (
- <>
-
- {/* Against the dark overlay, use the Dark Mode spinner. */}
-
-
-
- >
- )}
- {spinnerVariant === "corner" && (
-
- )}
-
-
- )}
-
- );
+ &.exit-active {
+ opacity: 0;
+ transition: opacity 0.2s;
+ }
+ `}
+ >
+ {layer.canvasMovieLibraryUrl ? (
+
+ ) : (
+
+ )}
+
+
+ ))}
+
+
+ {spinnerVariant === "overlay" && (
+ <>
+
+ {/* Against the dark overlay, use the Dark Mode spinner. */}
+
+
+
+ >
+ )}
+ {spinnerVariant === "corner" && (
+
+ )}
+
+
+ )}
+
+ );
}
export function FullScreenCenter({ children, ...otherProps }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
- if (hiResMode && layer.svgUrl) {
- return layer.svgUrl;
- } else if (layer.imageUrl) {
- return layer.imageUrl;
- } else {
- return null;
- }
+ if (hiResMode && layer.svgUrl) {
+ return layer.svgUrl;
+ } else if (layer.imageUrl) {
+ return layer.imageUrl;
+ } else {
+ return null;
+ }
}
function layerHasUsableAssets(layer, options = {}) {
- return getBestImageUrlForLayer(layer, options) != null;
+ return getBestImageUrlForLayer(layer, options) != null;
}
/**
@@ -380,116 +380,116 @@ function layerHasUsableAssets(layer, options = {}) {
* all the new layers are ready, then show them all at once!
*/
export function usePreloadLayers(layers) {
- const [hiResMode] = useLocalStorage("DTIHiResMode", false);
- const [preferArchive] = usePreferArchive();
+ const [hiResMode] = useLocalStorage("DTIHiResMode", false);
+ const [preferArchive] = usePreferArchive();
- const [error, setError] = React.useState(null);
- const [loadedLayers, setLoadedLayers] = React.useState([]);
- const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
+ const [error, setError] = React.useState(null);
+ const [loadedLayers, setLoadedLayers] = React.useState([]);
+ const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
- // NOTE: This condition would need to change if we started loading one at a
- // time, or if the error case would need to show a partial state!
- const loading = layers.length > 0 && loadedLayers !== layers;
+ // NOTE: This condition would need to change if we started loading one at a
+ // time, or if the error case would need to show a partial state!
+ const loading = layers.length > 0 && loadedLayers !== layers;
- React.useEffect(() => {
- // HACK: Don't clear the preview when we have zero layers, because it
- // usually means the parent is still loading data. I feel like this isn't
- // the right abstraction, though...
- if (layers.length === 0) {
- return;
- }
+ React.useEffect(() => {
+ // HACK: Don't clear the preview when we have zero layers, because it
+ // usually means the parent is still loading data. I feel like this isn't
+ // the right abstraction, though...
+ if (layers.length === 0) {
+ return;
+ }
- let canceled = false;
- setError(null);
- setLayersHaveAnimations(false);
+ let canceled = false;
+ setError(null);
+ setLayersHaveAnimations(false);
- const minimalAssetPromises = [];
- const imageAssetPromises = [];
- const movieAssetPromises = [];
- for (const layer of layers) {
- const imageUrl = getBestImageUrlForLayer(layer, { hiResMode });
- const imageAssetPromise =
- imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null;
- if (imageAssetPromise != null) {
- imageAssetPromises.push(imageAssetPromise);
- }
+ const minimalAssetPromises = [];
+ const imageAssetPromises = [];
+ const movieAssetPromises = [];
+ for (const layer of layers) {
+ const imageUrl = getBestImageUrlForLayer(layer, { hiResMode });
+ const imageAssetPromise =
+ imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null;
+ if (imageAssetPromise != null) {
+ imageAssetPromises.push(imageAssetPromise);
+ }
- if (layer.canvasMovieLibraryUrl) {
- // Start preloading the movie. But we won't block on it! The blocking
- // request will still be the image, which we'll show as a
- // placeholder, which should usually be noticeably faster!
- const movieLibraryPromise = loadMovieLibrary(
- layer.canvasMovieLibraryUrl,
- { preferArchive },
- );
- const movieAssetPromise = movieLibraryPromise.then((library) => ({
- library,
- libraryUrl: layer.canvasMovieLibraryUrl,
- }));
- movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
- movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
- movieAssetPromises.push(movieAssetPromise);
+ if (layer.canvasMovieLibraryUrl) {
+ // Start preloading the movie. But we won't block on it! The blocking
+ // request will still be the image, which we'll show as a
+ // placeholder, which should usually be noticeably faster!
+ const movieLibraryPromise = loadMovieLibrary(
+ layer.canvasMovieLibraryUrl,
+ { preferArchive },
+ );
+ const movieAssetPromise = movieLibraryPromise.then((library) => ({
+ library,
+ libraryUrl: layer.canvasMovieLibraryUrl,
+ }));
+ movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
+ movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
+ movieAssetPromises.push(movieAssetPromise);
- // The minimal asset for the movie case is *either* the image *or*
- // the movie, because we can start rendering when either is ready.
- minimalAssetPromises.push(
- Promise.any([imageAssetPromise, movieAssetPromise]),
- );
- } else if (imageAssetPromise != null) {
- minimalAssetPromises.push(imageAssetPromise);
- } else {
- console.warn(
- `Skipping preloading layer ${layer.id}: no asset URLs found`,
- );
- }
- }
+ // The minimal asset for the movie case is *either* the image *or*
+ // the movie, because we can start rendering when either is ready.
+ minimalAssetPromises.push(
+ Promise.any([imageAssetPromise, movieAssetPromise]),
+ );
+ } else if (imageAssetPromise != null) {
+ minimalAssetPromises.push(imageAssetPromise);
+ } else {
+ console.warn(
+ `Skipping preloading layer ${layer.id}: no asset URLs found`,
+ );
+ }
+ }
- // When the minimal assets have loaded, we can say the layers have
- // loaded, and allow the UI to start showing them!
- Promise.all(minimalAssetPromises)
- .then(() => {
- if (canceled) return;
- setLoadedLayers(layers);
- })
- .catch((e) => {
- if (canceled) return;
- console.error("Error preloading outfit layers", e);
- setError(e);
+ // When the minimal assets have loaded, we can say the layers have
+ // loaded, and allow the UI to start showing them!
+ Promise.all(minimalAssetPromises)
+ .then(() => {
+ if (canceled) return;
+ setLoadedLayers(layers);
+ })
+ .catch((e) => {
+ if (canceled) return;
+ console.error("Error preloading outfit layers", e);
+ setError(e);
- // Cancel any remaining promises, if cancelable.
- imageAssetPromises.forEach((p) => p.cancel && p.cancel());
- movieAssetPromises.forEach((p) => p.cancel && p.cancel());
- });
+ // Cancel any remaining promises, if cancelable.
+ imageAssetPromises.forEach((p) => p.cancel && p.cancel());
+ movieAssetPromises.forEach((p) => p.cancel && p.cancel());
+ });
- // As the movie assets come in, check them for animations, to decide
- // whether to show the Play/Pause button.
- const checkHasAnimations = (asset) => {
- if (canceled) return;
- let assetHasAnimations;
- try {
- assetHasAnimations = getHasAnimationsForMovieAsset(asset);
- } catch (e) {
- console.error("Error testing layers for animations", e);
- setError(e);
- return;
- }
+ // As the movie assets come in, check them for animations, to decide
+ // whether to show the Play/Pause button.
+ const checkHasAnimations = (asset) => {
+ if (canceled) return;
+ let assetHasAnimations;
+ try {
+ assetHasAnimations = getHasAnimationsForMovieAsset(asset);
+ } catch (e) {
+ console.error("Error testing layers for animations", e);
+ setError(e);
+ return;
+ }
- setLayersHaveAnimations(
- (alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
- );
- };
- movieAssetPromises.forEach((p) =>
- p.then(checkHasAnimations).catch((e) => {
- console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
- }),
- );
+ setLayersHaveAnimations(
+ (alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
+ );
+ };
+ movieAssetPromises.forEach((p) =>
+ p.then(checkHasAnimations).catch((e) => {
+ console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
+ }),
+ );
- return () => {
- canceled = true;
- };
- }, [layers, hiResMode, preferArchive]);
+ return () => {
+ canceled = true;
+ };
+ }, [layers, hiResMode, preferArchive]);
- return { loading, error, loadedLayers, layersHaveAnimations };
+ return { loading, error, loadedLayers, layersHaveAnimations };
}
// This cache is large because it's only storing booleans; mostly just capping
@@ -497,26 +497,26 @@ export function usePreloadLayers(layers) {
const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
- // This operation can be pretty expensive! We store a cache to only do it
- // once per layer per session ish, instead of on each outfit change.
- const cachedHasAnimations =
- HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
- if (cachedHasAnimations) {
- return cachedHasAnimations;
- }
+ // This operation can be pretty expensive! We store a cache to only do it
+ // once per layer per session ish, instead of on each outfit change.
+ const cachedHasAnimations =
+ HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
+ if (cachedHasAnimations) {
+ return cachedHasAnimations;
+ }
- const movieClip = buildMovieClip(library, libraryUrl);
+ const movieClip = buildMovieClip(library, libraryUrl);
- // Some movie clips require you to tick to the first frame of the movie
- // before the children mount onto the stage. If we detect animations
- // without doing this, we'll incorrectly say no, because we see no children!
- // Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
- movieClip.advance();
+ // Some movie clips require you to tick to the first frame of the movie
+ // before the children mount onto the stage. If we detect animations
+ // without doing this, we'll incorrectly say no, because we see no children!
+ // Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
+ movieClip.advance();
- const movieClipHasAnimations = hasAnimations(movieClip);
+ const movieClipHasAnimations = hasAnimations(movieClip);
- HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
- return movieClipHasAnimations;
+ HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
+ return movieClipHasAnimations;
}
/**
@@ -524,18 +524,18 @@ function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
* the container element once it triggers.
*/
function FadeInOnLoad({ children, ...props }) {
- const [isLoaded, setIsLoaded] = React.useState(false);
+ const [isLoaded, setIsLoaded] = React.useState(false);
- const onLoad = React.useCallback(() => setIsLoaded(true), []);
+ const onLoad = React.useCallback(() => setIsLoaded(true), []);
- const child = React.Children.only(children);
- const wrappedChild = React.cloneElement(child, { onLoad });
+ const child = React.Children.only(children);
+ const wrappedChild = React.cloneElement(child, { onLoad });
- return (
-
- {wrappedChild}
-
- );
+ return (
+
+ {wrappedChild}
+
+ );
}
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any
@@ -543,16 +543,16 @@ function FadeInOnLoad({ children, ...props }) {
// range… but it's affected 25 users in the past two months, which is
// surprisingly high. And the polyfill is small, so let's do it! (11/2021)
Promise.any =
- Promise.any ||
- function ($) {
- return new Promise(function (D, E, A, L) {
- A = [];
- L = $.map(function ($, i) {
- return Promise.resolve($).then(D, function (O) {
- return ((A[i] = O), --L) || E({ errors: A });
- });
- }).length;
- });
- };
+ Promise.any ||
+ function ($) {
+ return new Promise(function (D, E, A, L) {
+ A = [];
+ L = $.map(function ($, i) {
+ return Promise.resolve($).then(D, function (O) {
+ return ((A[i] = O), --L) || E({ errors: A });
+ });
+ }).length;
+ });
+ };
export default OutfitPreview;
diff --git a/app/javascript/wardrobe-2020/components/OutfitThumbnail.js b/app/javascript/wardrobe-2020/components/OutfitThumbnail.js
index a7890f83..243b06d8 100644
--- a/app/javascript/wardrobe-2020/components/OutfitThumbnail.js
+++ b/app/javascript/wardrobe-2020/components/OutfitThumbnail.js
@@ -2,21 +2,21 @@ import React from "react";
import { Box } from "@chakra-ui/react";
function OutfitThumbnail({ outfitId, updatedAt, ...props }) {
- const versionTimestamp = new Date(updatedAt).getTime();
+ const versionTimestamp = new Date(updatedAt).getTime();
- // NOTE: It'd be more reliable for testing to use a relative path, but
- // generating these on dev is SO SLOW, that I'd rather just not.
- const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
- const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
+ // NOTE: It'd be more reliable for testing to use a relative path, but
+ // generating these on dev is SO SLOW, that I'd rather just not.
+ const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
+ const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
- return (
-
- );
+ return (
+
+ );
}
export default OutfitThumbnail;
diff --git a/app/javascript/wardrobe-2020/components/PaginationToolbar.js b/app/javascript/wardrobe-2020/components/PaginationToolbar.js
index c3b34f8b..f57b2973 100644
--- a/app/javascript/wardrobe-2020/components/PaginationToolbar.js
+++ b/app/javascript/wardrobe-2020/components/PaginationToolbar.js
@@ -2,111 +2,111 @@ import React from "react";
import { Box, Button, Flex, Select } from "@chakra-ui/react";
function PaginationToolbar({
- isLoading,
- numTotalPages,
- currentPageNumber,
- goToPageNumber,
- buildPageUrl,
- size = "md",
- ...props
+ isLoading,
+ numTotalPages,
+ currentPageNumber,
+ goToPageNumber,
+ buildPageUrl,
+ size = "md",
+ ...props
}) {
- const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
- const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
- const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
+ const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
+ const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
+ const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
- const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
- const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
+ const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
+ const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
- return (
-
- goToPageNumber(currentPageNumber - 1)
- : undefined
- }
- _disabled={{
- cursor: isLoading ? "wait" : "not-allowed",
- opacity: 0.4,
- }}
- isDisabled={!hasPrevPage}
- size={size}
- >
- ← Prev
-
- {numTotalPages > 0 && (
-
- Page
-
-
-
- of {numTotalPages}
-
- )}
- goToPageNumber(currentPageNumber + 1)
- : undefined
- }
- _disabled={{
- cursor: isLoading ? "wait" : "not-allowed",
- opacity: 0.4,
- }}
- isDisabled={!hasNextPage}
- size={size}
- >
- Next →
-
-
- );
+ return (
+
+ goToPageNumber(currentPageNumber - 1)
+ : undefined
+ }
+ _disabled={{
+ cursor: isLoading ? "wait" : "not-allowed",
+ opacity: 0.4,
+ }}
+ isDisabled={!hasPrevPage}
+ size={size}
+ >
+ ← Prev
+
+ {numTotalPages > 0 && (
+
+ Page
+
+
+
+ of {numTotalPages}
+
+ )}
+ goToPageNumber(currentPageNumber + 1)
+ : undefined
+ }
+ _disabled={{
+ cursor: isLoading ? "wait" : "not-allowed",
+ opacity: 0.4,
+ }}
+ isDisabled={!hasNextPage}
+ size={size}
+ >
+ Next →
+
+
+ );
}
function LinkOrButton({ href, ...props }) {
- if (href != null) {
- return ;
- } else {
- return ;
- }
+ if (href != null) {
+ return ;
+ } else {
+ return ;
+ }
}
function PageNumberSelect({
- currentPageNumber,
- numTotalPages,
- onChange,
- ...props
+ currentPageNumber,
+ numTotalPages,
+ onChange,
+ ...props
}) {
- const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
+ const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
- const handleChange = React.useCallback(
- (e) => onChange(Number(e.target.value)),
- [onChange],
- );
+ const handleChange = React.useCallback(
+ (e) => onChange(Number(e.target.value)),
+ [onChange],
+ );
- return (
-
- );
+ return (
+
+ );
}
export default PaginationToolbar;
diff --git a/app/javascript/wardrobe-2020/components/SpeciesColorPicker.js b/app/javascript/wardrobe-2020/components/SpeciesColorPicker.js
index 88cffafb..396a2720 100644
--- a/app/javascript/wardrobe-2020/components/SpeciesColorPicker.js
+++ b/app/javascript/wardrobe-2020/components/SpeciesColorPicker.js
@@ -18,320 +18,320 @@ import { Delay, logAndCapture, useFetch } from "../util";
* devices.
*/
function SpeciesColorPicker({
- speciesId,
- colorId,
- idealPose,
- showPlaceholders = false,
- colorPlaceholderText = "",
- speciesPlaceholderText = "",
- stateMustAlwaysBeValid = false,
- isDisabled = false,
- speciesIsDisabled = false,
- size = "md",
- speciesTestId = null,
- colorTestId = null,
- onChange,
+ speciesId,
+ colorId,
+ idealPose,
+ showPlaceholders = false,
+ colorPlaceholderText = "",
+ speciesPlaceholderText = "",
+ stateMustAlwaysBeValid = false,
+ isDisabled = false,
+ speciesIsDisabled = false,
+ size = "md",
+ speciesTestId = null,
+ colorTestId = null,
+ onChange,
}) {
- const {
- loading: loadingMeta,
- error: errorMeta,
- data: meta,
- } = useQuery(gql`
- query SpeciesColorPicker {
- allSpecies {
- id
- name
- standardBodyId # Used for keeping items on during standard color changes
- }
+ const {
+ loading: loadingMeta,
+ error: errorMeta,
+ data: meta,
+ } = useQuery(gql`
+ query SpeciesColorPicker {
+ allSpecies {
+ id
+ name
+ standardBodyId # Used for keeping items on during standard color changes
+ }
- allColors {
- id
- name
- isStandard # Used for keeping items on during standard color changes
- }
- }
- `);
+ allColors {
+ id
+ name
+ isStandard # Used for keeping items on during standard color changes
+ }
+ }
+ `);
- const {
- loading: loadingValids,
- error: errorValids,
- valids,
- } = useAllValidPetPoses();
+ const {
+ loading: loadingValids,
+ error: errorValids,
+ valids,
+ } = useAllValidPetPoses();
- const allColors = (meta && [...meta.allColors]) || [];
- allColors.sort((a, b) => a.name.localeCompare(b.name));
- const allSpecies = (meta && [...meta.allSpecies]) || [];
- allSpecies.sort((a, b) => a.name.localeCompare(b.name));
+ const allColors = (meta && [...meta.allColors]) || [];
+ allColors.sort((a, b) => a.name.localeCompare(b.name));
+ const allSpecies = (meta && [...meta.allSpecies]) || [];
+ allSpecies.sort((a, b) => a.name.localeCompare(b.name));
- const textColor = useColorModeValue("inherit", "green.50");
+ const textColor = useColorModeValue("inherit", "green.50");
- if ((loadingMeta || loadingValids) && !showPlaceholders) {
- return (
-
-
- Loading species/color data…
-
-
- );
- }
+ if ((loadingMeta || loadingValids) && !showPlaceholders) {
+ return (
+
+
+ Loading species/color data…
+
+
+ );
+ }
- if (errorMeta || errorValids) {
- return (
-
- Error loading species/color data.
-
- );
- }
+ if (errorMeta || errorValids) {
+ return (
+
+ Error loading species/color data.
+
+ );
+ }
- // When the color changes, check if the new pair is valid, and update the
- // outfit if so!
- const onChangeColor = (e) => {
- const newColorId = e.target.value;
- console.debug(`SpeciesColorPicker.onChangeColor`, {
- // for IMPRESS-2020-1H
- speciesId,
- colorId,
- newColorId,
- });
+ // When the color changes, check if the new pair is valid, and update the
+ // outfit if so!
+ const onChangeColor = (e) => {
+ const newColorId = e.target.value;
+ console.debug(`SpeciesColorPicker.onChangeColor`, {
+ // for IMPRESS-2020-1H
+ speciesId,
+ colorId,
+ newColorId,
+ });
- // Ignore switching to the placeholder option. It shouldn't generally be
- // doable once real options exist, and it doesn't represent a valid or
- // meaningful transition in the case where it could happen.
- if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
- return;
- }
+ // Ignore switching to the placeholder option. It shouldn't generally be
+ // doable once real options exist, and it doesn't represent a valid or
+ // meaningful transition in the case where it could happen.
+ if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
+ return;
+ }
- const species = allSpecies.find((s) => s.id === speciesId);
- const newColor = allColors.find((c) => c.id === newColorId);
- const validPoses = getValidPoses(valids, speciesId, newColorId);
- const isValid = validPoses.size > 0;
- if (stateMustAlwaysBeValid && !isValid) {
- // NOTE: This shouldn't happen, because we should hide invalid colors.
- logAndCapture(
- new Error(
- `Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
- `with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
- `colorId=${newColorId}.`,
- ),
- );
- return;
- }
- const closestPose = getClosestPose(validPoses, idealPose);
- onChange(species, newColor, isValid, closestPose);
- };
+ const species = allSpecies.find((s) => s.id === speciesId);
+ const newColor = allColors.find((c) => c.id === newColorId);
+ const validPoses = getValidPoses(valids, speciesId, newColorId);
+ const isValid = validPoses.size > 0;
+ if (stateMustAlwaysBeValid && !isValid) {
+ // NOTE: This shouldn't happen, because we should hide invalid colors.
+ logAndCapture(
+ new Error(
+ `Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
+ `with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
+ `colorId=${newColorId}.`,
+ ),
+ );
+ return;
+ }
+ const closestPose = getClosestPose(validPoses, idealPose);
+ onChange(species, newColor, isValid, closestPose);
+ };
- // When the species changes, check if the new pair is valid, and update the
- // outfit if so!
- const onChangeSpecies = (e) => {
- const newSpeciesId = e.target.value;
- console.debug(`SpeciesColorPicker.onChangeSpecies`, {
- // for IMPRESS-2020-1H
- speciesId,
- newSpeciesId,
- colorId,
- });
+ // When the species changes, check if the new pair is valid, and update the
+ // outfit if so!
+ const onChangeSpecies = (e) => {
+ const newSpeciesId = e.target.value;
+ console.debug(`SpeciesColorPicker.onChangeSpecies`, {
+ // for IMPRESS-2020-1H
+ speciesId,
+ newSpeciesId,
+ colorId,
+ });
- // Ignore switching to the placeholder option. It shouldn't generally be
- // doable once real options exist, and it doesn't represent a valid or
- // meaningful transition in the case where it could happen.
- if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
- return;
- }
+ // Ignore switching to the placeholder option. It shouldn't generally be
+ // doable once real options exist, and it doesn't represent a valid or
+ // meaningful transition in the case where it could happen.
+ if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
+ return;
+ }
- const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
- if (!newSpecies) {
- // Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
- // ends up coming out of `onChange`!
- console.debug({ allSpecies, loadingMeta, errorMeta, meta });
- logAndCapture(
- new Error(
- `Assertion error in SpeciesColorPicker: species not found. ` +
- `speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
- `colorId=${colorId}.`,
- ),
- );
- return;
- }
+ const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
+ if (!newSpecies) {
+ // Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
+ // ends up coming out of `onChange`!
+ console.debug({ allSpecies, loadingMeta, errorMeta, meta });
+ logAndCapture(
+ new Error(
+ `Assertion error in SpeciesColorPicker: species not found. ` +
+ `speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
+ `colorId=${colorId}.`,
+ ),
+ );
+ return;
+ }
- let color = allColors.find((c) => c.id === colorId);
- let validPoses = getValidPoses(valids, newSpeciesId, colorId);
- let isValid = validPoses.size > 0;
+ let color = allColors.find((c) => c.id === colorId);
+ let validPoses = getValidPoses(valids, newSpeciesId, colorId);
+ let isValid = validPoses.size > 0;
- if (stateMustAlwaysBeValid && !isValid) {
- // If `stateMustAlwaysBeValid`, but the user switches to a species that
- // doesn't support this color, that's okay and normal! We'll just switch
- // to one of the four basic colors instead.
- const basicColorId = ["8", "34", "61", "84"][
- Math.floor(Math.random() * 4)
- ];
- const basicColor = allColors.find((c) => c.id === basicColorId);
- color = basicColor;
- validPoses = getValidPoses(valids, newSpeciesId, color.id);
- isValid = true;
- }
+ if (stateMustAlwaysBeValid && !isValid) {
+ // If `stateMustAlwaysBeValid`, but the user switches to a species that
+ // doesn't support this color, that's okay and normal! We'll just switch
+ // to one of the four basic colors instead.
+ const basicColorId = ["8", "34", "61", "84"][
+ Math.floor(Math.random() * 4)
+ ];
+ const basicColor = allColors.find((c) => c.id === basicColorId);
+ color = basicColor;
+ validPoses = getValidPoses(valids, newSpeciesId, color.id);
+ isValid = true;
+ }
- const closestPose = getClosestPose(validPoses, idealPose);
- onChange(newSpecies, color, isValid, closestPose);
- };
+ const closestPose = getClosestPose(validPoses, idealPose);
+ onChange(newSpecies, color, isValid, closestPose);
+ };
- // In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
- // species, so the user can't switch. (We handle species differently: if you
- // switch to a new species and the color is invalid, we reset the color. We
- // think this matches users' mental hierarchy of species -> color: showing
- // supported colors for a species makes sense, but the other way around feels
- // confusing and restrictive.)
- //
- // Also, if a color is provided that wouldn't normally be visible, we still
- // show it. This can happen when someone models a new species/color combo for
- // the first time - the boxes will still be red as if it were invalid, but
- // this still smooths out the experience a lot.
- let visibleColors = allColors;
- if (stateMustAlwaysBeValid && valids && speciesId) {
- visibleColors = visibleColors.filter(
- (c) =>
- getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId,
- );
- }
+ // In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
+ // species, so the user can't switch. (We handle species differently: if you
+ // switch to a new species and the color is invalid, we reset the color. We
+ // think this matches users' mental hierarchy of species -> color: showing
+ // supported colors for a species makes sense, but the other way around feels
+ // confusing and restrictive.)
+ //
+ // Also, if a color is provided that wouldn't normally be visible, we still
+ // show it. This can happen when someone models a new species/color combo for
+ // the first time - the boxes will still be red as if it were invalid, but
+ // this still smooths out the experience a lot.
+ let visibleColors = allColors;
+ if (stateMustAlwaysBeValid && valids && speciesId) {
+ visibleColors = visibleColors.filter(
+ (c) =>
+ getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId,
+ );
+ }
- return (
-
-
- {
- // If the selected color isn't in the set we have here, show the
- // placeholder. (Can happen during loading, or if an invalid color ID
- // like null is intentionally provided while the real value loads.)
- !visibleColors.some((c) => c.id === colorId) && (
-
- )
- }
- {
- // A long name for sizing! Should appear below the placeholder, out
- // of view.
- visibleColors.length === 0 &&
- }
- {visibleColors.map((color) => (
-
- ))}
-
-
-
- {
- // If the selected species isn't in the set we have here, show the
- // placeholder. (Can happen during loading, or if an invalid species
- // ID like null is intentionally provided while the real value
- // loads.)
- !allSpecies.some((s) => s.id === speciesId) && (
-
- )
- }
- {
- // A long name for sizing! Should appear below the placeholder, out
- // of view.
- allSpecies.length === 0 &&
- }
- {allSpecies.map((species) => (
-
- ))}
-
-
- );
+ return (
+
+
+ {
+ // If the selected color isn't in the set we have here, show the
+ // placeholder. (Can happen during loading, or if an invalid color ID
+ // like null is intentionally provided while the real value loads.)
+ !visibleColors.some((c) => c.id === colorId) && (
+
+ )
+ }
+ {
+ // A long name for sizing! Should appear below the placeholder, out
+ // of view.
+ visibleColors.length === 0 &&
+ }
+ {visibleColors.map((color) => (
+
+ ))}
+
+
+
+ {
+ // If the selected species isn't in the set we have here, show the
+ // placeholder. (Can happen during loading, or if an invalid species
+ // ID like null is intentionally provided while the real value
+ // loads.)
+ !allSpecies.some((s) => s.id === speciesId) && (
+
+ )
+ }
+ {
+ // A long name for sizing! Should appear below the placeholder, out
+ // of view.
+ allSpecies.length === 0 &&
+ }
+ {allSpecies.map((species) => (
+
+ ))}
+
+
+ );
}
const SpeciesColorSelect = ({
- size,
- valids,
- speciesId,
- colorId,
- isDisabled,
- isLoading,
- ...props
+ size,
+ valids,
+ speciesId,
+ colorId,
+ isDisabled,
+ isLoading,
+ ...props
}) => {
- const backgroundColor = useColorModeValue("white", "gray.600");
- const borderColor = useColorModeValue("green.600", "transparent");
- const textColor = useColorModeValue("inherit", "green.50");
+ const backgroundColor = useColorModeValue("white", "gray.600");
+ const borderColor = useColorModeValue("green.600", "transparent");
+ const textColor = useColorModeValue("inherit", "green.50");
- const loadingProps = isLoading
- ? {
- // Visually the disabled state is the same as the normal state, but
- // with a wait cursor. We don't expect this to take long, and the flash
- // of content is rough!
- opacity: "1 !important",
- cursor: "wait !important",
- }
- : {};
+ const loadingProps = isLoading
+ ? {
+ // Visually the disabled state is the same as the normal state, but
+ // with a wait cursor. We don't expect this to take long, and the flash
+ // of content is rough!
+ opacity: "1 !important",
+ cursor: "wait !important",
+ }
+ : {};
- return (
-
- );
+ return (
+
+ );
};
let cachedResponseForAllValidPetPoses = null;
@@ -346,79 +346,76 @@ let cachedResponseForAllValidPetPoses = null;
* data from GraphQL serves on the first render, without a loading state.
*/
export function useAllValidPetPoses() {
- const networkResponse = useFetch(
- buildImpress2020Url("/api/validPetPoses"),
- {
- responseType: "arrayBuffer",
- // If we already have globally-cached valids, skip the request.
- skip: cachedResponseForAllValidPetPoses != null,
- },
- );
+ const networkResponse = useFetch(buildImpress2020Url("/api/validPetPoses"), {
+ responseType: "arrayBuffer",
+ // If we already have globally-cached valids, skip the request.
+ skip: cachedResponseForAllValidPetPoses != null,
+ });
- // Use the globally-cached response if we have one, or await the network
- // response if not.
- const response = cachedResponseForAllValidPetPoses || networkResponse;
- const { loading, error, data: validsBuffer } = response;
+ // Use the globally-cached response if we have one, or await the network
+ // response if not.
+ const response = cachedResponseForAllValidPetPoses || networkResponse;
+ const { loading, error, data: validsBuffer } = response;
- const valids = React.useMemo(
- () => validsBuffer && new DataView(validsBuffer),
- [validsBuffer],
- );
+ const valids = React.useMemo(
+ () => validsBuffer && new DataView(validsBuffer),
+ [validsBuffer],
+ );
- // Once a network response comes in, save it as the globally-cached response.
- React.useEffect(() => {
- if (
- networkResponse &&
- !networkResponse.loading &&
- !cachedResponseForAllValidPetPoses
- ) {
- cachedResponseForAllValidPetPoses = networkResponse;
- }
- }, [networkResponse]);
+ // Once a network response comes in, save it as the globally-cached response.
+ React.useEffect(() => {
+ if (
+ networkResponse &&
+ !networkResponse.loading &&
+ !cachedResponseForAllValidPetPoses
+ ) {
+ cachedResponseForAllValidPetPoses = networkResponse;
+ }
+ }, [networkResponse]);
- return { loading, error, valids };
+ return { loading, error, valids };
}
function getPairByte(valids, speciesId, colorId) {
- // Reading a bit table, owo!
- const speciesIndex = speciesId - 1;
- const colorIndex = colorId - 1;
- const numColors = valids.getUint8(1);
- const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
- try {
- return valids.getUint8(pairByteIndex);
- } catch (e) {
- logAndCapture(
- new Error(
- `Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`,
- ),
- );
- return 0;
- }
+ // Reading a bit table, owo!
+ const speciesIndex = speciesId - 1;
+ const colorIndex = colorId - 1;
+ const numColors = valids.getUint8(1);
+ const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
+ try {
+ return valids.getUint8(pairByteIndex);
+ } catch (e) {
+ logAndCapture(
+ new Error(
+ `Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`,
+ ),
+ );
+ return 0;
+ }
}
function pairIsValid(valids, speciesId, colorId) {
- return getPairByte(valids, speciesId, colorId) !== 0;
+ return getPairByte(valids, speciesId, colorId) !== 0;
}
export function getValidPoses(valids, speciesId, colorId) {
- const pairByte = getPairByte(valids, speciesId, colorId);
+ const pairByte = getPairByte(valids, speciesId, colorId);
- const validPoses = new Set();
- if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
- if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
- if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
- if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
- if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
- if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
- if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
- if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
+ const validPoses = new Set();
+ if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
+ if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
+ if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
+ if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
+ if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
+ if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
+ if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
+ if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
- return validPoses;
+ return validPoses;
}
export function getClosestPose(validPoses, idealPose) {
- return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
+ return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
}
// For each pose, in what order do we prefer to match other poses?
@@ -431,86 +428,86 @@ export function getClosestPose(validPoses, idealPose) {
// - Unconverted vs converted is the biggest possible difference.
// - Unknown is the pose of last resort - even coming from another unknown.
const closestPosesInOrder = {
- HAPPY_MASC: [
- "HAPPY_MASC",
- "HAPPY_FEM",
- "SAD_MASC",
- "SAD_FEM",
- "SICK_MASC",
- "SICK_FEM",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- HAPPY_FEM: [
- "HAPPY_FEM",
- "HAPPY_MASC",
- "SAD_FEM",
- "SAD_MASC",
- "SICK_FEM",
- "SICK_MASC",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- SAD_MASC: [
- "SAD_MASC",
- "SAD_FEM",
- "HAPPY_MASC",
- "HAPPY_FEM",
- "SICK_MASC",
- "SICK_FEM",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- SAD_FEM: [
- "SAD_FEM",
- "SAD_MASC",
- "HAPPY_FEM",
- "HAPPY_MASC",
- "SICK_FEM",
- "SICK_MASC",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- SICK_MASC: [
- "SICK_MASC",
- "SICK_FEM",
- "SAD_MASC",
- "SAD_FEM",
- "HAPPY_MASC",
- "HAPPY_FEM",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- SICK_FEM: [
- "SICK_FEM",
- "SICK_MASC",
- "SAD_FEM",
- "SAD_MASC",
- "HAPPY_FEM",
- "HAPPY_MASC",
- "UNCONVERTED",
- "UNKNOWN",
- ],
- UNCONVERTED: [
- "UNCONVERTED",
- "HAPPY_FEM",
- "HAPPY_MASC",
- "SAD_FEM",
- "SAD_MASC",
- "SICK_FEM",
- "SICK_MASC",
- "UNKNOWN",
- ],
- UNKNOWN: [
- "HAPPY_FEM",
- "HAPPY_MASC",
- "SAD_FEM",
- "SAD_MASC",
- "SICK_FEM",
- "SICK_MASC",
- "UNCONVERTED",
- "UNKNOWN",
- ],
+ HAPPY_MASC: [
+ "HAPPY_MASC",
+ "HAPPY_FEM",
+ "SAD_MASC",
+ "SAD_FEM",
+ "SICK_MASC",
+ "SICK_FEM",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ HAPPY_FEM: [
+ "HAPPY_FEM",
+ "HAPPY_MASC",
+ "SAD_FEM",
+ "SAD_MASC",
+ "SICK_FEM",
+ "SICK_MASC",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ SAD_MASC: [
+ "SAD_MASC",
+ "SAD_FEM",
+ "HAPPY_MASC",
+ "HAPPY_FEM",
+ "SICK_MASC",
+ "SICK_FEM",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ SAD_FEM: [
+ "SAD_FEM",
+ "SAD_MASC",
+ "HAPPY_FEM",
+ "HAPPY_MASC",
+ "SICK_FEM",
+ "SICK_MASC",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ SICK_MASC: [
+ "SICK_MASC",
+ "SICK_FEM",
+ "SAD_MASC",
+ "SAD_FEM",
+ "HAPPY_MASC",
+ "HAPPY_FEM",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ SICK_FEM: [
+ "SICK_FEM",
+ "SICK_MASC",
+ "SAD_FEM",
+ "SAD_MASC",
+ "HAPPY_FEM",
+ "HAPPY_MASC",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
+ UNCONVERTED: [
+ "UNCONVERTED",
+ "HAPPY_FEM",
+ "HAPPY_MASC",
+ "SAD_FEM",
+ "SAD_MASC",
+ "SICK_FEM",
+ "SICK_MASC",
+ "UNKNOWN",
+ ],
+ UNKNOWN: [
+ "HAPPY_FEM",
+ "HAPPY_MASC",
+ "SAD_FEM",
+ "SAD_MASC",
+ "SICK_FEM",
+ "SICK_MASC",
+ "UNCONVERTED",
+ "UNKNOWN",
+ ],
};
export default React.memo(SpeciesColorPicker);
diff --git a/app/javascript/wardrobe-2020/components/SquareItemCard.js b/app/javascript/wardrobe-2020/components/SquareItemCard.js
index 2e737dce..45b5e7e7 100644
--- a/app/javascript/wardrobe-2020/components/SquareItemCard.js
+++ b/app/javascript/wardrobe-2020/components/SquareItemCard.js
@@ -1,11 +1,11 @@
import React from "react";
import {
- Box,
- IconButton,
- Skeleton,
- useColorModeValue,
- useTheme,
- useToken,
+ Box,
+ IconButton,
+ Skeleton,
+ useColorModeValue,
+ useTheme,
+ useToken,
} from "@chakra-ui/react";
import { ClassNames } from "@emotion/react";
@@ -14,440 +14,440 @@ import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
import usePreferArchive from "./usePreferArchive";
function SquareItemCard({
- item,
- showRemoveButton = false,
- onRemove = () => {},
- tradeMatchingMode = null,
- footer = null,
- ...props
+ item,
+ showRemoveButton = false,
+ onRemove = () => {},
+ tradeMatchingMode = null,
+ footer = null,
+ ...props
}) {
- const outlineShadowValue = useToken("shadows", "outline");
- const mdRadiusValue = useToken("radii", "md");
+ const outlineShadowValue = useToken("shadows", "outline");
+ const mdRadiusValue = useToken("radii", "md");
- const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
- const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
- const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] =
- useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
+ const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
+ const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
+ const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] =
+ useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
- // When this is a trade match, give it an extra colorful shadow highlight so
- // it stands out! (They'll generally be sorted to the front anyway, but this
- // make it easier to scan a user's lists page, and to learn how the sorting
- // works!)
- let tradeMatchShadow;
- if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
- tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
- } else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
- tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
- } else {
- tradeMatchShadow = null;
- }
+ // When this is a trade match, give it an extra colorful shadow highlight so
+ // it stands out! (They'll generally be sorted to the front anyway, but this
+ // make it easier to scan a user's lists page, and to learn how the sorting
+ // works!)
+ let tradeMatchShadow;
+ if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
+ tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
+ } else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
+ tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
+ } else {
+ tradeMatchShadow = null;
+ }
- return (
-
- {({ css }) => (
- // SquareItemCard renders in large lists of 1k+ items, so we get a big
- // perf win by using Emotion directly instead of Chakra's styled-system
- // Box.
-
-
-
- }
- removeButton={
- showRemoveButton ? (
-
- ) : null
- }
- boxShadow={tradeMatchShadow}
- footer={footer}
- />
-
- {showRemoveButton && (
-
+ {({ css }) => (
+ // SquareItemCard renders in large lists of 1k+ items, so we get a big
+ // perf win by using Emotion directly instead of Chakra's styled-system
+ // Box.
+
+
+
+ }
+ removeButton={
+ showRemoveButton ? (
+
+ ) : null
+ }
+ boxShadow={tradeMatchShadow}
+ footer={footer}
+ />
+
+ {showRemoveButton && (
+
-
-
- )}
-
- )}
-
- );
+ opacity: 0;
+ [role="group"]:hover &,
+ [role="group"]:focus-within &,
+ &:hover,
+ &:focus-within {
+ opacity: 1;
+ }
+ `}
+ >
+
+
+ )}
+
+ )}
+
+ );
}
function SquareItemCardLayout({
- name,
- thumbnailImage,
- footer,
- minHeightNumLines = 2,
- boxShadow = null,
+ name,
+ thumbnailImage,
+ footer,
+ minHeightNumLines = 2,
+ boxShadow = null,
}) {
- const { brightBackground } = useCommonStyles();
- const brightBackgroundValue = useToken("colors", brightBackground);
- const theme = useTheme();
+ const { brightBackground } = useCommonStyles();
+ const brightBackgroundValue = useToken("colors", brightBackground);
+ const theme = useTheme();
- return (
- // SquareItemCard renders in large lists of 1k+ items, so we get a big perf
- // win by using Emotion directly instead of Chakra's styled-system Box.
-
- {({ css }) => (
-
- {thumbnailImage}
-
+ {({ css }) => (
+
+ {thumbnailImage}
+
- {name}
-
- {footer && (
-
- {footer}
-
- )}
-
- )}
-
- );
+ min-height: ${minHeightNumLines * 1.5 + "em"};
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ width: 100%;
+ `}
+ // HACK: Emotion turns this into -webkit-display: -webkit-box?
+ style={{ display: "-webkit-box" }}
+ >
+ {name}
+
+ {footer && (
+
+ {footer}
+
+ )}
+
+ )}
+
+ );
}
function ItemThumbnail({ item, tradeMatchingMode }) {
- const [preferArchive] = usePreferArchive();
- const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
+ const [preferArchive] = usePreferArchive();
+ const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
- const thumbnailShadowColor = useColorModeValue(
- `${kindColorScheme}.200`,
- `${kindColorScheme}.600`,
- );
- const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
- const mdRadiusValue = useToken("radii", "md");
+ const thumbnailShadowColor = useColorModeValue(
+ `${kindColorScheme}.200`,
+ `${kindColorScheme}.600`,
+ );
+ const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
+ const mdRadiusValue = useToken("radii", "md");
- // Normally, we just show the owns/wants badges depending on whether the
- // current user owns/wants it. But, in a trade list, we use trade-matching
- // mode instead: only show the badge if it represents a viable trade, and add
- // some extra flair to it, too!
- let showOwnsBadge;
- let showWantsBadge;
- let showTradeMatchFlair;
- if (tradeMatchingMode == null) {
- showOwnsBadge = item.currentUserOwnsThis;
- showWantsBadge = item.currentUserWantsThis;
- showTradeMatchFlair = false;
- } else if (tradeMatchingMode === "offering") {
- showOwnsBadge = false;
- showWantsBadge = item.currentUserWantsThis;
- showTradeMatchFlair = true;
- } else if (tradeMatchingMode === "seeking") {
- showOwnsBadge = item.currentUserOwnsThis;
- showWantsBadge = false;
- showTradeMatchFlair = true;
- } else if (tradeMatchingMode === "hide-all") {
- showOwnsBadge = false;
- showWantsBadge = false;
- showTradeMatchFlair = false;
- } else {
- throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
- }
+ // Normally, we just show the owns/wants badges depending on whether the
+ // current user owns/wants it. But, in a trade list, we use trade-matching
+ // mode instead: only show the badge if it represents a viable trade, and add
+ // some extra flair to it, too!
+ let showOwnsBadge;
+ let showWantsBadge;
+ let showTradeMatchFlair;
+ if (tradeMatchingMode == null) {
+ showOwnsBadge = item.currentUserOwnsThis;
+ showWantsBadge = item.currentUserWantsThis;
+ showTradeMatchFlair = false;
+ } else if (tradeMatchingMode === "offering") {
+ showOwnsBadge = false;
+ showWantsBadge = item.currentUserWantsThis;
+ showTradeMatchFlair = true;
+ } else if (tradeMatchingMode === "seeking") {
+ showOwnsBadge = item.currentUserOwnsThis;
+ showWantsBadge = false;
+ showTradeMatchFlair = true;
+ } else if (tradeMatchingMode === "hide-all") {
+ showOwnsBadge = false;
+ showWantsBadge = false;
+ showTradeMatchFlair = false;
+ } else {
+ throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
+ }
- return (
-
- {({ css }) => (
-
-
+ {({ css }) => (
+
+
-
- {showOwnsBadge && (
-
-
- {showTradeMatchFlair && (
-
- Match
-
- )}
-
- )}
- {showWantsBadge && (
-
-
- {showTradeMatchFlair && (
-
- Match
-
- )}
-
- )}
-
- {item.isNc != null && (
-
-
- {item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
-
-
- )}
-
- )}
-
- );
+ /* Don't let alt text flash in while loading */
+ &:-moz-loading {
+ visibility: hidden;
+ }
+ `}
+ loading="lazy"
+ />
+
+ {showOwnsBadge && (
+
+
+ {showTradeMatchFlair && (
+
+ Match
+
+ )}
+
+ )}
+ {showWantsBadge && (
+
+
+ {showTradeMatchFlair && (
+
+ Match
+
+ )}
+
+ )}
+
+ {item.isNc != null && (
+
+
+ {item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
+
+
+ )}
+
+ )}
+
+ );
}
function ItemOwnsWantsBadge({ colorScheme, children, label }) {
- const badgeBackground = useColorModeValue(
- `${colorScheme}.100`,
- `${colorScheme}.500`,
- );
- const badgeColor = useColorModeValue(
- `${colorScheme}.500`,
- `${colorScheme}.100`,
- );
+ const badgeBackground = useColorModeValue(
+ `${colorScheme}.100`,
+ `${colorScheme}.500`,
+ );
+ const badgeColor = useColorModeValue(
+ `${colorScheme}.500`,
+ `${colorScheme}.100`,
+ );
- const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
- badgeBackground,
- badgeColor,
- ]);
+ const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
+ badgeBackground,
+ badgeColor,
+ ]);
- return (
-
- {({ css }) => (
-
+ {({ css }) => (
+
*/
- white-space: nowrap;
- vertical-align: middle;
- text-transform: uppercase;
- font-size: 0.65rem;
- font-weight: 700;
- background: ${badgeBackgroundValue};
- color: ${badgeColorValue};
- `}
- >
- {children}
-
- )}
-
- );
+ padding-left: 0.125rem;
+ padding-right: 0.125rem;
+ /* Copied from Chakra
*/
+ white-space: nowrap;
+ vertical-align: middle;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ font-weight: 700;
+ background: ${badgeBackgroundValue};
+ color: ${badgeColorValue};
+ `}
+ >
+ {children}
+
+ )}
+
+ );
}
function ItemThumbnailKindBadge({ colorScheme, children }) {
- const badgeBackground = useColorModeValue(
- `${colorScheme}.100`,
- `${colorScheme}.500`,
- );
- const badgeColor = useColorModeValue(
- `${colorScheme}.500`,
- `${colorScheme}.100`,
- );
+ const badgeBackground = useColorModeValue(
+ `${colorScheme}.100`,
+ `${colorScheme}.500`,
+ );
+ const badgeColor = useColorModeValue(
+ `${colorScheme}.500`,
+ `${colorScheme}.100`,
+ );
- const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
- badgeBackground,
- badgeColor,
- ]);
+ const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
+ badgeBackground,
+ badgeColor,
+ ]);
- return (
-
- {({ css }) => (
- */
- white-space: nowrap;
- vertical-align: middle;
- padding-left: 0.25rem;
- padding-right: 0.25rem;
- text-transform: uppercase;
- font-size: 0.65rem;
- border-radius: 0.125rem;
- font-weight: 700;
- background: ${badgeBackgroundValue};
- color: ${badgeColorValue};
- `}
- >
- {children}
-
- )}
-
- );
+ return (
+
+ {({ css }) => (
+ */
+ white-space: nowrap;
+ vertical-align: middle;
+ padding-left: 0.25rem;
+ padding-right: 0.25rem;
+ text-transform: uppercase;
+ font-size: 0.65rem;
+ border-radius: 0.125rem;
+ font-weight: 700;
+ background: ${badgeBackgroundValue};
+ color: ${badgeColorValue};
+ `}
+ >
+ {children}
+
+ )}
+
+ );
}
function SquareItemCardRemoveButton({ onClick }) {
- const backgroundColor = useColorModeValue("gray.200", "gray.500");
+ const backgroundColor = useColorModeValue("gray.200", "gray.500");
- return (
- }
- size="xs"
- borderRadius="full"
- boxShadow="lg"
- backgroundColor={backgroundColor}
- onClick={onClick}
- _hover={{
- // Override night mode's fade-out on hover
- opacity: 1,
- transform: "scale(1.15, 1.15)",
- }}
- _focus={{
- transform: "scale(1.15, 1.15)",
- boxShadow: "outline",
- }}
- />
- );
+ return (
+ }
+ size="xs"
+ borderRadius="full"
+ boxShadow="lg"
+ backgroundColor={backgroundColor}
+ onClick={onClick}
+ _hover={{
+ // Override night mode's fade-out on hover
+ opacity: 1,
+ transform: "scale(1.15, 1.15)",
+ }}
+ _focus={{
+ transform: "scale(1.15, 1.15)",
+ boxShadow: "outline",
+ }}
+ />
+ );
}
export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) {
- return (
-
-
- {minHeightNumLines >= 3 && (
-
- )}
- >
- }
- thumbnailImage={}
- minHeightNumLines={minHeightNumLines}
- footer={footer}
- />
- );
+ return (
+
+
+ {minHeightNumLines >= 3 && (
+
+ )}
+ >
+ }
+ thumbnailImage={}
+ minHeightNumLines={minHeightNumLines}
+ footer={footer}
+ />
+ );
}
export default SquareItemCard;
diff --git a/app/javascript/wardrobe-2020/components/getVisibleLayers.js b/app/javascript/wardrobe-2020/components/getVisibleLayers.js
index ea7e732a..4c9e4b54 100644
--- a/app/javascript/wardrobe-2020/components/getVisibleLayers.js
+++ b/app/javascript/wardrobe-2020/components/getVisibleLayers.js
@@ -1,131 +1,131 @@
import gql from "graphql-tag";
function getVisibleLayers(petAppearance, itemAppearances) {
- if (!petAppearance) {
- return [];
- }
+ if (!petAppearance) {
+ return [];
+ }
- const validItemAppearances = itemAppearances.filter((a) => a);
+ const validItemAppearances = itemAppearances.filter((a) => a);
- const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
+ const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
- const itemLayers = validItemAppearances
- .map((a) => a.layers)
- .flat()
- .map((l) => ({ ...l, source: "item" }));
+ const itemLayers = validItemAppearances
+ .map((a) => a.layers)
+ .flat()
+ .map((l) => ({ ...l, source: "item" }));
- let allLayers = [...petLayers, ...itemLayers];
+ let allLayers = [...petLayers, ...itemLayers];
- const itemRestrictedZoneIds = new Set(
- validItemAppearances
- .map((a) => a.restrictedZones)
- .flat()
- .map((z) => z.id),
- );
- const petRestrictedZoneIds = new Set(
- petAppearance.restrictedZones.map((z) => z.id),
- );
+ const itemRestrictedZoneIds = new Set(
+ validItemAppearances
+ .map((a) => a.restrictedZones)
+ .flat()
+ .map((z) => z.id),
+ );
+ const petRestrictedZoneIds = new Set(
+ petAppearance.restrictedZones.map((z) => z.id),
+ );
- const visibleLayers = allLayers.filter((layer) => {
- // When an item restricts a zone, it hides pet layers of the same zone.
- // We use this to e.g. make a hat hide a hair ruff.
- //
- // NOTE: Items' restricted layers also affect what items you can wear at
- // the same time. We don't enforce anything about that here, and
- // instead assume that the input by this point is valid!
- if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
- return false;
- }
+ const visibleLayers = allLayers.filter((layer) => {
+ // When an item restricts a zone, it hides pet layers of the same zone.
+ // We use this to e.g. make a hat hide a hair ruff.
+ //
+ // NOTE: Items' restricted layers also affect what items you can wear at
+ // the same time. We don't enforce anything about that here, and
+ // instead assume that the input by this point is valid!
+ if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
+ return false;
+ }
- // When a pet appearance restricts a zone, or when the pet is Unconverted,
- // it makes body-specific items incompatible. We use this to disallow UCs
- // from wearing certain body-specific Biology Effects, Statics, etc, while
- // still allowing non-body-specific items in those zones! (I think this
- // happens for some Invisible pet stuff, too?)
- //
- // TODO: We shouldn't be *hiding* these zones, like we do with items; we
- // should be doing this way earlier, to prevent the item from even
- // showing up even in search results!
- //
- // NOTE: This can result in both pet layers and items occupying the same
- // zone, like Static, so long as the item isn't body-specific! That's
- // correct, and the item layer should be on top! (Here, we implement
- // it by placing item layers second in the list, and rely on JS sort
- // stability, and *then* rely on the UI to respect that ordering when
- // rendering them by depth. Not great! 😅)
- //
- // NOTE: We used to also include the pet appearance's *occupied* zones in
- // this condition, not just the restricted zones, as a sensible
- // defensive default, even though we weren't aware of any relevant
- // items. But now we know that actually the "Bruce Brucey B Mouth"
- // occupies the real Mouth zone, and still should be visible and
- // above pet layers! So, we now only check *restricted* zones.
- //
- // NOTE: UCs used to implement their restrictions by listing specific
- // zones, but it seems that the logic has changed to just be about
- // UC-ness and body-specific-ness, and not necessarily involve the
- // set of restricted zones at all. (This matters because e.g. UCs
- // shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
- // don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
- // zone restriction case running too, because I don't think it
- // _hurts_ anything, and I'm not confident enough in this conclusion.
- //
- // TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
- // use zone restrictions?
- if (
- layer.source === "item" &&
- layer.bodyId !== "0" &&
- (petAppearance.pose === "UNCONVERTED" ||
- petRestrictedZoneIds.has(layer.zone.id))
- ) {
- return false;
- }
+ // When a pet appearance restricts a zone, or when the pet is Unconverted,
+ // it makes body-specific items incompatible. We use this to disallow UCs
+ // from wearing certain body-specific Biology Effects, Statics, etc, while
+ // still allowing non-body-specific items in those zones! (I think this
+ // happens for some Invisible pet stuff, too?)
+ //
+ // TODO: We shouldn't be *hiding* these zones, like we do with items; we
+ // should be doing this way earlier, to prevent the item from even
+ // showing up even in search results!
+ //
+ // NOTE: This can result in both pet layers and items occupying the same
+ // zone, like Static, so long as the item isn't body-specific! That's
+ // correct, and the item layer should be on top! (Here, we implement
+ // it by placing item layers second in the list, and rely on JS sort
+ // stability, and *then* rely on the UI to respect that ordering when
+ // rendering them by depth. Not great! 😅)
+ //
+ // NOTE: We used to also include the pet appearance's *occupied* zones in
+ // this condition, not just the restricted zones, as a sensible
+ // defensive default, even though we weren't aware of any relevant
+ // items. But now we know that actually the "Bruce Brucey B Mouth"
+ // occupies the real Mouth zone, and still should be visible and
+ // above pet layers! So, we now only check *restricted* zones.
+ //
+ // NOTE: UCs used to implement their restrictions by listing specific
+ // zones, but it seems that the logic has changed to just be about
+ // UC-ness and body-specific-ness, and not necessarily involve the
+ // set of restricted zones at all. (This matters because e.g. UCs
+ // shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
+ // don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
+ // zone restriction case running too, because I don't think it
+ // _hurts_ anything, and I'm not confident enough in this conclusion.
+ //
+ // TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
+ // use zone restrictions?
+ if (
+ layer.source === "item" &&
+ layer.bodyId !== "0" &&
+ (petAppearance.pose === "UNCONVERTED" ||
+ petRestrictedZoneIds.has(layer.zone.id))
+ ) {
+ return false;
+ }
- // A pet appearance can also restrict its own zones. The Wraith Uni is an
- // interesting example: it has a horn, but its zone restrictions hide it!
- if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
- return false;
- }
+ // A pet appearance can also restrict its own zones. The Wraith Uni is an
+ // interesting example: it has a horn, but its zone restrictions hide it!
+ if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
+ return false;
+ }
- return true;
- });
- visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
+ return true;
+ });
+ visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
- return visibleLayers;
+ return visibleLayers;
}
export const itemAppearanceFragmentForGetVisibleLayers = gql`
- fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
- id
- layers {
- id
- bodyId
- zone {
- id
- depth
- }
- }
- restrictedZones {
- id
- }
- }
+ fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
+ id
+ layers {
+ id
+ bodyId
+ zone {
+ id
+ depth
+ }
+ }
+ restrictedZones {
+ id
+ }
+ }
`;
export const petAppearanceFragmentForGetVisibleLayers = gql`
- fragment PetAppearanceForGetVisibleLayers on PetAppearance {
- id
- pose
- layers {
- id
- zone {
- id
- depth
- }
- }
- restrictedZones {
- id
- }
- }
+ fragment PetAppearanceForGetVisibleLayers on PetAppearance {
+ id
+ pose
+ layers {
+ id
+ zone {
+ id
+ depth
+ }
+ }
+ restrictedZones {
+ id
+ }
+ }
`;
export default getVisibleLayers;
diff --git a/app/javascript/wardrobe-2020/components/useCurrentUser.js b/app/javascript/wardrobe-2020/components/useCurrentUser.js
index 1e168c4d..f9658abf 100644
--- a/app/javascript/wardrobe-2020/components/useCurrentUser.js
+++ b/app/javascript/wardrobe-2020/components/useCurrentUser.js
@@ -2,34 +2,34 @@
const currentUserId = readCurrentUserId();
function useCurrentUser() {
- if (currentUserId == null) {
- return {
- isLoggedIn: false,
- id: null,
- };
- }
+ if (currentUserId == null) {
+ return {
+ isLoggedIn: false,
+ id: null,
+ };
+ }
- return {
- isLoggedIn: true,
- id: currentUserId,
- };
+ return {
+ isLoggedIn: true,
+ id: currentUserId,
+ };
}
function readCurrentUserId() {
- try {
- const element = document.querySelector("meta[name=dti-current-user-id]");
- const value = element.getAttribute("content");
- if (value === "null") {
- return null;
- }
- return value;
- } catch (error) {
- console.error(
- `[readCurrentUserId] Couldn't read user ID, using null instead`,
- error,
- );
- return null;
- }
+ try {
+ const element = document.querySelector("meta[name=dti-current-user-id]");
+ const value = element.getAttribute("content");
+ if (value === "null") {
+ return null;
+ }
+ return value;
+ } catch (error) {
+ console.error(
+ `[readCurrentUserId] Couldn't read user ID, using null instead`,
+ error,
+ );
+ return null;
+ }
}
export default useCurrentUser;
diff --git a/app/javascript/wardrobe-2020/components/useOutfitAppearance.js b/app/javascript/wardrobe-2020/components/useOutfitAppearance.js
index e0025121..396c8600 100644
--- a/app/javascript/wardrobe-2020/components/useOutfitAppearance.js
+++ b/app/javascript/wardrobe-2020/components/useOutfitAppearance.js
@@ -3,8 +3,8 @@ import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import getVisibleLayers, {
- itemAppearanceFragmentForGetVisibleLayers,
- petAppearanceFragmentForGetVisibleLayers,
+ itemAppearanceFragmentForGetVisibleLayers,
+ petAppearanceFragmentForGetVisibleLayers,
} from "./getVisibleLayers";
import { useAltStyle } from "../loaders/alt-styles";
@@ -13,198 +13,198 @@ import { useAltStyle } from "../loaders/alt-styles";
* visibleLayers for rendering.
*/
export default function useOutfitAppearance(outfitState) {
- const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
- outfitState;
+ const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
+ outfitState;
- // We split this query out from the other one, so that we can HTTP cache it.
- //
- // While Apollo gives us fine-grained caching during the page session, we can
- // only HTTP a full query at a time.
- //
- // This is a minor optimization with respect to keeping the user's cache
- // populated with their favorite species/color combinations. Once we start
- // caching the items by body instead of species/color, this could make color
- // changes really snappy!
- //
- // The larger optimization is that this enables the CDN to edge-cache the
- // most popular species/color combinations, for very fast previews on the
- // HomePage. At time of writing, Vercel isn't actually edge-caching these, I
- // assume because our traffic isn't enough - so let's keep an eye on this!
- const {
- loading: loading1,
- error: error1,
- data: data1,
- } = useQuery(
- appearanceId == null
- ? gql`
- query OutfitPetAppearance(
- $speciesId: ID!
- $colorId: ID!
- $pose: Pose!
- ) {
- petAppearance(
- speciesId: $speciesId
- colorId: $colorId
- pose: $pose
- ) {
- ...PetAppearanceForOutfitPreview
- }
- }
- ${petAppearanceFragment}
- `
- : gql`
- query OutfitPetAppearanceById($appearanceId: ID!) {
- petAppearance: petAppearanceById(id: $appearanceId) {
- ...PetAppearanceForOutfitPreview
- }
- }
- ${petAppearanceFragment}
- `,
- {
- variables: {
- speciesId,
- colorId,
- pose,
- appearanceId,
- },
- skip:
- speciesId == null ||
- colorId == null ||
- (pose == null && appearanceId == null),
- },
- );
+ // We split this query out from the other one, so that we can HTTP cache it.
+ //
+ // While Apollo gives us fine-grained caching during the page session, we can
+ // only HTTP a full query at a time.
+ //
+ // This is a minor optimization with respect to keeping the user's cache
+ // populated with their favorite species/color combinations. Once we start
+ // caching the items by body instead of species/color, this could make color
+ // changes really snappy!
+ //
+ // The larger optimization is that this enables the CDN to edge-cache the
+ // most popular species/color combinations, for very fast previews on the
+ // HomePage. At time of writing, Vercel isn't actually edge-caching these, I
+ // assume because our traffic isn't enough - so let's keep an eye on this!
+ const {
+ loading: loading1,
+ error: error1,
+ data: data1,
+ } = useQuery(
+ appearanceId == null
+ ? gql`
+ query OutfitPetAppearance(
+ $speciesId: ID!
+ $colorId: ID!
+ $pose: Pose!
+ ) {
+ petAppearance(
+ speciesId: $speciesId
+ colorId: $colorId
+ pose: $pose
+ ) {
+ ...PetAppearanceForOutfitPreview
+ }
+ }
+ ${petAppearanceFragment}
+ `
+ : gql`
+ query OutfitPetAppearanceById($appearanceId: ID!) {
+ petAppearance: petAppearanceById(id: $appearanceId) {
+ ...PetAppearanceForOutfitPreview
+ }
+ }
+ ${petAppearanceFragment}
+ `,
+ {
+ variables: {
+ speciesId,
+ colorId,
+ pose,
+ appearanceId,
+ },
+ skip:
+ speciesId == null ||
+ colorId == null ||
+ (pose == null && appearanceId == null),
+ },
+ );
- const {
- loading: loading2,
- error: error2,
- data: data2,
- } = useQuery(
- gql`
- query OutfitItemsAppearance(
- $speciesId: ID!
- $colorId: ID!
- $altStyleId: ID
- $wornItemIds: [ID!]!
- ) {
- items(ids: $wornItemIds) {
- id
- name # HACK: This is for HTML5 detection UI in OutfitControls!
- appearance: appearanceOn(
- speciesId: $speciesId
- colorId: $colorId
- altStyleId: $altStyleId
- ) {
- ...ItemAppearanceForOutfitPreview
- }
- }
- }
- ${itemAppearanceFragment}
- `,
- {
- variables: {
- speciesId,
- colorId,
- altStyleId,
- wornItemIds,
- },
- skip: speciesId == null || colorId == null || wornItemIds.length === 0,
- },
- );
+ const {
+ loading: loading2,
+ error: error2,
+ data: data2,
+ } = useQuery(
+ gql`
+ query OutfitItemsAppearance(
+ $speciesId: ID!
+ $colorId: ID!
+ $altStyleId: ID
+ $wornItemIds: [ID!]!
+ ) {
+ items(ids: $wornItemIds) {
+ id
+ name # HACK: This is for HTML5 detection UI in OutfitControls!
+ appearance: appearanceOn(
+ speciesId: $speciesId
+ colorId: $colorId
+ altStyleId: $altStyleId
+ ) {
+ ...ItemAppearanceForOutfitPreview
+ }
+ }
+ }
+ ${itemAppearanceFragment}
+ `,
+ {
+ variables: {
+ speciesId,
+ colorId,
+ altStyleId,
+ wornItemIds,
+ },
+ skip: speciesId == null || colorId == null || wornItemIds.length === 0,
+ },
+ );
- const {
- isLoading: loading3,
- error: error3,
- data: altStyle,
- } = useAltStyle(altStyleId, speciesId);
+ const {
+ isLoading: loading3,
+ error: error3,
+ data: altStyle,
+ } = useAltStyle(altStyleId, speciesId);
- const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
- const items = data2?.items;
- const itemAppearances = React.useMemo(
- () => (items || []).map((i) => i.appearance),
- [items],
- );
- const visibleLayers = React.useMemo(
- () => getVisibleLayers(petAppearance, itemAppearances),
- [petAppearance, itemAppearances],
- );
+ const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
+ const items = data2?.items;
+ const itemAppearances = React.useMemo(
+ () => (items || []).map((i) => i.appearance),
+ [items],
+ );
+ const visibleLayers = React.useMemo(
+ () => getVisibleLayers(petAppearance, itemAppearances),
+ [petAppearance, itemAppearances],
+ );
- const bodyId = petAppearance?.bodyId;
+ const bodyId = petAppearance?.bodyId;
- return {
- loading: loading1 || loading2 || loading3,
- error: error1 || error2 || error3,
- petAppearance,
- items: items || [],
- itemAppearances,
- visibleLayers,
- bodyId,
- };
+ return {
+ loading: loading1 || loading2 || loading3,
+ error: error1 || error2 || error3,
+ petAppearance,
+ items: items || [],
+ itemAppearances,
+ visibleLayers,
+ bodyId,
+ };
}
export const appearanceLayerFragment = gql`
- fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
- id
- svgUrl
- canvasMovieLibraryUrl
- imageUrl: imageUrlV2(idealSize: SIZE_600)
- bodyId
- knownGlitches # For HTML5 & Known Glitches UI
- zone {
- id
- depth
- label
- }
- swfUrl # For the layer info modal
- }
+ fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
+ id
+ svgUrl
+ canvasMovieLibraryUrl
+ imageUrl: imageUrlV2(idealSize: SIZE_600)
+ bodyId
+ knownGlitches # For HTML5 & Known Glitches UI
+ zone {
+ id
+ depth
+ label
+ }
+ swfUrl # For the layer info modal
+ }
`;
export const appearanceLayerFragmentForSupport = gql`
- fragment AppearanceLayerForSupport on AppearanceLayer {
- id
- remoteId # HACK: This is for Support tools, but other views don't need it
- swfUrl # HACK: This is for Support tools, but other views don't need it
- zone {
- id
- label # HACK: This is for Support tools, but other views don't need it
- }
- }
+ fragment AppearanceLayerForSupport on AppearanceLayer {
+ id
+ remoteId # HACK: This is for Support tools, but other views don't need it
+ swfUrl # HACK: This is for Support tools, but other views don't need it
+ zone {
+ id
+ label # HACK: This is for Support tools, but other views don't need it
+ }
+ }
`;
export const itemAppearanceFragment = gql`
- fragment ItemAppearanceForOutfitPreview on ItemAppearance {
- id
- layers {
- id
- ...AppearanceLayerForOutfitPreview
- ...AppearanceLayerForSupport # HACK: Most users don't need this!
- }
- ...ItemAppearanceForGetVisibleLayers
- }
+ fragment ItemAppearanceForOutfitPreview on ItemAppearance {
+ id
+ layers {
+ id
+ ...AppearanceLayerForOutfitPreview
+ ...AppearanceLayerForSupport # HACK: Most users don't need this!
+ }
+ ...ItemAppearanceForGetVisibleLayers
+ }
- ${appearanceLayerFragment}
- ${appearanceLayerFragmentForSupport}
- ${itemAppearanceFragmentForGetVisibleLayers}
+ ${appearanceLayerFragment}
+ ${appearanceLayerFragmentForSupport}
+ ${itemAppearanceFragmentForGetVisibleLayers}
`;
export const petAppearanceFragment = gql`
- fragment PetAppearanceForOutfitPreview on PetAppearance {
- id
- bodyId
- pose # For Known Glitches UI
- isGlitched # For Known Glitches UI
- species {
- id # For Known Glitches UI
- }
- color {
- id # For Known Glitches UI
- }
- layers {
- id
- ...AppearanceLayerForOutfitPreview
- }
- ...PetAppearanceForGetVisibleLayers
- }
+ fragment PetAppearanceForOutfitPreview on PetAppearance {
+ id
+ bodyId
+ pose # For Known Glitches UI
+ isGlitched # For Known Glitches UI
+ species {
+ id # For Known Glitches UI
+ }
+ color {
+ id # For Known Glitches UI
+ }
+ layers {
+ id
+ ...AppearanceLayerForOutfitPreview
+ }
+ ...PetAppearanceForGetVisibleLayers
+ }
- ${appearanceLayerFragment}
- ${petAppearanceFragmentForGetVisibleLayers}
+ ${appearanceLayerFragment}
+ ${petAppearanceFragmentForGetVisibleLayers}
`;
diff --git a/app/javascript/wardrobe-2020/components/usePreferArchive.js b/app/javascript/wardrobe-2020/components/usePreferArchive.js
index 87a76fa9..11488e2e 100644
--- a/app/javascript/wardrobe-2020/components/usePreferArchive.js
+++ b/app/javascript/wardrobe-2020/components/usePreferArchive.js
@@ -5,18 +5,18 @@ import { useLocalStorage } from "../util";
* using images.neopets.com, when images.neopets.com is being slow and bleh!
*/
function usePreferArchive() {
- const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
- "DTIPreferArchive",
- null,
- );
+ const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
+ "DTIPreferArchive",
+ null,
+ );
- // Oct 13 2022: I might default this back to on again if the lag gets
- // miserable again, but it's okaaay right now? ish? Bad enough that I want to
- // offer this option, but decent enough that I don't want to turn it on by
- // default and break new items yet!
- const preferArchive = preferArchiveSavedValue ?? false;
+ // Oct 13 2022: I might default this back to on again if the lag gets
+ // miserable again, but it's okaaay right now? ish? Bad enough that I want to
+ // offer this option, but decent enough that I don't want to turn it on by
+ // default and break new items yet!
+ const preferArchive = preferArchiveSavedValue ?? false;
- return [preferArchive, setPreferArchive];
+ return [preferArchive, setPreferArchive];
}
export default usePreferArchive;
diff --git a/app/javascript/wardrobe-2020/impress-2020-config.js b/app/javascript/wardrobe-2020/impress-2020-config.js
index 6ee75d98..4a5eb369 100644
--- a/app/javascript/wardrobe-2020/impress-2020-config.js
+++ b/app/javascript/wardrobe-2020/impress-2020-config.js
@@ -11,7 +11,7 @@ export function getSupportSecret() {
function readOrigin() {
const node = document.querySelector("meta[name=impress-2020-origin]");
- return node?.content || "https://impress-2020.openneo.net"
+ return node?.content || "https://impress-2020.openneo.net";
}
function readSupportSecret() {
diff --git a/app/javascript/wardrobe-2020/loaders/items.js b/app/javascript/wardrobe-2020/loaders/items.js
index 603f65e0..921b89f9 100644
--- a/app/javascript/wardrobe-2020/loaders/items.js
+++ b/app/javascript/wardrobe-2020/loaders/items.js
@@ -13,9 +13,7 @@ export function useItemAppearances(id, options = {}) {
}
async function loadItemAppearancesData(id) {
- const res = await fetch(
- `/items/${encodeURIComponent(id)}/appearances.json`,
- );
+ const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`);
if (!res.ok) {
throw new Error(
diff --git a/app/javascript/wardrobe-2020/loaders/outfits.js b/app/javascript/wardrobe-2020/loaders/outfits.js
index b0473ebe..60f44cf0 100644
--- a/app/javascript/wardrobe-2020/loaders/outfits.js
+++ b/app/javascript/wardrobe-2020/loaders/outfits.js
@@ -44,9 +44,7 @@ async function loadSavedOutfit(id) {
const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
if (!res.ok) {
- throw new Error(
- `loading outfit failed: ${res.status} ${res.statusText}`,
- );
+ throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
}
return res.json().then(normalizeOutfit);
@@ -99,9 +97,7 @@ async function saveOutfit({
}
if (!res.ok) {
- throw new Error(
- `saving outfit failed: ${res.status} ${res.statusText}`,
- );
+ throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
}
return res.json().then(normalizeOutfit);
@@ -116,9 +112,7 @@ async function deleteOutfit(id) {
});
if (!res.ok) {
- throw new Error(
- `deleting outfit failed: ${res.status} ${res.statusText}`,
- );
+ throw new Error(`deleting outfit failed: ${res.status} ${res.statusText}`);
}
}
@@ -132,9 +126,7 @@ function normalizeOutfit(outfit) {
appearanceId: String(outfit.pet_state_id),
altStyleId: outfit.alt_style_id ? String(outfit.alt_style_id) : null,
wornItemIds: (outfit.item_ids?.worn || []).map((id) => String(id)),
- closetedItemIds: (outfit.item_ids?.closeted || []).map((id) =>
- String(id),
- ),
+ closetedItemIds: (outfit.item_ids?.closeted || []).map((id) => String(id)),
creator: outfit.user ? { id: String(outfit.user.id) } : null,
createdAt: outfit.created_at,
updatedAt: outfit.updated_at,
diff --git a/app/javascript/wardrobe-2020/util.js b/app/javascript/wardrobe-2020/util.js
index 53403cd6..419e3202 100644
--- a/app/javascript/wardrobe-2020/util.js
+++ b/app/javascript/wardrobe-2020/util.js
@@ -1,11 +1,11 @@
import React from "react";
import {
- Box,
- Flex,
- Grid,
- Heading,
- Link,
- useColorModeValue,
+ Box,
+ Flex,
+ Grid,
+ Heading,
+ Link,
+ useColorModeValue,
} from "@chakra-ui/react";
import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react";
@@ -28,18 +28,18 @@ import ErrorGrundoImg2x from "./images/error-grundo@2x.png";
* https://developers.google.com/web/fundamentals/performance/rail
*/
export function Delay({ children, ms = 300 }) {
- const [isVisible, setIsVisible] = React.useState(false);
+ const [isVisible, setIsVisible] = React.useState(false);
- React.useEffect(() => {
- const id = setTimeout(() => setIsVisible(true), ms);
- return () => clearTimeout(id);
- }, [ms, setIsVisible]);
+ React.useEffect(() => {
+ const id = setTimeout(() => setIsVisible(true), ms);
+ return () => clearTimeout(id);
+ }, [ms, setIsVisible]);
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
/**
@@ -47,17 +47,17 @@ export function Delay({ children, ms = 300 }) {
* font and some special typographical styles!
*/
export function Heading1({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
/**
@@ -65,17 +65,17 @@ export function Heading1({ children, ...props }) {
* special typographical styles!!
*/
export function Heading2({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
/**
@@ -83,111 +83,111 @@ export function Heading2({ children, ...props }) {
* special typographical styles!!
*/
export function Heading3({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
/**
* ErrorMessage is a simple error message for simple errors!
*/
export function ErrorMessage({ children, ...props }) {
- return (
-
- {children}
-
- );
+ return (
+
+ {children}
+
+ );
}
export function useCommonStyles() {
- return {
- brightBackground: useColorModeValue("white", "gray.700"),
- bodyBackground: useColorModeValue("gray.50", "gray.800"),
- };
+ return {
+ brightBackground: useColorModeValue("white", "gray.700"),
+ bodyBackground: useColorModeValue("gray.50", "gray.800"),
+ };
}
/**
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
*/
export function safeImageUrl(
- urlString,
- { crossOrigin = null, preferArchive = false } = {},
+ urlString,
+ { crossOrigin = null, preferArchive = false } = {},
) {
- if (urlString == null) {
- return urlString;
- }
+ if (urlString == null) {
+ return urlString;
+ }
- let url;
- try {
- url = new URL(
- urlString,
- // A few item thumbnail images incorrectly start with "/". When that
- // happens, the correct URL is at images.neopets.com.
- //
- // So, we provide "http://images.neopets.com" as the base URL when
- // parsing. Most URLs are absolute and will ignore it, but relative URLs
- // will resolve relative to that base.
- "http://images.neopets.com",
- );
- } catch (e) {
- logAndCapture(
- new Error(
- `safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
- ),
- );
- return buildImpress2020Url("/__error__URL-was-not-parseable__");
- }
+ let url;
+ try {
+ url = new URL(
+ urlString,
+ // A few item thumbnail images incorrectly start with "/". When that
+ // happens, the correct URL is at images.neopets.com.
+ //
+ // So, we provide "http://images.neopets.com" as the base URL when
+ // parsing. Most URLs are absolute and will ignore it, but relative URLs
+ // will resolve relative to that base.
+ "http://images.neopets.com",
+ );
+ } catch (e) {
+ logAndCapture(
+ new Error(
+ `safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
+ ),
+ );
+ return buildImpress2020Url("/__error__URL-was-not-parseable__");
+ }
- // Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
- // proxy if we need CORS headers.
- if (
- url.origin === "http://images.neopets.com" ||
- url.origin === "https://images.neopets.com"
- ) {
- url.protocol = "https:";
- if (preferArchive) {
- const archiveUrl = new URL(
- `/api/readFromArchive`,
- window.location.origin,
- );
- archiveUrl.search = new URLSearchParams({ url: url.toString() });
- url = archiveUrl;
- } else if (crossOrigin) {
- // NOTE: Previously we would rewrite this to our proxy that adds an
- // `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
- // openneo.net), but images.neopets.com now includes this header for us!
- //
- // So, do nothing!
- }
- } else if (
- url.origin === "http://pets.neopets.com" ||
- url.origin === "https://pets.neopets.com"
- ) {
- url.protocol = "https:";
- if (crossOrigin) {
- url.host = "pets.neopets-asset-proxy.openneo.net";
- }
- }
+ // Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
+ // proxy if we need CORS headers.
+ if (
+ url.origin === "http://images.neopets.com" ||
+ url.origin === "https://images.neopets.com"
+ ) {
+ url.protocol = "https:";
+ if (preferArchive) {
+ const archiveUrl = new URL(
+ `/api/readFromArchive`,
+ window.location.origin,
+ );
+ archiveUrl.search = new URLSearchParams({ url: url.toString() });
+ url = archiveUrl;
+ } else if (crossOrigin) {
+ // NOTE: Previously we would rewrite this to our proxy that adds an
+ // `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
+ // openneo.net), but images.neopets.com now includes this header for us!
+ //
+ // So, do nothing!
+ }
+ } else if (
+ url.origin === "http://pets.neopets.com" ||
+ url.origin === "https://pets.neopets.com"
+ ) {
+ url.protocol = "https:";
+ if (crossOrigin) {
+ url.host = "pets.neopets-asset-proxy.openneo.net";
+ }
+ }
- if (url.protocol !== "https:" && url.hostname !== "localhost") {
- logAndCapture(
- new Error(
- `safeImageUrl was provided an unsafe URL, but we don't know how to ` +
- `upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
- ),
- );
- return buildImpress2020Url("/__error__URL-was-not-HTTPS__");
- }
+ if (url.protocol !== "https:" && url.hostname !== "localhost") {
+ logAndCapture(
+ new Error(
+ `safeImageUrl was provided an unsafe URL, but we don't know how to ` +
+ `upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
+ ),
+ );
+ return buildImpress2020Url("/__error__URL-was-not-HTTPS__");
+ }
- return url.toString();
+ return url.toString();
}
/**
@@ -201,43 +201,43 @@ export function safeImageUrl(
* Adapted from https://usehooks.com/useDebounce/
*/
export function useDebounce(
- value,
- delay,
- { waitForFirstPause = false, initialValue = null, forceReset = null } = {},
+ value,
+ delay,
+ { waitForFirstPause = false, initialValue = null, forceReset = null } = {},
) {
- // State and setters for debounced value
- const [debouncedValue, setDebouncedValue] = React.useState(
- waitForFirstPause ? initialValue : value,
- );
+ // State and setters for debounced value
+ const [debouncedValue, setDebouncedValue] = React.useState(
+ waitForFirstPause ? initialValue : value,
+ );
- React.useEffect(
- () => {
- // Update debounced value after delay
- const handler = setTimeout(() => {
- setDebouncedValue(value);
- }, delay);
+ React.useEffect(
+ () => {
+ // Update debounced value after delay
+ const handler = setTimeout(() => {
+ setDebouncedValue(value);
+ }, delay);
- // Cancel the timeout if value changes (also on delay change or unmount)
- // This is how we prevent debounced value from updating if value is changed ...
- // .. within the delay period. Timeout gets cleared and restarted.
- return () => {
- clearTimeout(handler);
- };
- },
- [value, delay], // Only re-call effect if value or delay changes
- );
+ // Cancel the timeout if value changes (also on delay change or unmount)
+ // This is how we prevent debounced value from updating if value is changed ...
+ // .. within the delay period. Timeout gets cleared and restarted.
+ return () => {
+ clearTimeout(handler);
+ };
+ },
+ [value, delay], // Only re-call effect if value or delay changes
+ );
- // The `forceReset` option helps us decide whether to set the value
- // immediately! We'll update it in an effect for consistency and clarity, but
- // also return it immediately rather than wait a tick.
- const shouldForceReset = forceReset && forceReset(debouncedValue, value);
- React.useEffect(() => {
- if (shouldForceReset) {
- setDebouncedValue(value);
- }
- }, [shouldForceReset, value]);
+ // The `forceReset` option helps us decide whether to set the value
+ // immediately! We'll update it in an effect for consistency and clarity, but
+ // also return it immediately rather than wait a tick.
+ const shouldForceReset = forceReset && forceReset(debouncedValue, value);
+ React.useEffect(() => {
+ if (shouldForceReset) {
+ setDebouncedValue(value);
+ }
+ }, [shouldForceReset, value]);
- return shouldForceReset ? value : debouncedValue;
+ return shouldForceReset ? value : debouncedValue;
}
/**
@@ -246,53 +246,53 @@ export function useDebounce(
* Our limited API is designed to match the `use-http` library!
*/
export function useFetch(url, { responseType, skip, ...fetchOptions }) {
- // Just trying to be clear about what you'll get back ^_^` If we want to
- // fetch non-binary data later, extend this and get something else from res!
- if (responseType !== "arrayBuffer") {
- throw new Error(`unsupported responseType ${responseType}`);
- }
+ // Just trying to be clear about what you'll get back ^_^` If we want to
+ // fetch non-binary data later, extend this and get something else from res!
+ if (responseType !== "arrayBuffer") {
+ throw new Error(`unsupported responseType ${responseType}`);
+ }
- const [response, setResponse] = React.useState({
- loading: skip ? false : true,
- error: null,
- data: null,
- });
+ const [response, setResponse] = React.useState({
+ loading: skip ? false : true,
+ error: null,
+ data: null,
+ });
- // We expect this to be a simple object, so this helps us only re-send the
- // fetch when the options have actually changed, rather than e.g. a new copy
- // of an identical object!
- const fetchOptionsAsJson = JSON.stringify(fetchOptions);
+ // We expect this to be a simple object, so this helps us only re-send the
+ // fetch when the options have actually changed, rather than e.g. a new copy
+ // of an identical object!
+ const fetchOptionsAsJson = JSON.stringify(fetchOptions);
- React.useEffect(() => {
- if (skip) {
- return;
- }
+ React.useEffect(() => {
+ if (skip) {
+ return;
+ }
- let canceled = false;
+ let canceled = false;
- fetch(url, JSON.parse(fetchOptionsAsJson))
- .then(async (res) => {
- if (canceled) {
- return;
- }
+ fetch(url, JSON.parse(fetchOptionsAsJson))
+ .then(async (res) => {
+ if (canceled) {
+ return;
+ }
- const arrayBuffer = await res.arrayBuffer();
- setResponse({ loading: false, error: null, data: arrayBuffer });
- })
- .catch((error) => {
- if (canceled) {
- return;
- }
+ const arrayBuffer = await res.arrayBuffer();
+ setResponse({ loading: false, error: null, data: arrayBuffer });
+ })
+ .catch((error) => {
+ if (canceled) {
+ return;
+ }
- setResponse({ loading: false, error, data: null });
- });
+ setResponse({ loading: false, error, data: null });
+ });
- return () => {
- canceled = true;
- };
- }, [skip, url, fetchOptionsAsJson]);
+ return () => {
+ canceled = true;
+ };
+ }, [skip, url, fetchOptionsAsJson]);
- return response;
+ return response;
}
/**
@@ -303,96 +303,96 @@ export function useFetch(url, { responseType, skip, ...fetchOptions }) {
*/
let storageListeners = [];
export function useLocalStorage(key, initialValue) {
- const loadValue = React.useCallback(() => {
- if (typeof localStorage === "undefined") {
- return initialValue;
- }
- try {
- const item = localStorage.getItem(key);
- return item ? JSON.parse(item) : initialValue;
- } catch (error) {
- console.error(error);
- return initialValue;
- }
- }, [key, initialValue]);
+ const loadValue = React.useCallback(() => {
+ if (typeof localStorage === "undefined") {
+ return initialValue;
+ }
+ try {
+ const item = localStorage.getItem(key);
+ return item ? JSON.parse(item) : initialValue;
+ } catch (error) {
+ console.error(error);
+ return initialValue;
+ }
+ }, [key, initialValue]);
- const [storedValue, setStoredValue] = React.useState(loadValue);
+ const [storedValue, setStoredValue] = React.useState(loadValue);
- const setValue = React.useCallback(
- (value) => {
- try {
- setStoredValue(value);
- window.localStorage.setItem(key, JSON.stringify(value));
- storageListeners.forEach((l) => l());
- } catch (error) {
- console.error(error);
- }
- },
- [key],
- );
+ const setValue = React.useCallback(
+ (value) => {
+ try {
+ setStoredValue(value);
+ window.localStorage.setItem(key, JSON.stringify(value));
+ storageListeners.forEach((l) => l());
+ } catch (error) {
+ console.error(error);
+ }
+ },
+ [key],
+ );
- const reloadValue = React.useCallback(() => {
- setStoredValue(loadValue());
- }, [loadValue, setStoredValue]);
+ const reloadValue = React.useCallback(() => {
+ setStoredValue(loadValue());
+ }, [loadValue, setStoredValue]);
- // Listen for changes elsewhere on the page, and update here too!
- React.useEffect(() => {
- storageListeners.push(reloadValue);
- return () => {
- storageListeners = storageListeners.filter((l) => l !== reloadValue);
- };
- }, [reloadValue]);
+ // Listen for changes elsewhere on the page, and update here too!
+ React.useEffect(() => {
+ storageListeners.push(reloadValue);
+ return () => {
+ storageListeners = storageListeners.filter((l) => l !== reloadValue);
+ };
+ }, [reloadValue]);
- // Listen for changes in other tabs, and update here too! (This does not
- // catch same-page updates!)
- React.useEffect(() => {
- window.addEventListener("storage", reloadValue);
- return () => window.removeEventListener("storage", reloadValue);
- }, [reloadValue]);
+ // Listen for changes in other tabs, and update here too! (This does not
+ // catch same-page updates!)
+ React.useEffect(() => {
+ window.addEventListener("storage", reloadValue);
+ return () => window.removeEventListener("storage", reloadValue);
+ }, [reloadValue]);
- return [storedValue, setValue];
+ return [storedValue, setValue];
}
export function loadImage(
- rawSrc,
- { crossOrigin = null, preferArchive = false } = {},
+ rawSrc,
+ { crossOrigin = null, preferArchive = false } = {},
) {
- const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
- const image = new Image();
- let canceled = false;
- let resolved = false;
+ const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
+ const image = new Image();
+ let canceled = false;
+ let resolved = false;
- const promise = new Promise((resolve, reject) => {
- image.onload = () => {
- if (canceled) return;
- resolved = true;
- resolve(image);
- };
- image.onerror = () => {
- if (canceled) return;
- reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
- };
- if (crossOrigin) {
- image.crossOrigin = crossOrigin;
- }
- image.src = src;
- });
+ const promise = new Promise((resolve, reject) => {
+ image.onload = () => {
+ if (canceled) return;
+ resolved = true;
+ resolve(image);
+ };
+ image.onerror = () => {
+ if (canceled) return;
+ reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
+ };
+ if (crossOrigin) {
+ image.crossOrigin = crossOrigin;
+ }
+ image.src = src;
+ });
- promise.cancel = () => {
- // NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
- // resolved images. That's because our approach to cancelation
- // mutates the Image object we already returned, which could be
- // surprising if the caller is using the Image and expected the
- // `cancel` call to only cancel any in-flight network requests.
- // (e.g. we cancel a DTI movie when it unloads from the page, but
- // it might stick around in the movie cache, and we want those images
- // to still work!)
- if (resolved) return;
- image.src = "";
- canceled = true;
- };
+ promise.cancel = () => {
+ // NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
+ // resolved images. That's because our approach to cancelation
+ // mutates the Image object we already returned, which could be
+ // surprising if the caller is using the Image and expected the
+ // `cancel` call to only cancel any in-flight network requests.
+ // (e.g. we cancel a DTI movie when it unloads from the page, but
+ // it might stick around in the movie cache, and we want those images
+ // to still work!)
+ if (resolved) return;
+ image.src = "";
+ canceled = true;
+ };
- return promise;
+ return promise;
}
/**
@@ -401,16 +401,16 @@ export function loadImage(
* because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
*/
export function loadable(load, options) {
- return loadableLibrary(
- () =>
- load().catch((e) => {
- console.error("Error loading page, reloading:", e);
- window.location.reload();
- // Return a component that renders nothing, while we reload!
- return () => null;
- }),
- options,
- );
+ return loadableLibrary(
+ () =>
+ load().catch((e) => {
+ console.error("Error loading page, reloading:", e);
+ window.location.reload();
+ // Return a component that renders nothing, while we reload!
+ return () => null;
+ }),
+ options,
+ );
}
/**
@@ -420,113 +420,113 @@ export function loadable(load, options) {
* genuinely unexpected error worth logging.
*/
export function logAndCapture(e) {
- console.error(e);
- Sentry.captureException(e);
+ console.error(e);
+ Sentry.captureException(e);
}
export function getGraphQLErrorMessage(error) {
- // If this is a GraphQL Bad Request error, show the message of the first
- // error the server returned. Otherwise, just use the normal error message!
- return (
- error?.networkError?.result?.errors?.[0]?.message || error?.message || null
- );
+ // If this is a GraphQL Bad Request error, show the message of the first
+ // error the server returned. Otherwise, just use the normal error message!
+ return (
+ error?.networkError?.result?.errors?.[0]?.message || error?.message || null
+ );
}
export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
- // Log the detailed error to the console, so we can have a good debug
- // experience without the parent worrying about it!
- React.useEffect(() => {
- if (error) {
- console.error(error);
- }
- }, [error]);
+ // Log the detailed error to the console, so we can have a good debug
+ // experience without the parent worrying about it!
+ React.useEffect(() => {
+ if (error) {
+ console.error(error);
+ }
+ }, [error]);
- return (
-
-
-
-
-
-
-
-
- {variant === "unexpected" && <>Ah dang, I broke it 😖>}
- {variant === "network" && <>Oops, it didn't work, sorry 😖>}
- {variant === "not-found" && <>Oops, page not found 😖>}
-
-
- {variant === "unexpected" && (
- <>
- There was an error displaying this page. I'll get info about it
- automatically, but you can tell me more at{" "}
-
- matchu@openneo.net
-
- !
- >
- )}
- {variant === "network" && (
- <>
- There was an error displaying this page. Check your internet
- connection and try again—and if you keep having trouble, please
- tell me more at{" "}
-
- matchu@openneo.net
-
- !
- >
- )}
- {variant === "not-found" && (
- <>
- We couldn't find this page. Maybe it's been deleted? Check the URL
- and try again—and if you keep having trouble, please tell me more
- at{" "}
-
- matchu@openneo.net
-
- !
- >
- )}
-
- {error && (
-
-
- "{getGraphQLErrorMessage(error)}"
-
- )}
-
-
- );
+ return (
+
+
+
+
+
+
+
+
+ {variant === "unexpected" && <>Ah dang, I broke it 😖>}
+ {variant === "network" && <>Oops, it didn't work, sorry 😖>}
+ {variant === "not-found" && <>Oops, page not found 😖>}
+
+
+ {variant === "unexpected" && (
+ <>
+ There was an error displaying this page. I'll get info about it
+ automatically, but you can tell me more at{" "}
+
+ matchu@openneo.net
+
+ !
+ >
+ )}
+ {variant === "network" && (
+ <>
+ There was an error displaying this page. Check your internet
+ connection and try again—and if you keep having trouble, please
+ tell me more at{" "}
+
+ matchu@openneo.net
+
+ !
+ >
+ )}
+ {variant === "not-found" && (
+ <>
+ We couldn't find this page. Maybe it's been deleted? Check the URL
+ and try again—and if you keep having trouble, please tell me more
+ at{" "}
+
+ matchu@openneo.net
+
+ !
+ >
+ )}
+
+ {error && (
+
+
+ "{getGraphQLErrorMessage(error)}"
+
+ )}
+
+
+ );
}
export function TestErrorSender() {
- React.useEffect(() => {
- if (window.location.href.includes("send-test-error-for-sentry")) {
- throw new Error("Test error for Sentry");
- }
- });
+ React.useEffect(() => {
+ if (window.location.href.includes("send-test-error-for-sentry")) {
+ throw new Error("Test error for Sentry");
+ }
+ });
- return null;
+ return null;
}
diff --git a/package.json b/package.json
index 9969aeef..d4615880 100644
--- a/package.json
+++ b/package.json
@@ -1,53 +1,57 @@
{
- "name": "impress",
- "private": true,
- "dependencies": {
- "@apollo/client": "^3.6.9",
- "@chakra-ui/icons": "^1.0.4",
- "@chakra-ui/react": "^1.6.0",
- "@emotion/react": "^11.1.4",
- "@emotion/styled": "^11.0.0",
- "@hotwired/turbo-rails": "^8.0.4",
- "@loadable/component": "^5.12.0",
- "@sentry/react": "^5.30.0",
- "@sentry/tracing": "^5.30.0",
- "@tanstack/react-query": "^5.4.3",
- "apollo-link-persisted-queries": "^0.2.2",
- "easeljs": "^1.0.2",
- "esbuild": "^0.19.0",
- "framer-motion": "^4.1.11",
- "graphql": "^15.5.0",
- "graphql-tag": "^2.12.6",
- "immer": "^9.0.6",
- "lru-cache": "^6.0.0",
- "react": "^18.2.0",
- "react-autosuggest": "^10.0.2",
- "react-dom": "^18.2.0",
- "react-icons": "^4.2.0",
- "react-router-dom": "^6.15.0",
- "react-transition-group": "^4.3.0",
- "tweenjs": "^1.0.2"
- },
- "devDependencies": {
- "@typescript-eslint/eslint-plugin": "^7.8.0",
- "@typescript-eslint/parser": "^7.8.0",
- "eslint": "^8.52.0",
- "eslint-plugin-jsx-a11y": "^6.8.0",
- "eslint-plugin-react": "^7.33.2",
- "eslint-plugin-react-hooks": "^4.6.0",
- "express": "^4.18.3",
- "husky": "^8.0.3",
- "node-fetch": "^3.3.2",
- "oauth2-mock-server": "^7.1.1",
- "prettier": "^3.0.3",
- "typescript": "^5.2.2"
- },
- "scripts": {
- "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --asset-names='[name]-[hash].digested' --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text",
- "build:dev": "yarn build --public-path=/dev-assets",
- "dev": "yarn build:dev --watch",
- "lint": "eslint app/javascript",
- "prepare": "husky install"
- },
- "packageManager": "yarn@4.4.1"
+ "name": "impress",
+ "private": true,
+ "dependencies": {
+ "@apollo/client": "^3.6.9",
+ "@chakra-ui/icons": "^1.0.4",
+ "@chakra-ui/react": "^1.6.0",
+ "@emotion/react": "^11.1.4",
+ "@emotion/styled": "^11.0.0",
+ "@hotwired/turbo-rails": "^8.0.4",
+ "@loadable/component": "^5.12.0",
+ "@sentry/react": "^5.30.0",
+ "@sentry/tracing": "^5.30.0",
+ "@tanstack/react-query": "^5.4.3",
+ "apollo-link-persisted-queries": "^0.2.2",
+ "easeljs": "^1.0.2",
+ "esbuild": "^0.19.0",
+ "framer-motion": "^4.1.11",
+ "graphql": "^15.5.0",
+ "graphql-tag": "^2.12.6",
+ "immer": "^9.0.6",
+ "lru-cache": "^6.0.0",
+ "react": "^18.2.0",
+ "react-autosuggest": "^10.0.2",
+ "react-dom": "^18.2.0",
+ "react-icons": "^4.2.0",
+ "react-router-dom": "^6.15.0",
+ "react-transition-group": "^4.3.0",
+ "tweenjs": "^1.0.2"
+ },
+ "devDependencies": {
+ "@typescript-eslint/eslint-plugin": "^7.8.0",
+ "@typescript-eslint/parser": "^7.8.0",
+ "eslint": "^8.52.0",
+ "eslint-plugin-jsx-a11y": "^6.8.0",
+ "eslint-plugin-react": "^7.33.2",
+ "eslint-plugin-react-hooks": "^4.6.0",
+ "express": "^4.18.3",
+ "husky": "^8.0.3",
+ "node-fetch": "^3.3.2",
+ "oauth2-mock-server": "^7.1.1",
+ "prettier": "^3.0.3",
+ "typescript": "^5.2.2"
+ },
+ "scripts": {
+ "build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --asset-names='[name]-[hash].digested' --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text",
+ "build:dev": "yarn build --public-path=/dev-assets",
+ "dev": "yarn build:dev --watch",
+ "format": "prettier -w app/javascript app/assets/javascripts",
+ "lint": "eslint app/javascript",
+ "prepare": "husky install"
+ },
+ "prettier": {
+ "useTabs": true
+ },
+ "packageManager": "yarn@4.4.1"
}