Set Prettier default to tabs instead of spaces, run on all JS

I haven't been running Prettier consistently on things in this project.
Now, it's quick-runnable, and I've got it on everything!

Also, I just think tabs are the right default for this kind of thing,
and I'm glad to get to switch over to it! (In `package.json`.)
This commit is contained in:
Emi Matchu 2024-09-09 16:10:45 -07:00
parent 71ffb7f1be
commit 0e314482f7
57 changed files with 11694 additions and 11709 deletions

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
/app/assets/javascripts/lib

View file

@ -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,
});
})();

File diff suppressed because it is too large Load diff

View file

@ -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);
})();

View file

@ -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);
}
});

View file

@ -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());
});
})();

View file

@ -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 `<measured-container>` 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(
`<measured-content> must be in a <measured-container>`,
);
}
container.style.setProperty("--natural-width", this.offsetWidth + "px");
}
#measure() {
// Find our `<measured-container>` 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(`<measured-content> must be in a <measured-container>`);
}
container.style.setProperty("--natural-width", this.offsetWidth + "px");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);

View file

@ -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(`<outfit-layer> contained no image or iframe: `, this);
}
@ -137,8 +135,7 @@ class OutfitLayer extends HTMLElement {
}
} else {
throw new Error(
`<outfit-layer> got unexpected message: ` +
JSON.stringify(data),
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
);
}
}

View file

@ -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();
})();

View file

@ -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();
});
})();

View file

@ -1,5 +1,5 @@
import "@hotwired/turbo-rails";
document.getElementById("locale").addEventListener("change", function () {
document.getElementById("locale-form").submit();
document.getElementById("locale-form").submit();
});

View file

@ -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(
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
);

View file

@ -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 (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<ApolloProvider client={apolloClient}>
<ChakraProvider resetCSS={false} theme={theme}>
<ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider>
</ApolloProvider>
</QueryClientProvider>
</BrowserRouter>
);
return (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<ApolloProvider client={apolloClient}>
<ChakraProvider resetCSS={false} theme={theme}>
<ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider>
</ApolloProvider>
</QueryClientProvider>
</BrowserRouter>
);
}
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 (
<>
<Box className="chakra-css-reset">{children}</Box>
<Global
styles={css`
/* Chakra's default global styles, placed here so we can override
return (
<>
<Box className="chakra-css-reset">{children}</Box>
<Global
styles={css`
/* Chakra's default global styles, placed here so we can override
* the actual _global_ styles in the theme to be empty. That way,
* it only affects Chakra stuff, not all elements! */
${scopedGlobalStylesCSS}
${scopedGlobalStylesCSS}
/* Chakra's CSS reset, copy-pasted and rescoped! */
/* Chakra's CSS reset, copy-pasted and rescoped! */
:where(.chakra-css-reset, .chakra-portal) {
*,
*::before,
*::after {
border-width: 0;
border-style: solid;
box-sizing: border-box;
}
*,
*::before,
*::after {
border-width: 0;
border-style: solid;
box-sizing: border-box;
}
main {
display: block;
}
main {
display: block;
}
hr {
border-top-width: 1px;
box-sizing: content-box;
height: 0;
overflow: visible;
}
hr {
border-top-width: 1px;
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em;
}
a {
background-color: transparent;
color: inherit;
text-decoration: inherit;
}
a {
background-color: transparent;
color: inherit;
text-decoration: inherit;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bold;
}
b,
strong {
font-weight: bold;
}
small {
font-size: 80%;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
select {
text-transform: none;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"] {
-moz-appearance: textfield;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none !important;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none !important;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
details {
display: block;
}
summary {
display: list-item;
}
summary {
display: list-item;
}
template {
display: none;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
[hidden] {
display: none !important;
}
body,
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
body,
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
button {
background: transparent;
padding: 0;
}
button {
background: transparent;
padding: 0;
}
fieldset {
margin: 0;
padding: 0;
}
fieldset {
margin: 0;
padding: 0;
}
ol,
ul {
margin: 0;
padding: 0;
}
ol,
ul {
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
textarea {
resize: vertical;
}
button,
[role="button"] {
cursor: pointer;
}
button,
[role="button"] {
cursor: pointer;
}
button::-moz-focus-inner {
border: 0 !important;
}
button::-moz-focus-inner {
border: 0 !important;
}
table {
border-collapse: collapse;
}
table {
border-collapse: collapse;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
button,
input,
optgroup,
select,
textarea {
padding: 0;
line-height: inherit;
color: inherit;
}
button,
input,
optgroup,
select,
textarea {
padding: 0;
line-height: inherit;
color: inherit;
}
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
}
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
}
img,
video {
max-width: 100%;
height: auto;
}
img,
video {
max-width: 100%;
height: auto;
}
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
outline: none;
box-shadow: none;
}
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
outline: none;
box-shadow: none;
}
select::-ms-expand {
display: none;
}
}
`}
/>
</>
);
select::-ms-expand {
display: none;
}
}
`}
/>
</>
);
}

View file

@ -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 (
<>
<ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0">
<ItemCardContent
item={item}
badges={<ItemBadges item={item} />}
itemNameId={itemNameId}
isWorn={isWorn}
isDiabled={isDisabled}
focusSelector={containerHasFocus}
/>
</Box>
<Box flex="0 0 auto" marginTop="5px">
{isInOutfit && (
<ItemActionButton
icon={<DeleteIcon />}
label="Remove"
onClick={(e) => {
onRemove(item.id);
e.preventDefault();
}}
/>
)}
<SupportOnly>
<ItemActionButton
icon={<EditIcon />}
label="Support"
onClick={(e) => {
setSupportDrawerIsOpen(true);
e.preventDefault();
}}
/>
</SupportOnly>
<ItemActionButton
icon={<InfoIcon />}
label="More info"
to={`/items/${item.id}`}
target="_blank"
/>
</Box>
</ItemContainer>
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
</SupportOnly>
</>
);
return (
<>
<ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0">
<ItemCardContent
item={item}
badges={<ItemBadges item={item} />}
itemNameId={itemNameId}
isWorn={isWorn}
isDiabled={isDisabled}
focusSelector={containerHasFocus}
/>
</Box>
<Box flex="0 0 auto" marginTop="5px">
{isInOutfit && (
<ItemActionButton
icon={<DeleteIcon />}
label="Remove"
onClick={(e) => {
onRemove(item.id);
e.preventDefault();
}}
/>
)}
<SupportOnly>
<ItemActionButton
icon={<EditIcon />}
label="Support"
onClick={(e) => {
setSupportDrawerIsOpen(true);
e.preventDefault();
}}
/>
</SupportOnly>
<ItemActionButton
icon={<InfoIcon />}
label="More info"
to={`/items/${item.id}`}
target="_blank"
/>
</Box>
</ItemContainer>
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
</SupportOnly>
</>
);
}
/**
* ItemSkeleton is a placeholder for when an Item is loading.
*/
function ItemSkeleton() {
return (
<ItemContainer isDisabled>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer>
);
return (
<ItemContainer isDisabled>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer>
);
}
/**
@ -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 (
<ClassNames>
{({ css, cx }) => (
<Box
p="1"
my="1"
borderRadius="lg"
d="flex"
cursor={isDisabled ? undefined : "pointer"}
border="1px"
borderColor="transparent"
className={cx([
"item-container",
!isDisabled &&
css`
&:hover,
input:focus + & {
background-color: ${focusBackgroundColor};
}
return (
<ClassNames>
{({ css, cx }) => (
<Box
p="1"
my="1"
borderRadius="lg"
d="flex"
cursor={isDisabled ? undefined : "pointer"}
border="1px"
borderColor="transparent"
className={cx([
"item-container",
!isDisabled &&
css`
&:hover,
input:focus + & {
background-color: ${focusBackgroundColor};
}
input:active + & {
border-color: ${activeBorderColor};
}
input:active + & {
border-color: ${activeBorderColor};
}
input:checked:focus + & {
border-color: ${focusCheckedBorderColor};
}
`,
])}
>
{children}
</Box>
)}
</ClassNames>
);
input:checked:focus + & {
border-color: ${focusCheckedBorderColor};
}
`,
])}
>
{children}
</Box>
)}
</ClassNames>
);
}
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 (
<ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{
// 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 <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
}
{getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList>
);
return (
<ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{
// 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 <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
}
{getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList>
);
}
/**
* 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 (
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<LinkOrButton
{...props}
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
onClick={onClick}
className={css`
opacity: 0;
transition: all 0.2s;
return (
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<LinkOrButton
{...props}
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
onClick={onClick}
className={css`
opacity: 0;
transition: all 0.2s;
${containerHasFocus} {
opacity: 1;
}
${containerHasFocus} {
opacity: 1;
}
&:focus,
&:hover {
opacity: 1;
background-color: ${focusBackgroundColor};
color: ${focusColor};
}
&:focus,
&:hover {
opacity: 1;
background-color: ${focusBackgroundColor};
color: ${focusColor};
}
/* On touch devices, always show the buttons! This avoids having to
/* On touch devices, always show the buttons! This avoids having to
* tap to reveal them (which toggles the item), or worse,
* accidentally tapping a hidden button without realizing! */
@media (hover: none) {
opacity: 1;
}
`}
/>
</Tooltip>
)}
</ClassNames>
);
@media (hover: none) {
opacity: 1;
}
`}
/>
</Tooltip>
)}
</ClassNames>
);
}
function LinkOrButton({ href, component, ...props }) {
const ButtonComponent = component;
if (href != null) {
return <ButtonComponent as="a" href={href} {...props} />;
} else {
return <ButtonComponent {...props} />;
}
const ButtonComponent = component;
if (href != null) {
return <ButtonComponent as="a" href={href} {...props} />;
} else {
return <ButtonComponent {...props} />;
}
}
/**
@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
* components in this to ensure a consistent list layout.
*/
export function ItemListContainer({ children, ...props }) {
return (
<Flex direction="column" {...props}>
{children}
</Flex>
);
return (
<Flex direction="column" {...props}>
{children}
</Flex>
);
}
/**
@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
* Items are loading.
*/
export function ItemListSkeleton({ count, ...props }) {
return (
<ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} />
))}
</ItemListContainer>
);
return (
<ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} />
))}
</ItemListContainer>
);
}
/**
@ -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);

View file

@ -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 (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery}
/>
</Box>
)}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box
key="search-panel"
flex="1 0 0"
position="relative"
overflowY="scroll"
ref={scrollContainerRef}
data-test-id="search-panel-scroll-container"
>
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
) : (
<Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2">
<ItemsPanel
loading={loading}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
</Sentry.ErrorBoundary>
);
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery}
/>
</Box>
)}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box
key="search-panel"
flex="1 0 0"
position="relative"
overflowY="scroll"
ref={scrollContainerRef}
data-test-id="search-panel-scroll-container"
>
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
) : (
<Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2">
<ItemsPanel
loading={loading}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
</Sentry.ErrorBoundary>
);
}
export default ItemsAndSearchPanels;

View file

@ -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 (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<>
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition
key={zoneId}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
</TransitionGroup>
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label={
altStyleId != null
? "Many items don't fit Alt Style pets"
: "These items don't fit this pet"
}
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</>
)}
</Flex>
</Box>
)}
</ClassNames>
);
return (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<>
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition
key={zoneId}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
</TransitionGroup>
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label={
altStyleId != null
? "Many items don't fit Alt Style pets"
: "These items don't fit this pet"
}
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</>
)}
</Flex>
</Box>
)}
</ClassNames>
);
}
/**
@ -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 (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
}
/**
@ -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(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
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(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
}
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
}
/**
@ -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 (
<Tooltip
label="Shopping list"
placement="top"
background="purple.500"
color="white"
>
<IconButton
aria-label="Shopping list"
as={isDisabled ? "button" : "a"}
href={isDisabled ? undefined : targetUrl}
target={isDisabled ? undefined : "_blank"}
icon={<IoBagCheck />}
colorScheme="purple"
size="sm"
isRound
isDisabled={isDisabled}
/>
</Tooltip>
);
return (
<Tooltip
label="Shopping list"
placement="top"
background="purple.500"
color="white"
>
<IconButton
aria-label="Shopping list"
as={isDisabled ? "button" : "a"}
href={isDisabled ? undefined : targetUrl}
target={isDisabled ? undefined : "_blank"}
icon={<IoBagCheck />}
colorScheme="purple"
size="sm"
isRound
isDisabled={isDisabled}
/>
</Tooltip>
);
}
/**
@ -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 (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (isNewOutfit) {
return (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
// 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.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<OutfitSavingIndicator outfitSaving={outfitSaving} />
<Box width="3" flex="0 0 auto" />
<ShoppingListButton outfitState={outfitState} />
<Box width="2" flex="0 0 auto" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
isRound
size="sm"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
return (
// The Editable wraps everything, including the menu, because the menu has
// a Rename option.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<OutfitSavingIndicator outfitSaving={outfitSaving} />
<Box width="3" flex="0 0 auto" />
<ShoppingListButton outfitState={outfitState} />
<Box width="2" flex="0 0 auto" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
isRound
size="sm"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
}
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 (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
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" && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
mutateAsync(id)
.then(() => {
window.location = "/your-outfits";
})
.catch((e) => {
/* handled in error UI */
})
}
// We continue to show the loading spinner in the success case,
// while we redirect away!
isLoading={status === "pending" || status === "success"}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
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" && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
mutateAsync(id)
.then(() => {
window.location = "/your-outfits";
})
.catch((e) => {
/* handled in error UI */
})
}
// We continue to show the loading spinner in the success case,
// while we redirect away!
isLoading={status === "pending" || status === "success"}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
/**
@ -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;

View file

@ -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 (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay>
<ModalContent maxWidth="800px">
<ModalHeader>Outfit layers</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LayerTable layers={visibleLayers} />
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay>
<ModalContent maxWidth="800px">
<ModalHeader>Outfit layers</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LayerTable layers={visibleLayers} />
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function LayerTable({ layers }) {
return (
<Table>
<Thead>
<Tr>
<Th>Preview</Th>
<Th>DTI ID</Th>
<Th>Zone</Th>
<Th>Links</Th>
</Tr>
</Thead>
<Tbody>
{layers.map((layer) => (
<LayerTableRow key={layer.id} layer={layer} />
))}
</Tbody>
</Table>
);
return (
<Table>
<Thead>
<Tr>
<Th>Preview</Th>
<Th>DTI ID</Th>
<Th>Zone</Th>
<Th>Links</Th>
</Tr>
</Thead>
<Tbody>
{layers.map((layer) => (
<LayerTableRow key={layer.id} layer={layer} />
))}
</Tbody>
</Table>
);
}
function LayerTableRow({ layer, ...props }) {
return (
<Tr {...props}>
<Td>
<Box
as="img"
src={layer.imageUrl}
width="60px"
height="60px"
boxShadow="md"
/>
</Td>
<Td>{layer.id}</Td>
<Td>{layer.zone.label}</Td>
<Td>
<Box display="flex" gap=".5em">
{layer.imageUrl && (
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
PNG
</Button>
)}
{layer.swfUrl && (
<Button as="a" href={layer.swfUrl} size="sm" download>
SWF
</Button>
)}
{layer.svgUrl && (
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
SVG
</Button>
)}
</Box>
</Td>
</Tr>
);
return (
<Tr {...props}>
<Td>
<Box
as="img"
src={layer.imageUrl}
width="60px"
height="60px"
boxShadow="md"
/>
</Td>
<Td>{layer.id}</Td>
<Td>{layer.zone.label}</Td>
<Td>
<Box display="flex" gap=".5em">
{layer.imageUrl && (
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
PNG
</Button>
)}
{layer.swfUrl && (
<Button as="a" href={layer.swfUrl} size="sm" download>
SWF
</Button>
)}
{layer.svgUrl && (
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
SVG
</Button>
)}
</Box>
</Td>
</Tr>
);
}
export default LayersInfoModal;

File diff suppressed because it is too large Load diff

View file

@ -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(
<Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
} else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'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!
</Box>,
);
}
}
}
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
glitchMessages.push(
<Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
} else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'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!
</Box>,
);
}
}
}
// 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(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? (
<>
We're aware of a glitch affecting the art for <i>{item.name}</i>.
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{" "}
<i>{item.name}</i>, 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!
</>
)}
</Box>,
);
}
}
// 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(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? (
<>
We're aware of a glitch affecting the art for <i>{item.name}</i>.
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{" "}
<i>{item.name}</i>, 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!
</>
)}
</Box>,
);
}
}
// 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(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i>, 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!
</Box>,
);
}
}
// 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(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i>, 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!
</Box>,
);
}
}
// 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(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> 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.
</Box>,
);
}
}
}
// 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(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> 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.
</Box>,
);
}
}
}
// 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(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that causes it to
display incorrectlybut 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!
</Box>,
);
}
}
// 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(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that causes it to
display incorrectlybut 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!
</Box>,
);
}
}
// 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(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> 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!
</Box>,
);
}
}
// 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(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> 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!
</Box>,
);
}
}
// 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(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
}
}
if (itemIsDyeworks && !itemIsConverted) {
glitchMessages.push(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
}
}
// Look for Baby Body Paint items.
for (const item of items) {
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
if (itemIsBabyBodyPaint) {
glitchMessages.push(
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
}
}
// Look for Baby Body Paint items.
for (const item of items) {
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
if (itemIsBabyBodyPaint) {
glitchMessages.push(
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
<i>{item.name}</i> 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!
</Box>,
);
}
}
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") {
glitchMessages.push(
<Box key={`invisible-pet-warning`}>
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!
</Box>,
);
}
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") {
glitchMessages.push(
<Box key={`invisible-pet-warning`}>
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!
</Box>,
);
}
// 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(
<Box key={`faerie-uni-dithering-horn-warning`}>
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
designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time!
</Box>,
);
}
// 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(
<Box key={`faerie-uni-dithering-horn-warning`}>
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
designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time!
</Box>,
);
}
// 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.
<Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't
seen a <em>correct</em> 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!
</Box>,
);
}
// 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.
<Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't
seen a <em>correct</em> 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!
</Box>,
);
}
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(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "}
<i>{layer.zone.label}</i> 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!
</Box>,
);
}
}
// 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(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "}
<i>{layer.zone.label}</i> 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!
</Box>,
);
}
}
// 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(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
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.
</Box>,
);
}
}
}
// 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(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
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.
</Box>,
);
}
}
}
// 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(
<Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that causes it to display incorrectlybut 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!
</Box>,
);
}
}
// 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(
<Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that causes it to display incorrectlybut 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!
</Box>,
);
}
}
if (glitchMessages.length === 0) {
return null;
}
if (glitchMessages.length === 0) {
return null;
}
return (
<GlitchBadgeLayout
aria-label="Has known glitches"
tooltipLabel={
<Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches
</Box>
<VStack spacing="1em">{glitchMessages}</VStack>
</Box>
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug />
</GlitchBadgeLayout>
);
return (
<GlitchBadgeLayout
aria-label="Has known glitches"
tooltipLabel={
<Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches
</Box>
<VStack spacing="1em">{glitchMessages}</VStack>
</Box>
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug />
</GlitchBadgeLayout>
);
}
export default OutfitKnownGlitchesBadge;

File diff suppressed because it is too large Load diff

View file

@ -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 (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Box>
<Box paddingX="4" paddingY="4">
<Flex as="label" align="center">
<Box fontWeight="600" flex="0 0 auto">
Add new items:
</Box>
<Box width="8" />
<SearchToolbar
query={searchQuery}
onChange={onChangeSearchQuery}
flex="0 1 100%"
suggestionsPlacement="top"
/>
<Box width="8" />
{numTotalPages != null && (
<Box flex="0 0 auto">
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={1}
goToPageNumber={() => alert("TODO")}
buildPageUrl={() => null}
size="sm"
/>
</Box>
)}
</Flex>
</Box>
<Box maxHeight="32" overflow="auto">
<Box as="ul" listStyleType="disc" paddingLeft="8">
{items.map((item) => (
<Box key={item.id} as="li">
{item.name}
</Box>
))}
</Box>
</Box>
</Box>
</Sentry.ErrorBoundary>
);
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Box>
<Box paddingX="4" paddingY="4">
<Flex as="label" align="center">
<Box fontWeight="600" flex="0 0 auto">
Add new items:
</Box>
<Box width="8" />
<SearchToolbar
query={searchQuery}
onChange={onChangeSearchQuery}
flex="0 1 100%"
suggestionsPlacement="top"
/>
<Box width="8" />
{numTotalPages != null && (
<Box flex="0 0 auto">
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={1}
goToPageNumber={() => alert("TODO")}
buildPageUrl={() => null}
size="sm"
/>
</Box>
)}
</Flex>
</Box>
<Box maxHeight="32" overflow="auto">
<Box as="ul" listStyleType="disc" paddingLeft="8">
{items.map((item) => (
<Box key={item.id} as="li">
{item.name}
</Box>
))}
</Box>
</Box>
</Box>
</Sentry.ErrorBoundary>
);
}
export default SearchFooter;

View file

@ -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 (
<Box
onKeyDown={(e) => {
// This will catch any Escape presses when the user's focus is inside
// the SearchPanel.
if (e.key === "Escape") {
onMoveFocusUpToQuery(e);
}
}}
>
<SearchResults
// When the query changes, replace the SearchResults component with a
// new instance. This resets both `currentPageNumber`, to take us back
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
// item you like in one search, then immediately do a second search and
// try a conflicting item, we'll restore the item you liked from your
// first search!
key={serializeQuery(query)}
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
scrollToTop={scrollToTop}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
</Box>
);
return (
<Box
onKeyDown={(e) => {
// This will catch any Escape presses when the user's focus is inside
// the SearchPanel.
if (e.key === "Escape") {
onMoveFocusUpToQuery(e);
}
}}
>
<SearchResults
// When the query changes, replace the SearchResults component with a
// new instance. This resets both `currentPageNumber`, to take us back
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
// item you like in one search, then immediately do a second search and
// try a conflicting item, we'll restore the item you liked from your
// first search!
key={serializeQuery(query)}
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
scrollToTop={scrollToTop}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
</Box>
);
}
/**
@ -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 <MajorErrorMessage error={error} variant="network" />;
}
if (error) {
return <MajorErrorMessage error={error} variant="network" />;
}
// 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 (
<Box>
<Box
position="sticky"
top="0"
background={searchPanelBackground}
zIndex="2"
paddingX="5"
paddingBottom="2"
paddingTop="1"
>
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={currentPageNumber}
goToPageNumber={setCurrentPageNumber}
buildPageUrl={() => null}
size="sm"
/>
</Box>
<ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => (
<SearchResultItem
key={item.id}
item={item}
itemIdsToReconsider={itemIdsToReconsider}
isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit}
checkboxRef={index === 0 ? firstSearchResultRef : null}
goToPrevItem={goToPrevItem}
goToNextItem={goToNextItem}
/>
))}
</ItemListContainer>
{loading && (
<ItemListSkeleton
count={SEARCH_PER_PAGE}
paddingX="4"
paddingBottom="2"
/>
)}
{!loading && items.length === 0 && (
<Text paddingX="4">
We couldn't find any matching items{" "}
<span role="img" aria-label="(thinking emoji)">
🤔
</span>{" "}
Try again?
</Text>
)}
</Box>
);
// 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 (
<Box>
<Box
position="sticky"
top="0"
background={searchPanelBackground}
zIndex="2"
paddingX="5"
paddingBottom="2"
paddingTop="1"
>
<PaginationToolbar
numTotalPages={numTotalPages}
currentPageNumber={currentPageNumber}
goToPageNumber={setCurrentPageNumber}
buildPageUrl={() => null}
size="sm"
/>
</Box>
<ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => (
<SearchResultItem
key={item.id}
item={item}
itemIdsToReconsider={itemIdsToReconsider}
isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit}
checkboxRef={index === 0 ? firstSearchResultRef : null}
goToPrevItem={goToPrevItem}
goToNextItem={goToNextItem}
/>
))}
</ItemListContainer>
{loading && (
<ItemListSkeleton
count={SEARCH_PER_PAGE}
paddingX="4"
paddingBottom="2"
/>
)}
{!loading && items.length === 0 && (
<Text paddingX="4">
We couldn't find any matching items{" "}
<span role="img" aria-label="(thinking emoji)">
🤔
</span>{" "}
Try again?
</Text>
)}
</Box>
);
}
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 <Item>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 <Item>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
<label>
<VisuallyHidden
as="input"
type="checkbox"
aria-label={`Wear "${item.name}"`}
value={item.id}
checked={isWorn}
ref={checkboxRef}
onChange={(e) => {
const itemId = e.target.value;
const willBeWorn = e.target.checked;
if (willBeWorn) {
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
} else {
dispatchToOutfit({
type: "unwearItem",
itemId,
itemIdsToReconsider,
});
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.target.click();
} else if (e.key === "ArrowUp") {
goToPrevItem(e);
} else if (e.key === "ArrowDown") {
goToNextItem(e);
}
}}
/>
<Item
item={item}
isWorn={isWorn}
isInOutfit={isInOutfit}
onRemove={onRemove}
/>
</label>
);
return (
// We're wrapping the control inside the label, which works just fine!
// eslint-disable-next-line jsx-a11y/label-has-associated-control
<label>
<VisuallyHidden
as="input"
type="checkbox"
aria-label={`Wear "${item.name}"`}
value={item.id}
checked={isWorn}
ref={checkboxRef}
onChange={(e) => {
const itemId = e.target.value;
const willBeWorn = e.target.checked;
if (willBeWorn) {
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
} else {
dispatchToOutfit({
type: "unwearItem",
itemId,
itemIdsToReconsider,
});
}
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.target.click();
} else if (e.key === "ArrowUp") {
goToPrevItem(e);
} else if (e.key === "ArrowDown") {
goToNextItem(e);
}
}}
/>
<Item
item={item}
isWorn={isWorn}
isInOutfit={isInOutfit}
onRemove={onRemove}
/>
</label>
);
}
/**
@ -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;

View file

@ -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 }) => (
<Box
fontWeight={isHighlighted ? "bold" : "normal"}
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
padding="2"
paddingLeft="2.5rem"
fontSize="sm"
>
{text}
</Box>
),
[suggestionBgColor, highlightedBgColor],
);
const renderSuggestion = React.useCallback(
({ text }, { isHighlighted }) => (
<Box
fontWeight={isHighlighted ? "bold" : "normal"}
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
padding="2"
paddingLeft="2.5rem"
fontSize="sm"
>
{text}
</Box>
),
[suggestionBgColor, highlightedBgColor],
);
const renderSuggestionsContainer = React.useCallback(
({ containerProps, children }) => {
const { className, ...otherContainerProps } = containerProps;
return (
<ClassNames>
{({ css, cx }) => (
<Box
{...otherContainerProps}
boxShadow="md"
overflow="auto"
transition="all 0.4s"
maxHeight="48"
width="100%"
className={cx(
className,
css`
li {
list-style: none;
}
`,
)}
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
>
{children}
{!children && advancedSearchIsOpen && (
<Box
padding="4"
fontSize="sm"
fontStyle="italic"
textAlign="center"
>
No more filters available!
</Box>
)}
</Box>
)}
</ClassNames>
);
},
[advancedSearchIsOpen, suggestionsPlacement],
);
const renderSuggestionsContainer = React.useCallback(
({ containerProps, children }) => {
const { className, ...otherContainerProps } = containerProps;
return (
<ClassNames>
{({ css, cx }) => (
<Box
{...otherContainerProps}
boxShadow="md"
overflow="auto"
transition="all 0.4s"
maxHeight="48"
width="100%"
className={cx(
className,
css`
li {
list-style: none;
}
`,
)}
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
>
{children}
{!children && advancedSearchIsOpen && (
<Box
padding="4"
fontSize="sm"
fontStyle="italic"
textAlign="center"
>
No more filters available!
</Box>
)}
</Box>
)}
</ClassNames>
);
},
[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 ? (
<>
<Box as="span" fontWeight="600">
Items:
</Box>{" "}
{queryFilterText}
</>
) : (
<Box as="span" fontWeight="600">
Items
</Box>
);
}
let queryFilterText = getQueryFilterText(query);
if (showItemsLabel) {
queryFilterText = queryFilterText ? (
<>
<Box as="span" fontWeight="600">
Items:
</Box>{" "}
{queryFilterText}
</>
) : (
<Box as="span" fontWeight="600">
Items
</Box>
);
}
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 (
<Box position="relative" {...props}>
<Autosuggest
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
onSuggestionsFetchRequested={({ value }) => {
// 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) => (
<InputGroup boxShadow={boxShadow} borderRadius="md">
{queryFilterText ? (
<InputLeftAddon>
<SearchIcon color="gray.400" marginRight="3" />
<Box fontSize="sm">{queryFilterText}</Box>
</InputLeftAddon>
) : (
<InputLeftElement>
<SearchIcon color="gray.400" />
</InputLeftElement>
)}
<Input
background={background}
// TODO: How to improve a11y here?
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...inputProps}
/>
<InputRightElement
width="auto"
justifyContent="flex-end"
paddingRight="2px"
paddingY="2px"
>
{!searchQueryIsEmpty(query) && (
<Tooltip label="Clear">
<IconButton
icon={<CloseIcon fontSize="0.6em" />}
color="gray.400"
variant="ghost"
height="100%"
marginLeft="1"
aria-label="Clear search"
onClick={() => {
setSuggestions([]);
onChange(emptySearchQuery);
}}
/>
</Tooltip>
)}
<Tooltip label="Advanced search">
<IconButton
icon={
advancedSearchIsOpen ? (
<ChevronUpIcon fontSize="1.5em" />
) : (
<ChevronDownIcon fontSize="1.5em" />
)
}
color="gray.400"
variant="ghost"
height="100%"
aria-label="Open advanced search"
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
/>
</Tooltip>
</InputRightElement>
</InputGroup>
)}
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,
});
}
},
}}
/>
</Box>
);
return (
<Box position="relative" {...props}>
<Autosuggest
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
onSuggestionsFetchRequested={({ value }) => {
// 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) => (
<InputGroup boxShadow={boxShadow} borderRadius="md">
{queryFilterText ? (
<InputLeftAddon>
<SearchIcon color="gray.400" marginRight="3" />
<Box fontSize="sm">{queryFilterText}</Box>
</InputLeftAddon>
) : (
<InputLeftElement>
<SearchIcon color="gray.400" />
</InputLeftElement>
)}
<Input
background={background}
// TODO: How to improve a11y here?
// eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus}
{...inputProps}
/>
<InputRightElement
width="auto"
justifyContent="flex-end"
paddingRight="2px"
paddingY="2px"
>
{!searchQueryIsEmpty(query) && (
<Tooltip label="Clear">
<IconButton
icon={<CloseIcon fontSize="0.6em" />}
color="gray.400"
variant="ghost"
height="100%"
marginLeft="1"
aria-label="Clear search"
onClick={() => {
setSuggestions([]);
onChange(emptySearchQuery);
}}
/>
</Tooltip>
)}
<Tooltip label="Advanced search">
<IconButton
icon={
advancedSearchIsOpen ? (
<ChevronUpIcon fontSize="1.5em" />
) : (
<ChevronDownIcon fontSize="1.5em" />
)
}
color="gray.400"
variant="ghost"
height="100%"
aria-label="Open advanced search"
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
/>
</Tooltip>
</InputRightElement>
</InputGroup>
)}
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,
});
}
},
}}
/>
</Box>
);
}
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;

View file

@ -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 (
<Box
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
// Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here!
zIndex="0"
>
<Grid
templateAreas={{
base: `"previewAndControls"
return (
<Box
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
// Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here!
zIndex="0"
>
<Grid
templateAreas={{
base: `"previewAndControls"
"itemsAndMaybeSearchPanel"`,
md: `"previewAndControls itemsAndMaybeSearchPanel"
md: `"previewAndControls itemsAndMaybeSearchPanel"
"searchFooter searchFooter"`,
}}
templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)",
md: "minmax(300px, 1fr) auto",
}}
templateColumns={{
base: "100%",
md: "50% 50%",
}}
height="100%"
width="100%"
>
<Box
gridArea="previewAndControls"
bg="gray.900"
color="gray.50"
position="relative"
>
{previewAndControls}
</Box>
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
{itemsAndMaybeSearchPanel}
</Box>
<Box
gridArea="searchFooter"
bg={searchBackground}
boxShadow={`0 0 8px ${searchShadowColorValue}`}
display={{ base: "none", md: "block" }}
>
{searchFooter}
</Box>
</Grid>
</Box>
);
}}
templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)",
md: "minmax(300px, 1fr) auto",
}}
templateColumns={{
base: "100%",
md: "50% 50%",
}}
height="100%"
width="100%"
>
<Box
gridArea="previewAndControls"
bg="gray.900"
color="gray.50"
position="relative"
>
{previewAndControls}
</Box>
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
{itemsAndMaybeSearchPanel}
</Box>
<Box
gridArea="searchFooter"
bg={searchBackground}
boxShadow={`0 0 8px ${searchShadowColorValue}`}
display={{ base: "none", md: "block" }}
>
{searchFooter}
</Box>
</Grid>
</Box>
);
}
export default WardrobePageLayout;

View file

@ -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: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
"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: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
"data-test-id": "wardrobe-outfit-preview",
});
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Center position="absolute" top="0" bottom="0" left="0" right="0">
<DarkMode>{preview}</DarkMode>
</Center>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
showAnimationControls={hasAnimations}
appearance={appearance}
/>
</Box>
</Sentry.ErrorBoundary>
);
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Center position="absolute" top="0" bottom="0" left="0" right="0">
<DarkMode>{preview}</DarkMode>
</Center>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
showAnimationControls={hasAnimations}
appearance={appearance}
/>
</Box>
</Sentry.ErrorBoundary>
);
}
/**
@ -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 (
<OutfitThumbnail
outfitId={data.outfit.id}
updatedAt={data.outfit.updatedAt}
alt=""
objectFit="contain"
width="100%"
height="100%"
filter="blur(2px)"
/>
);
return (
<OutfitThumbnail
outfitId={data.outfit.id}
updatedAt={data.outfit.updatedAt}
alt=""
objectFit="contain"
width="100%"
height="100%"
filter="blur(2px)"
/>
);
}
export default WardrobePreviewAndControls;

View file

@ -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 <Prompt /> 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 <Prompt /> 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 (
<OutfitStateContext.Provider value={outfitState}>
<WardrobePageLayout
previewAndControls={
<WardrobePreviewAndControls
isLoading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
}
itemsAndMaybeSearchPanel={
<ItemsAndSearchPanels
loading={loading}
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
}
searchFooter={
<SearchFooter
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
/>
}
/>
</OutfitStateContext.Provider>
);
// 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 (
<OutfitStateContext.Provider value={outfitState}>
<WardrobePageLayout
previewAndControls={
<WardrobePreviewAndControls
isLoading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
}
itemsAndMaybeSearchPanel={
<ItemsAndSearchPanels
loading={loading}
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
}
searchFooter={
<SearchFooter
searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery}
outfitState={outfitState}
/>
}
/>
</OutfitStateContext.Provider>
);
}
export default WardrobePage;

View file

@ -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 (
<ClassNames>
{({ css }) => (
<Box
as="button"
width="150px"
textAlign="center"
fontSize="xs"
onClick={onOpen}
>
<Box
width="150px"
height="150px"
marginBottom="1"
boxShadow="md"
borderRadius="md"
position="relative"
>
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
<Box
className={css`
opacity: 0;
transition: opacity 0.2s;
return (
<ClassNames>
{({ css }) => (
<Box
as="button"
width="150px"
textAlign="center"
fontSize="xs"
onClick={onOpen}
>
<Box
width="150px"
height="150px"
marginBottom="1"
boxShadow="md"
borderRadius="md"
position="relative"
>
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
<Box
className={css`
opacity: 0;
transition: opacity 0.2s;
button:hover &,
button:focus & {
opacity: 1;
}
button:hover &,
button:focus & {
opacity: 1;
}
/* On touch devices, always show the icon, to clarify that this is
/* On touch devices, always show the icon, to clarify that this is
* an interactable object! (Whereas I expect other devices to
* discover things by exploratory hover or focus!) */
@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"
>
<EditIcon
boxSize="16px"
position="relative"
top="-2px"
right="-1px"
/>
</Box>
</Box>
<Box>
<Box as="span" fontWeight="700">
{itemLayer.zone.label}
</Box>{" "}
<Box as="span" fontWeight="600">
(Zone {itemLayer.zone.id})
</Box>
</Box>
<Box>Neopets ID: {itemLayer.remoteId}</Box>
<Box>DTI ID: {itemLayer.id}</Box>
<AppearanceLayerSupportModal
item={item}
layer={itemLayer}
outfitState={outfitState}
isOpen={isOpen}
onClose={onClose}
/>
</Box>
)}
</ClassNames>
);
@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"
>
<EditIcon
boxSize="16px"
position="relative"
top="-2px"
right="-1px"
/>
</Box>
</Box>
<Box>
<Box as="span" fontWeight="700">
{itemLayer.zone.label}
</Box>{" "}
<Box as="span" fontWeight="600">
(Zone {itemLayer.zone.id})
</Box>
</Box>
<Box>Neopets ID: {itemLayer.remoteId}</Box>
<Box>DTI ID: {itemLayer.id}</Box>
<AppearanceLayerSupportModal
item={item}
layer={itemLayer}
outfitState={outfitState}
isOpen={isOpen}
onClose={onClose}
/>
</Box>
)}
</ClassNames>
);
}
export default ItemSupportAppearanceLayer;

View file

@ -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 (
<Drawer
placement={placement}
size="md"
isOpen={isOpen}
onClose={onClose}
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
// default implementation breaks out layout somehow 🤔 idk, let's not!
blockScrollOnMount={false}
>
<DrawerOverlay>
<DrawerContent
maxHeight={placement === "bottom" ? "90vh" : undefined}
overflow="auto"
>
<DrawerCloseButton />
<DrawerHeader>
{item.name}
<Badge colorScheme="pink" marginLeft="3">
Support <span aria-hidden="true">💖</span>
</Badge>
</DrawerHeader>
<DrawerBody paddingBottom="5">
<Metadata>
<MetadataLabel>Item ID:</MetadataLabel>
<MetadataValue>{item.id}</MetadataValue>
<MetadataLabel>Restricted zones:</MetadataLabel>
<MetadataValue>
<ItemSupportRestrictedZones item={item} />
</MetadataValue>
</Metadata>
<Stack spacing="8" marginTop="6">
<ItemSupportFields item={item} />
<ItemSupportAppearanceLayers item={item} />
</Stack>
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
return (
<Drawer
placement={placement}
size="md"
isOpen={isOpen}
onClose={onClose}
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
// default implementation breaks out layout somehow 🤔 idk, let's not!
blockScrollOnMount={false}
>
<DrawerOverlay>
<DrawerContent
maxHeight={placement === "bottom" ? "90vh" : undefined}
overflow="auto"
>
<DrawerCloseButton />
<DrawerHeader>
{item.name}
<Badge colorScheme="pink" marginLeft="3">
Support <span aria-hidden="true">💖</span>
</Badge>
</DrawerHeader>
<DrawerBody paddingBottom="5">
<Metadata>
<MetadataLabel>Item ID:</MetadataLabel>
<MetadataValue>{item.id}</MetadataValue>
<MetadataLabel>Restricted zones:</MetadataLabel>
<MetadataValue>
<ItemSupportRestrictedZones item={item} />
</MetadataValue>
</Metadata>
<Stack spacing="8" marginTop="6">
<ItemSupportFields item={item} />
<ItemSupportAppearanceLayers item={item} />
</Stack>
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
}
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 <Spinner size="xs" />;
}
if (loading) {
return <Spinner size="xs" />;
}
if (error) {
return <Text color="red.400">{error.message}</Text>;
}
if (error) {
return <Text color="red.400">{error.message}</Text>;
}
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 && <Box color={errorColor}>{error.message}</Box>}
<ItemSupportSpecialColorFields
loading={loading}
error={error}
item={item}
manualSpecialColor={data?.item?.manualSpecialColor?.id}
/>
<ItemSupportPetCompatibilityRuleFields
loading={loading}
error={error}
item={item}
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
/>
</>
);
return (
<>
{error && <Box color={errorColor}>{error.message}</Box>}
<ItemSupportSpecialColorFields
loading={loading}
error={error}
item={item}
manualSpecialColor={data?.item?.manualSpecialColor?.id}
/>
<ItemSupportPetCompatibilityRuleFields
loading={loading}
error={error}
item={item}
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
/>
</>
);
}
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 (
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
<FormLabel>Special color</FormLabel>
<Select
placeholder={
loading || colorsLoading
? "Loading…"
: "Default: Auto-detect from item description"
}
value={manualSpecialColor?.id}
isDisabled={mutationLoading}
icon={
loading || colorsLoading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{nonStandardColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</Select>
{colorsError && (
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
)}
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!colorsError && !mutationError && (
<FormHelperText>
This controls which previews we show on the{" "}
<Link
href={`https://impress.openneo.net/items/${
item.id
}-${item.name.replace(/ /g, "-")}`}
color={linkColor}
isExternal
>
classic item page <ExternalLinkIcon />
</Link>
.
</FormHelperText>
)}
</FormControl>
);
return (
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
<FormLabel>Special color</FormLabel>
<Select
placeholder={
loading || colorsLoading
? "Loading…"
: "Default: Auto-detect from item description"
}
value={manualSpecialColor?.id}
isDisabled={mutationLoading}
icon={
loading || colorsLoading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{nonStandardColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</Select>
{colorsError && (
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
)}
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!colorsError && !mutationError && (
<FormHelperText>
This controls which previews we show on the{" "}
<Link
href={`https://impress.openneo.net/items/${
item.id
}-${item.name.replace(/ /g, "-")}`}
color={linkColor}
isExternal
>
classic item page <ExternalLinkIcon />
</Link>
.
</FormHelperText>
)}
</FormControl>
);
}
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 (
<FormControl isInvalid={Boolean(error || mutationError)}>
<FormLabel>Pet compatibility rule</FormLabel>
<Select
value={explicitlyBodySpecific ? "true" : "false"}
isDisabled={mutationLoading}
icon={
loading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{loading ? (
<option>Loading</option>
) : (
<>
<option value="false">
Default: Auto-detect whether this fits all pets
</option>
<option value="true">
Body specific: Always different for each pet body
</option>
</>
)}
</Select>
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!mutationError && (
<FormHelperText>
By default, we assume Background-y zones fit all pets the same. When
items don't follow that rule, we can override it.
</FormHelperText>
)}
</FormControl>
);
return (
<FormControl isInvalid={Boolean(error || mutationError)}>
<FormLabel>Pet compatibility rule</FormLabel>
<Select
value={explicitlyBodySpecific ? "true" : "false"}
isDisabled={mutationLoading}
icon={
loading || mutationLoading ? (
<Spinner />
) : mutationData ? (
<CheckCircleIcon />
) : undefined
}
onChange={onChange}
>
{loading ? (
<option>Loading</option>
) : (
<>
<option value="false">
Default: Auto-detect whether this fits all pets
</option>
<option value="true">
Body specific: Always different for each pet body
</option>
</>
)}
</Select>
{mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
)}
{!mutationError && (
<FormHelperText>
By default, we assume Background-y zones fit all pets the same. When
items don't follow that rule, we can override it.
</FormHelperText>
)}
</FormControl>
);
}
/**
@ -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 (
<FormControl>
<Flex align="center">
<FormLabel>Appearance layers</FormLabel>
<Box width="4" flex="1 0 auto" />
<Button size="xs" onClick={modalState.onOpen}>
View on all pets <ChevronRightIcon />
</Button>
<AllItemLayersSupportModal
item={item}
isOpen={modalState.isOpen}
onClose={modalState.onClose}
/>
</Flex>
<HStack spacing="4" overflow="auto" paddingX="1">
{itemLayers.map((itemLayer) => (
<ItemSupportAppearanceLayer
key={itemLayer.id}
item={item}
itemLayer={itemLayer}
biologyLayers={biologyLayers}
outfitState={outfitState}
/>
))}
</HStack>
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl>
);
return (
<FormControl>
<Flex align="center">
<FormLabel>Appearance layers</FormLabel>
<Box width="4" flex="1 0 auto" />
<Button size="xs" onClick={modalState.onOpen}>
View on all pets <ChevronRightIcon />
</Button>
<AllItemLayersSupportModal
item={item}
isOpen={modalState.isOpen}
onClose={modalState.onClose}
/>
</Flex>
<HStack spacing="4" overflow="auto" paddingX="1">
{itemLayers.map((itemLayer) => (
<ItemSupportAppearanceLayer
key={itemLayer.id}
item={item}
itemLayer={itemLayer}
biologyLayers={biologyLayers}
outfitState={outfitState}
/>
))}
</HStack>
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl>
);
}
export default ItemSupportDrawer;

View file

@ -6,34 +6,34 @@ import { Box } from "@chakra-ui/react";
* and their values.
*/
function Metadata({ children, ...props }) {
return (
<Box
as="dl"
display="grid"
gridTemplateColumns="max-content auto"
gridRowGap="1"
gridColumnGap="2"
{...props}
>
{children}
</Box>
);
return (
<Box
as="dl"
display="grid"
gridTemplateColumns="max-content auto"
gridRowGap="1"
gridColumnGap="2"
{...props}
>
{children}
</Box>
);
}
function MetadataLabel({ children, ...props }) {
return (
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
{children}
</Box>
);
return (
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
{children}
</Box>
);
}
function MetadataValue({ children, ...props }) {
return (
<Box as="dd" gridColumn="2" {...props}>
{children}
</Box>
);
return (
<Box as="dd" gridColumn="2" {...props}>
{children}
</Box>
);
}
export default Metadata;

View file

@ -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;

View file

@ -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;

View file

@ -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;

File diff suppressed because it is too large Load diff

View file

@ -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;
}

View file

@ -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;

View file

@ -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 (
<GlitchBadgeLayout
hasGlitches={false}
aria-label="HTML5 supported!"
tooltipLabel={
tooltipLabel ||
"This item is converted to HTML5, and ready to use on Neopets.com!"
}
>
<CheckCircleIcon fontSize="xs" />
<Icon
viewBox="0 0 36 36"
fontSize="xl"
// Visual re-balancing, there's too much visual right-padding here!
marginRight="-1"
>
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
</Icon>
</GlitchBadgeLayout>
);
} else if (delayedUsesHTML5 === false) {
return (
<GlitchBadgeLayout
hasGlitches={true}
aria-label="HTML5 not supported"
tooltipLabel={
tooltipLabel || (
<>
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!
</>
)
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<Icon viewBox="0 0 36 36" fontSize="xl">
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
if (delayedUsesHTML5 === true) {
return (
<GlitchBadgeLayout
hasGlitches={false}
aria-label="HTML5 supported!"
tooltipLabel={
tooltipLabel ||
"This item is converted to HTML5, and ready to use on Neopets.com!"
}
>
<CheckCircleIcon fontSize="xs" />
<Icon
viewBox="0 0 36 36"
fontSize="xl"
// Visual re-balancing, there's too much visual right-padding here!
marginRight="-1"
>
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
</Icon>
</GlitchBadgeLayout>
);
} else if (delayedUsesHTML5 === false) {
return (
<GlitchBadgeLayout
hasGlitches={true}
aria-label="HTML5 not supported"
tooltipLabel={
tooltipLabel || (
<>
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!
</>
)
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<Icon viewBox="0 0 36 36" fontSize="xl">
{/* From Twemoji Keycap 5 */}
<path
fill="currentColor"
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
/>
{/* From Twemoji Not Allowed */}
<path
fill="#DD2E44"
opacity="0.75"
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
/>
</Icon>
</GlitchBadgeLayout>
);
} else {
// If no `usesHTML5` value has been provided yet, we're empty for now!
return null;
}
{/* From Twemoji Not Allowed */}
<path
fill="#DD2E44"
opacity="0.75"
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
/>
</Icon>
</GlitchBadgeLayout>
);
} 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 (
<Tooltip
textAlign="center"
fontSize="xs"
placement="bottom"
label={tooltipLabel}
// HACK: Chakra tooltips seem inconsistent about staying open when focus
// comes from touch events. But I really want this one to work on
// mobile!
isOpen={isHovered || isFocused}
>
<Flex
align="center"
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
color={hasGlitches ? yellowTextColor : greenTextColor}
border="1px solid"
borderRadius="md"
boxShadow="md"
paddingX="2"
paddingY="1"
transition="all 0.2s"
tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }}
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
minHeight="30px"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
>
{children}
</Flex>
</Tooltip>
);
return (
<Tooltip
textAlign="center"
fontSize="xs"
placement="bottom"
label={tooltipLabel}
// HACK: Chakra tooltips seem inconsistent about staying open when focus
// comes from touch events. But I really want this one to work on
// mobile!
isOpen={isHovered || isFocused}
>
<Flex
align="center"
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
color={hasGlitches ? yellowTextColor : greenTextColor}
border="1px solid"
borderRadius="md"
boxShadow="md"
paddingX="2"
paddingY="1"
transition="all 0.2s"
tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }}
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
minHeight="30px"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)}
{...props}
>
{children}
</Flex>
</Tooltip>
);
}
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;

View file

@ -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: (
<path
fill="currentColor"
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
/>
),
// https://www.svgrepo.com/svg/108090/clothes-hanger
viewBox: "0 0 473 473",
path: (
<path
fill="currentColor"
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
/>
),
});
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 (
<ClassNames>
{({ css }) => (
<Box
className={css`
/*
return (
<ClassNames>
{({ css }) => (
<Box
className={css`
/*
Adapted from animate.css "swing". We spend 75% of the time swinging,
then 25% of the time pausing before the next loop.
We use this animation for folks who are okay with dizzy-ish motion.
For reduced motion, we use a pulse-fade instead.
*/
@keyframes swing {
15% {
transform: rotate3d(0, 0, 1, 15deg);
}
@keyframes swing {
15% {
transform: rotate3d(0, 0, 1, 15deg);
}
30% {
transform: rotate3d(0, 0, 1, -10deg);
}
30% {
transform: rotate3d(0, 0, 1, -10deg);
}
45% {
transform: rotate3d(0, 0, 1, 5deg);
}
45% {
transform: rotate3d(0, 0, 1, 5deg);
}
60% {
transform: rotate3d(0, 0, 1, -5deg);
}
60% {
transform: rotate3d(0, 0, 1, -5deg);
}
75% {
transform: rotate3d(0, 0, 1, 0deg);
}
75% {
transform: rotate3d(0, 0, 1, 0deg);
}
100% {
transform: rotate3d(0, 0, 1, 0deg);
}
}
100% {
transform: rotate3d(0, 0, 1, 0deg);
}
}
/*
/*
A homebrew fade-pulse animation. We use this for folks who don't
like motion. It's an important accessibility thing!
*/
@keyframes fade-pulse {
0% {
opacity: 0.2;
}
@keyframes fade-pulse {
0% {
opacity: 0.2;
}
50% {
opacity: 1;
}
50% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
100% {
opacity: 0.2;
}
}
@media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite swing;
transform-origin: top center;
}
@media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite swing;
transform-origin: top center;
}
@media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite fade-pulse;
}
`}
{...props}
>
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
</Box>
)}
</ClassNames>
);
@media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite fade-pulse;
}
`}
{...props}
>
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
</Box>
)}
</ClassNames>
);
}
export default HangerSpinner;

View file

@ -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 <SquareItemCard item={item} {...props} />;
case "list":
return (
<Box
as="a"
href={`/items/${item.id}`}
display="block"
p="2"
boxShadow="lg"
borderRadius="lg"
background={brightBackground}
transition="all 0.2s"
className="item-card"
width="100%"
minWidth="0"
{...props}
>
<ItemCardContent
item={item}
badges={badges}
focusSelector=".item-card:hover &, .item-card:focus &"
/>
</Box>
);
default:
throw new Error(`Unexpected ItemCard variant: ${variant}`);
}
switch (variant) {
case "grid":
return <SquareItemCard item={item} {...props} />;
case "list":
return (
<Box
as="a"
href={`/items/${item.id}`}
display="block"
p="2"
boxShadow="lg"
borderRadius="lg"
background={brightBackground}
transition="all 0.2s"
className="item-card"
width="100%"
minWidth="0"
{...props}
>
<ItemCardContent
item={item}
badges={badges}
focusSelector=".item-card:hover &, .item-card:focus &"
/>
</Box>
);
default:
throw new Error(`Unexpected ItemCard variant: ${variant}`);
}
}
export function ItemCardContent({
item,
badges,
isWorn,
isDisabled,
itemNameId,
focusSelector,
item,
badges,
isWorn,
isDisabled,
itemNameId,
focusSelector,
}) {
return (
<Box display="flex">
<Box>
<Box flex="0 0 auto" marginRight="3">
<ItemThumbnail
item={item}
isActive={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
/>
</Box>
</Box>
<Box flex="1 1 0" minWidth="0" marginTop="1px">
<ItemName
id={itemNameId}
isWorn={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
>
{item.name}
</ItemName>
return (
<Box display="flex">
<Box>
<Box flex="0 0 auto" marginRight="3">
<ItemThumbnail
item={item}
isActive={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
/>
</Box>
</Box>
<Box flex="1 1 0" minWidth="0" marginTop="1px">
<ItemName
id={itemNameId}
isWorn={isWorn}
isDisabled={isDisabled}
focusSelector={focusSelector}
>
{item.name}
</ItemName>
{badges}
</Box>
</Box>
);
{badges}
</Box>
</Box>
);
}
/**
@ -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 (
<ClassNames>
{({ css }) => (
<Box
width={size === "lg" ? "80px" : "50px"}
height={size === "lg" ? "80px" : "50px"}
transition="all 0.15s"
transformOrigin="center"
position="relative"
className={css([
{
transform: "scale(0.8)",
},
!isDisabled &&
!isActive && {
[focusSelector]: {
opacity: "0.9",
transform: "scale(0.9)",
},
},
!isDisabled &&
isActive && {
opacity: 1,
transform: "none",
},
])}
{...props}
>
<Box
borderRadius="lg"
boxShadow="md"
border="1px"
overflow="hidden"
width="100%"
height="100%"
className={css([
{
borderColor: `${borderColor} !important`,
},
!isDisabled &&
!isActive && {
[focusSelector]: {
borderColor: `${focusBorderColor} !important`,
},
},
])}
>
{/* If the item is still loading, wait with an empty box. */}
{item && (
<Box
as="img"
width="100%"
height="100%"
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
/>
)}
</Box>
</Box>
)}
</ClassNames>
);
return (
<ClassNames>
{({ css }) => (
<Box
width={size === "lg" ? "80px" : "50px"}
height={size === "lg" ? "80px" : "50px"}
transition="all 0.15s"
transformOrigin="center"
position="relative"
className={css([
{
transform: "scale(0.8)",
},
!isDisabled &&
!isActive && {
[focusSelector]: {
opacity: "0.9",
transform: "scale(0.9)",
},
},
!isDisabled &&
isActive && {
opacity: 1,
transform: "none",
},
])}
{...props}
>
<Box
borderRadius="lg"
boxShadow="md"
border="1px"
overflow="hidden"
width="100%"
height="100%"
className={css([
{
borderColor: `${borderColor} !important`,
},
!isDisabled &&
!isActive && {
[focusSelector]: {
borderColor: `${focusBorderColor} !important`,
},
},
])}
>
{/* If the item is still loading, wait with an empty box. */}
{item && (
<Box
as="img"
width="100%"
height="100%"
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
/>
)}
</Box>
</Box>
)}
</ClassNames>
);
}
/**
@ -186,245 +186,245 @@ export function ItemThumbnail({
* states.
*/
function ItemName({ children, isDisabled, focusSelector, ...props }) {
const theme = useTheme();
const theme = useTheme();
return (
<ClassNames>
{({ css }) => (
<Box
fontSize="md"
transition="all 0.15s"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
className={
!isDisabled &&
css`
${focusSelector} {
opacity: 0.9;
font-weight: ${theme.fontWeights.medium};
}
return (
<ClassNames>
{({ css }) => (
<Box
fontSize="md"
transition="all 0.15s"
overflow="hidden"
whiteSpace="nowrap"
textOverflow="ellipsis"
className={
!isDisabled &&
css`
${focusSelector} {
opacity: 0.9;
font-weight: ${theme.fontWeights.medium};
}
input:checked + .item-container & {
opacity: 1;
font-weight: ${theme.fontWeights.bold};
}
`
}
{...props}
>
{children}
</Box>
)}
</ClassNames>
);
input:checked + .item-container & {
opacity: 1;
font-weight: ${theme.fontWeights.bold};
}
`
}
{...props}
>
{children}
</Box>
)}
</ClassNames>
);
}
export function ItemCardList({ children }) {
return (
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
{children}
</SimpleGrid>
);
return (
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
{children}
</SimpleGrid>
);
}
export function ItemBadgeList({ children, ...props }) {
return (
<Wrap spacing="2" opacity="0.7" {...props}>
{React.Children.map(
children,
(badge) => badge && <WrapItem>{badge}</WrapItem>,
)}
</Wrap>
);
return (
<Wrap spacing="2" opacity="0.7" {...props}>
{React.Children.map(
children,
(badge) => badge && <WrapItem>{badge}</WrapItem>,
)}
</Wrap>
);
}
export function ItemBadgeTooltip({ label, children }) {
return (
<Tooltip
label={<Box textAlign="center">{label}</Box>}
placement="top"
openDelay={400}
>
{children}
</Tooltip>
);
return (
<Tooltip
label={<Box textAlign="center">{label}</Box>}
placement="top"
openDelay={400}
>
{children}
</Tooltip>
);
}
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="Neocash">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="purple"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NC
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
return (
<ItemBadgeTooltip label="Neocash">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="purple"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NC
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="Neopoints">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NP
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
return (
<ItemBadgeTooltip label="Neopoints">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
NP
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return (
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="orange"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
PB
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
return (
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
<Badge
ref={ref}
as={isEditButton ? "button" : "span"}
colorScheme="orange"
display="flex"
alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }}
{...props}
>
PB
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge>
</ItemBadgeTooltip>
);
});
export const ItemKindBadge = React.forwardRef(
({ isNc, isPb, isEditButton, ...props }, ref) => {
if (isNc) {
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else if (isPb) {
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else {
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
}
},
({ isNc, isPb, isEditButton, ...props }, ref) => {
if (isNc) {
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else if (isPb) {
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else {
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
}
},
);
export function YouOwnThisBadge({ variant = "long" }) {
let badge = (
<Badge
colorScheme="green"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<CheckIcon aria-label="Check" />
{variant === "medium" && <Box marginLeft="1">Own</Box>}
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
</Badge>
);
let badge = (
<Badge
colorScheme="green"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<CheckIcon aria-label="Check" />
{variant === "medium" && <Box marginLeft="1">Own</Box>}
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
</Badge>
);
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
);
}
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
);
}
return badge;
return badge;
}
export function YouWantThisBadge({ variant = "long" }) {
let badge = (
<Badge
colorScheme="blue"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<StarIcon aria-label="Star" />
{variant === "medium" && <Box marginLeft="1">Want</Box>}
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
</Badge>
);
let badge = (
<Badge
colorScheme="blue"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<StarIcon aria-label="Star" />
{variant === "medium" && <Box marginLeft="1">Want</Box>}
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
</Badge>
);
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
);
}
if (variant === "short" || variant === "medium") {
badge = (
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
);
}
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 (
<ItemBadgeTooltip
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
>
<Badge>
<Box display="flex" alignItems="center">
{shorthand} <NotAllowedIcon marginLeft="1" />
</Box>
</Badge>
</ItemBadgeTooltip>
);
}
if (variant === "restricts") {
return (
<ItemBadgeTooltip
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
>
<Badge>
<Box display="flex" alignItems="center">
{shorthand} <NotAllowedIcon marginLeft="1" />
</Box>
</Badge>
</ItemBadgeTooltip>
);
}
if (shorthand !== zoneLabel) {
return (
<ItemBadgeTooltip label={zoneLabel}>
<Badge>{shorthand}</Badge>
</ItemBadgeTooltip>
);
}
if (shorthand !== zoneLabel) {
return (
<ItemBadgeTooltip label={zoneLabel}>
<Badge>{shorthand}</Badge>
</ItemBadgeTooltip>
);
}
return <Badge>{shorthand}</Badge>;
return <Badge>{shorthand}</Badge>;
}
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) => (
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
));
return labels.map((label) => (
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
));
}
export function MaybeAnimatedBadge() {
return (
<ItemBadgeTooltip label="Maybe animated? (Support only)">
<Badge
colorScheme="orange"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<Box as={HiSparkles} aria-label="Sparkles" />
</Badge>
</ItemBadgeTooltip>
);
return (
<ItemBadgeTooltip label="Maybe animated? (Support only)">
<Badge
colorScheme="orange"
display="flex"
alignItems="center"
minHeight="1.5em"
>
<Box as={HiSparkles} aria-label="Sparkles" />
</Badge>
</ItemBadgeTooltip>
);
}
export default ItemCard;

View file

@ -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 (
<Grid templateAreas="single-shared-area">
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{
width: width,
height: height,
gridArea: "single-shared-area",
}}
data-is-loaded={movieIsLoaded}
{...canvasProps}
/>
{/* 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 && (
<Box
as="img"
src={safeImageUrl(placeholderImageUrl)}
width={width}
height={height}
gridArea="single-shared-area"
opacity={movieIsLoaded ? 0 : 1}
transition="opacity 0.2s"
onLoad={callOnLoadIfNotYetCalled}
/>
)}
</Grid>
);
return (
<Grid templateAreas="single-shared-area">
<canvas
ref={canvasRef}
width={internalWidth}
height={internalHeight}
style={{
width: width,
height: height,
gridArea: "single-shared-area",
}}
data-is-loaded={movieIsLoaded}
{...canvasProps}
/>
{/* 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 && (
<Box
as="img"
src={safeImageUrl(placeholderImageUrl)}
width={width}
height={height}
gridArea="single-shared-area"
opacity={movieIsLoaded ? 0 : 1}
transition="opacity 0.2s"
onLoad={callOnLoadIfNotYetCalled}
/>
)}
</Grid>
);
}
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;

View file

@ -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 = (
<FullScreenCenter>
<Text color={textColor} d="flex" alignItems="center">
<WarningIcon />
<Box width={2} />
Could not load preview. Try again?
</Text>
</FullScreenCenter>
);
} else {
preview = (
<OutfitLayers
loading={isLoading || loading || loading2}
visibleLayers={loadedLayers}
placeholder={placeholder}
loadingDelayMs={loadingDelayMs}
spinnerVariant={spinnerVariant}
onMovieError={onMovieError}
onLowFps={onLowFps}
doTransitions
isPaused={isPaused}
{...props}
/>
);
}
let preview;
if (error || error2) {
preview = (
<FullScreenCenter>
<Text color={textColor} d="flex" alignItems="center">
<WarningIcon />
<Box width={2} />
Could not load preview. Try again?
</Text>
</FullScreenCenter>
);
} else {
preview = (
<OutfitLayers
loading={isLoading || loading || loading2}
visibleLayers={loadedLayers}
placeholder={placeholder}
loadingDelayMs={loadingDelayMs}
spinnerVariant={spinnerVariant}
onMovieError={onMovieError}
onLowFps={onLowFps}
doTransitions
isPaused={isPaused}
{...props}
/>
);
}
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 <img> 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 <img> 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 (
<ClassNames>
{({ css }) => (
<Box
pos="relative"
height="100%"
width="100%"
maxWidth="600px"
maxHeight="600px"
// Create a stacking context, so the z-indexed layers don't escape!
zIndex="0"
ref={containerRef}
data-loading={loading ? true : undefined}
{...props}
>
{placeholder && (
<FullScreenCenter>
<Box
// We show the placeholder until there are visible layers, at which
// point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s"
width="100%"
height="100%"
maxWidth="600px"
maxHeight="600px"
>
{placeholder}
</Box>
</FullScreenCenter>
)}
<TransitionGroup enter={false} exit={doTransitions}>
{layersWithAssets.map((layer) => (
<CSSTransition
// We manage the fade-in and fade-out separately! The fade-out
// happens here, when the layer exits the DOM.
key={layer.id}
timeout={200}
>
<FadeInOnLoad
as={FullScreenCenter}
zIndex={layer.zone.depth}
className={css`
&.exit {
opacity: 1;
}
return (
<ClassNames>
{({ css }) => (
<Box
pos="relative"
height="100%"
width="100%"
maxWidth="600px"
maxHeight="600px"
// Create a stacking context, so the z-indexed layers don't escape!
zIndex="0"
ref={containerRef}
data-loading={loading ? true : undefined}
{...props}
>
{placeholder && (
<FullScreenCenter>
<Box
// We show the placeholder until there are visible layers, at which
// point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s"
width="100%"
height="100%"
maxWidth="600px"
maxHeight="600px"
>
{placeholder}
</Box>
</FullScreenCenter>
)}
<TransitionGroup enter={false} exit={doTransitions}>
{layersWithAssets.map((layer) => (
<CSSTransition
// We manage the fade-in and fade-out separately! The fade-out
// happens here, when the layer exits the DOM.
key={layer.id}
timeout={200}
>
<FadeInOnLoad
as={FullScreenCenter}
zIndex={layer.zone.depth}
className={css`
&.exit {
opacity: 1;
}
&.exit-active {
opacity: 0;
transition: opacity 0.2s;
}
`}
>
{layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode,
})}
width={canvasSize}
height={canvasSize}
isPaused={isPaused}
onError={onMovieError}
onLowFps={onLowFps}
/>
) : (
<Box
as="img"
src={safeImageUrl(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive },
)}
alt=""
objectFit="contain"
maxWidth="100%"
maxHeight="100%"
/>
)}
</FadeInOnLoad>
</CSSTransition>
))}
</TransitionGroup>
<FullScreenCenter
zIndex="9000"
// This is similar to our Delay util component, but Delay disappears
// immediately on load, whereas we want this to fade out smoothly. We
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
// fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!)
opacity={loading && loadingDelayHasPassed ? 1 : 0}
transition="opacity 0.2s"
>
{spinnerVariant === "overlay" && (
<>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
backgroundColor="gray.900"
opacity="0.7"
/>
{/* Against the dark overlay, use the Dark Mode spinner. */}
<DarkMode>
<HangerSpinner />
</DarkMode>
</>
)}
{spinnerVariant === "corner" && (
<HangerSpinner
size="sm"
position="absolute"
bottom="2"
right="2"
/>
)}
</FullScreenCenter>
</Box>
)}
</ClassNames>
);
&.exit-active {
opacity: 0;
transition: opacity 0.2s;
}
`}
>
{layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode,
})}
width={canvasSize}
height={canvasSize}
isPaused={isPaused}
onError={onMovieError}
onLowFps={onLowFps}
/>
) : (
<Box
as="img"
src={safeImageUrl(
getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive },
)}
alt=""
objectFit="contain"
maxWidth="100%"
maxHeight="100%"
/>
)}
</FadeInOnLoad>
</CSSTransition>
))}
</TransitionGroup>
<FullScreenCenter
zIndex="9000"
// This is similar to our Delay util component, but Delay disappears
// immediately on load, whereas we want this to fade out smoothly. We
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
// fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!)
opacity={loading && loadingDelayHasPassed ? 1 : 0}
transition="opacity 0.2s"
>
{spinnerVariant === "overlay" && (
<>
<Box
position="absolute"
top="0"
left="0"
right="0"
bottom="0"
backgroundColor="gray.900"
opacity="0.7"
/>
{/* Against the dark overlay, use the Dark Mode spinner. */}
<DarkMode>
<HangerSpinner />
</DarkMode>
</>
)}
{spinnerVariant === "corner" && (
<HangerSpinner
size="sm"
position="absolute"
bottom="2"
right="2"
/>
)}
</FullScreenCenter>
</Box>
)}
</ClassNames>
);
}
export function FullScreenCenter({ children, ...otherProps }) {
return (
<Flex
pos="absolute"
top="0"
right="0"
bottom="0"
left="0"
alignItems="center"
justifyContent="center"
{...otherProps}
>
{children}
</Flex>
);
return (
<Flex
pos="absolute"
top="0"
right="0"
bottom="0"
left="0"
alignItems="center"
justifyContent="center"
{...otherProps}
>
{children}
</Flex>
);
}
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 (
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
{wrappedChild}
</Box>
);
return (
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
{wrappedChild}
</Box>
);
}
// 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;

View file

@ -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 (
<Box
as="img"
src={thumbnailUrl150}
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
{...props}
/>
);
return (
<Box
as="img"
src={thumbnailUrl150}
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
{...props}
/>
);
}
export default OutfitThumbnail;

View file

@ -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 (
<Flex align="center" justify="space-between" {...props}>
<LinkOrButton
href={prevPageUrl}
onClick={
prevPageUrl == null
? () => goToPageNumber(currentPageNumber - 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasPrevPage}
size={size}
>
Prev
</LinkOrButton>
{numTotalPages > 0 && (
<Flex align="center" paddingX="4" fontSize={size}>
<Box flex="0 0 auto">Page</Box>
<Box width="1" />
<PageNumberSelect
currentPageNumber={currentPageNumber}
numTotalPages={numTotalPages}
onChange={goToPageNumber}
marginBottom="-2px"
size={size}
/>
<Box width="1" />
<Box flex="0 0 auto">of {numTotalPages}</Box>
</Flex>
)}
<LinkOrButton
href={nextPageUrl}
onClick={
nextPageUrl == null
? () => goToPageNumber(currentPageNumber + 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasNextPage}
size={size}
>
Next
</LinkOrButton>
</Flex>
);
return (
<Flex align="center" justify="space-between" {...props}>
<LinkOrButton
href={prevPageUrl}
onClick={
prevPageUrl == null
? () => goToPageNumber(currentPageNumber - 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasPrevPage}
size={size}
>
Prev
</LinkOrButton>
{numTotalPages > 0 && (
<Flex align="center" paddingX="4" fontSize={size}>
<Box flex="0 0 auto">Page</Box>
<Box width="1" />
<PageNumberSelect
currentPageNumber={currentPageNumber}
numTotalPages={numTotalPages}
onChange={goToPageNumber}
marginBottom="-2px"
size={size}
/>
<Box width="1" />
<Box flex="0 0 auto">of {numTotalPages}</Box>
</Flex>
)}
<LinkOrButton
href={nextPageUrl}
onClick={
nextPageUrl == null
? () => goToPageNumber(currentPageNumber + 1)
: undefined
}
_disabled={{
cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4,
}}
isDisabled={!hasNextPage}
size={size}
>
Next
</LinkOrButton>
</Flex>
);
}
function LinkOrButton({ href, ...props }) {
if (href != null) {
return <Button as="a" href={href} {...props} />;
} else {
return <Button {...props} />;
}
if (href != null) {
return <Button as="a" href={href} {...props} />;
} else {
return <Button {...props} />;
}
}
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 (
<Select
value={currentPageNumber}
onChange={handleChange}
width="7ch"
variant="flushed"
textAlign="center"
{...props}
>
{allPageNumbers.map((pageNumber) => (
<option key={pageNumber} value={pageNumber}>
{pageNumber}
</option>
))}
</Select>
);
return (
<Select
value={currentPageNumber}
onChange={handleChange}
width="7ch"
variant="flushed"
textAlign="center"
{...props}
>
{allPageNumbers.map((pageNumber) => (
<option key={pageNumber} value={pageNumber}>
{pageNumber}
</option>
))}
</Select>
);
}
export default PaginationToolbar;

View file

@ -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 (
<Delay ms={5000}>
<Text color={textColor} textShadow="md">
Loading species/color data
</Text>
</Delay>
);
}
if ((loadingMeta || loadingValids) && !showPlaceholders) {
return (
<Delay ms={5000}>
<Text color={textColor} textShadow="md">
Loading species/color data
</Text>
</Delay>
);
}
if (errorMeta || errorValids) {
return (
<Text color={textColor} textShadow="md">
Error loading species/color data.
</Text>
);
}
if (errorMeta || errorValids) {
return (
<Text color={textColor} textShadow="md">
Error loading species/color data.
</Text>
);
}
// 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 (
<Flex direction="row">
<SpeciesColorSelect
aria-label="Pet color"
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled}
onChange={onChangeColor}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={colorTestId}
>
{
// 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) && (
<option value="SpeciesColorPicker-color-loading-placeholder">
{colorPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
visibleColors.length === 0 && <option>Dimensional</option>
}
{visibleColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</SpeciesColorSelect>
<Box width={size === "sm" ? 2 : 4} />
<SpeciesColorSelect
aria-label="Pet species"
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled || speciesIsDisabled}
// Don't fade out in the speciesIsDisabled case; it's more like a
// read-only state.
_disabled={
speciesIsDisabled
? { opacity: "1", cursor: "not-allowed" }
: undefined
}
onChange={onChangeSpecies}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={speciesTestId}
>
{
// 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) && (
<option value="SpeciesColorPicker-species-loading-placeholder">
{speciesPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
allSpecies.length === 0 && <option>Tuskaninny</option>
}
{allSpecies.map((species) => (
<option key={species.id} value={species.id}>
{species.name}
</option>
))}
</SpeciesColorSelect>
</Flex>
);
return (
<Flex direction="row">
<SpeciesColorSelect
aria-label="Pet color"
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled}
onChange={onChangeColor}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={colorTestId}
>
{
// 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) && (
<option value="SpeciesColorPicker-color-loading-placeholder">
{colorPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
visibleColors.length === 0 && <option>Dimensional</option>
}
{visibleColors.map((color) => (
<option key={color.id} value={color.id}>
{color.name}
</option>
))}
</SpeciesColorSelect>
<Box width={size === "sm" ? 2 : 4} />
<SpeciesColorSelect
aria-label="Pet species"
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't
// trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading.
isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId
}
isDisabled={isDisabled || speciesIsDisabled}
// Don't fade out in the speciesIsDisabled case; it's more like a
// read-only state.
_disabled={
speciesIsDisabled
? { opacity: "1", cursor: "not-allowed" }
: undefined
}
onChange={onChangeSpecies}
size={size}
valids={valids}
speciesId={speciesId}
colorId={colorId}
data-test-id={speciesTestId}
>
{
// 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) && (
<option value="SpeciesColorPicker-species-loading-placeholder">
{speciesPlaceholderText}
</option>
)
}
{
// A long name for sizing! Should appear below the placeholder, out
// of view.
allSpecies.length === 0 && <option>Tuskaninny</option>
}
{allSpecies.map((species) => (
<option key={species.id} value={species.id}>
{species.name}
</option>
))}
</SpeciesColorSelect>
</Flex>
);
}
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 (
<Select
backgroundColor={backgroundColor}
color={textColor}
size={size}
border="1px"
borderColor={borderColor}
boxShadow="md"
width="auto"
transition="all 0.25s"
_hover={{
borderColor: "green.400",
}}
isInvalid={
valids &&
speciesId &&
colorId &&
!pairIsValid(valids, speciesId, colorId)
}
isDisabled={isDisabled || isLoading}
errorBorderColor="red.300"
{...props}
{...loadingProps}
/>
);
return (
<Select
backgroundColor={backgroundColor}
color={textColor}
size={size}
border="1px"
borderColor={borderColor}
boxShadow="md"
width="auto"
transition="all 0.25s"
_hover={{
borderColor: "green.400",
}}
isInvalid={
valids &&
speciesId &&
colorId &&
!pairIsValid(valids, speciesId, colorId)
}
isDisabled={isDisabled || isLoading}
errorBorderColor="red.300"
{...props}
{...loadingProps}
/>
);
};
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);

View file

@ -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 (
<ClassNames>
{({ 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.
<div
className={css`
position: relative;
display: flex;
`}
role="group"
>
<Box
as="a"
href={`/items/${item.id}`}
className={css`
border-radius: ${mdRadiusValue};
transition: all 0.2s;
&:hover,
&:focus {
transform: scale(1.05);
}
&:focus {
box-shadow: ${outlineShadowValue};
outline: none;
}
`}
{...props}
>
<SquareItemCardLayout
name={item.name}
thumbnailImage={
<ItemThumbnail
item={item}
tradeMatchingMode={tradeMatchingMode}
/>
}
removeButton={
showRemoveButton ? (
<SquareItemCardRemoveButton onClick={onRemove} />
) : null
}
boxShadow={tradeMatchShadow}
footer={footer}
/>
</Box>
{showRemoveButton && (
<div
className={css`
position: absolute;
right: 0;
top: 0;
transform: translate(50%, -50%);
z-index: 1;
return (
<ClassNames>
{({ 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.
<div
className={css`
position: relative;
display: flex;
`}
role="group"
>
<Box
as="a"
href={`/items/${item.id}`}
className={css`
border-radius: ${mdRadiusValue};
transition: all 0.2s;
&:hover,
&:focus {
transform: scale(1.05);
}
&:focus {
box-shadow: ${outlineShadowValue};
outline: none;
}
`}
{...props}
>
<SquareItemCardLayout
name={item.name}
thumbnailImage={
<ItemThumbnail
item={item}
tradeMatchingMode={tradeMatchingMode}
/>
}
removeButton={
showRemoveButton ? (
<SquareItemCardRemoveButton onClick={onRemove} />
) : null
}
boxShadow={tradeMatchShadow}
footer={footer}
/>
</Box>
{showRemoveButton && (
<div
className={css`
position: absolute;
right: 0;
top: 0;
transform: translate(50%, -50%);
z-index: 1;
/* Apply some padding, so accidental clicks around the button
/* Apply some padding, so accidental clicks around the button
* don't click the link instead, or vice-versa! */
padding: 0.75em;
padding: 0.75em;
opacity: 0;
[role="group"]:hover &,
[role="group"]:focus-within &,
&:hover,
&:focus-within {
opacity: 1;
}
`}
>
<SquareItemCardRemoveButton onClick={onRemove} />
</div>
)}
</div>
)}
</ClassNames>
);
opacity: 0;
[role="group"]:hover &,
[role="group"]:focus-within &,
&:hover,
&:focus-within {
opacity: 1;
}
`}
>
<SquareItemCardRemoveButton onClick={onRemove} />
</div>
)}
</div>
)}
</ClassNames>
);
}
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.
<ClassNames>
{({ css }) => (
<div
className={css`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: ${boxShadow || theme.shadows.md};
border-radius: ${theme.radii.md};
padding: ${theme.space["3"]};
width: calc(80px + 2em);
background: ${brightBackgroundValue};
`}
>
{thumbnailImage}
<div
className={css`
margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm};
/* Set min height to match a 2-line item name, so the cards
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.
<ClassNames>
{({ css }) => (
<div
className={css`
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
box-shadow: ${boxShadow || theme.shadows.md};
border-radius: ${theme.radii.md};
padding: ${theme.space["3"]};
width: calc(80px + 2em);
background: ${brightBackgroundValue};
`}
>
{thumbnailImage}
<div
className={css`
margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm};
/* Set min height to match a 2-line item name, so the cards
* in a row aren't toooo differently sized... */
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}
</div>
{footer && (
<Box marginTop="2" width="100%">
{footer}
</Box>
)}
</div>
)}
</ClassNames>
);
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}
</div>
{footer && (
<Box marginTop="2" width="100%">
{footer}
</Box>
)}
</div>
)}
</ClassNames>
);
}
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 (
<ClassNames>
{({ css }) => (
<div
className={css`
position: relative;
`}
>
<img
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
width={80}
height={80}
className={css`
border-radius: ${mdRadiusValue};
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
return (
<ClassNames>
{({ css }) => (
<div
className={css`
position: relative;
`}
>
<img
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`}
width={80}
height={80}
className={css`
border-radius: ${mdRadiusValue};
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
/* Don't let alt text flash in while loading */
&:-moz-loading {
visibility: hidden;
}
`}
loading="lazy"
/>
<div
className={css`
position: absolute;
top: -6px;
left: -6px;
display: flex;
flex-direction: column;
gap: 2px;
`}
>
{showOwnsBadge && (
<ItemOwnsWantsBadge
colorScheme="green"
label={
showTradeMatchFlair
? "You own this, and they want it!"
: "You own this"
}
>
<CheckIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
{showWantsBadge && (
<ItemOwnsWantsBadge
colorScheme="blue"
label={
showTradeMatchFlair
? "You want this, and they own it!"
: "You want this"
}
>
<StarIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
</div>
{item.isNc != null && (
<div
className={css`
position: absolute;
bottom: -6px;
right: -3px;
`}
>
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
</ItemThumbnailKindBadge>
</div>
)}
</div>
)}
</ClassNames>
);
/* Don't let alt text flash in while loading */
&:-moz-loading {
visibility: hidden;
}
`}
loading="lazy"
/>
<div
className={css`
position: absolute;
top: -6px;
left: -6px;
display: flex;
flex-direction: column;
gap: 2px;
`}
>
{showOwnsBadge && (
<ItemOwnsWantsBadge
colorScheme="green"
label={
showTradeMatchFlair
? "You own this, and they want it!"
: "You own this"
}
>
<CheckIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
{showWantsBadge && (
<ItemOwnsWantsBadge
colorScheme="blue"
label={
showTradeMatchFlair
? "You want this, and they own it!"
: "You want this"
}
>
<StarIcon />
{showTradeMatchFlair && (
<div
className={css`
margin-left: 0.25em;
margin-right: 0.125rem;
`}
>
Match
</div>
)}
</ItemOwnsWantsBadge>
)}
</div>
{item.isNc != null && (
<div
className={css`
position: absolute;
bottom: -6px;
right: -3px;
`}
>
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
</ItemThumbnailKindBadge>
</div>
)}
</div>
)}
</ClassNames>
);
}
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 (
<ClassNames>
{({ css }) => (
<div
aria-label={label}
title={label}
className={css`
border-radius: 999px;
height: 16px;
min-width: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 2px ${badgeBackgroundValue};
/* Decrease the padding: I don't want to hit the edges, but I want
return (
<ClassNames>
{({ css }) => (
<div
aria-label={label}
title={label}
className={css`
border-radius: 999px;
height: 16px;
min-width: 16px;
font-size: 14px;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 2px ${badgeBackgroundValue};
/* Decrease the padding: I don't want to hit the edges, but I want
* to be a circle when possible! */
padding-left: 0.125rem;
padding-right: 0.125rem;
/* Copied from Chakra <Badge> */
white-space: nowrap;
vertical-align: middle;
text-transform: uppercase;
font-size: 0.65rem;
font-weight: 700;
background: ${badgeBackgroundValue};
color: ${badgeColorValue};
`}
>
{children}
</div>
)}
</ClassNames>
);
padding-left: 0.125rem;
padding-right: 0.125rem;
/* Copied from Chakra <Badge> */
white-space: nowrap;
vertical-align: middle;
text-transform: uppercase;
font-size: 0.65rem;
font-weight: 700;
background: ${badgeBackgroundValue};
color: ${badgeColorValue};
`}
>
{children}
</div>
)}
</ClassNames>
);
}
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 (
<ClassNames>
{({ css }) => (
<div
className={css`
/* Copied from Chakra <Badge> */
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}
</div>
)}
</ClassNames>
);
return (
<ClassNames>
{({ css }) => (
<div
className={css`
/* Copied from Chakra <Badge> */
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}
</div>
)}
</ClassNames>
);
}
function SquareItemCardRemoveButton({ onClick }) {
const backgroundColor = useColorModeValue("gray.200", "gray.500");
const backgroundColor = useColorModeValue("gray.200", "gray.500");
return (
<IconButton
aria-label="Remove"
title="Remove"
icon={<CloseIcon />}
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 (
<IconButton
aria-label="Remove"
title="Remove"
icon={<CloseIcon />}
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 (
<SquareItemCardLayout
name={
<>
<Skeleton width="100%" height="1em" marginTop="2" />
{minHeightNumLines >= 3 && (
<Skeleton width="100%" height="1em" marginTop="2" />
)}
</>
}
thumbnailImage={<Skeleton width="80px" height="80px" />}
minHeightNumLines={minHeightNumLines}
footer={footer}
/>
);
return (
<SquareItemCardLayout
name={
<>
<Skeleton width="100%" height="1em" marginTop="2" />
{minHeightNumLines >= 3 && (
<Skeleton width="100%" height="1em" marginTop="2" />
)}
</>
}
thumbnailImage={<Skeleton width="80px" height="80px" />}
minHeightNumLines={minHeightNumLines}
footer={footer}
/>
);
}
export default SquareItemCard;

View file

@ -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;

View file

@ -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;

View file

@ -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}
`;

View file

@ -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;

View file

@ -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() {

View file

@ -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(

View file

@ -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,

View file

@ -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 (
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
{children}
</Box>
);
return (
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
{children}
</Box>
);
}
/**
@ -47,17 +47,17 @@ export function Delay({ children, ms = 300 }) {
* font and some special typographical styles!
*/
export function Heading1({ children, ...props }) {
return (
<Heading
as="h1"
size="2xl"
fontFamily="Delicious, sans-serif"
fontWeight="800"
{...props}
>
{children}
</Heading>
);
return (
<Heading
as="h1"
size="2xl"
fontFamily="Delicious, sans-serif"
fontWeight="800"
{...props}
>
{children}
</Heading>
);
}
/**
@ -65,17 +65,17 @@ export function Heading1({ children, ...props }) {
* special typographical styles!!
*/
export function Heading2({ children, ...props }) {
return (
<Heading
as="h2"
size="xl"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
return (
<Heading
as="h2"
size="xl"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
}
/**
@ -83,111 +83,111 @@ export function Heading2({ children, ...props }) {
* special typographical styles!!
*/
export function Heading3({ children, ...props }) {
return (
<Heading
as="h3"
size="lg"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
return (
<Heading
as="h3"
size="lg"
fontFamily="Delicious, sans-serif"
fontWeight="700"
{...props}
>
{children}
</Heading>
);
}
/**
* ErrorMessage is a simple error message for simple errors!
*/
export function ErrorMessage({ children, ...props }) {
return (
<Box color="red.400" {...props}>
{children}
</Box>
);
return (
<Box color="red.400" {...props}>
{children}
</Box>
);
}
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 (
<Flex justify="center" marginTop="8">
<Grid
templateAreas='"icon title" "icon description" "icon details"'
templateColumns="auto minmax(0, 1fr)"
maxWidth="500px"
marginX="8"
columnGap="4"
>
<Box gridArea="icon" marginTop="2">
<Box
borderRadius="full"
boxShadow="md"
overflow="hidden"
width="100px"
height="100px"
>
<img
src={ErrorGrundoImg}
srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
alt="Distressed Grundo programmer"
width={100}
height={100}
/>
</Box>
</Box>
<Box gridArea="title" fontSize="lg" marginBottom="1">
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
{variant === "not-found" && <>Oops, page not found 😖</>}
</Box>
<Box gridArea="description" marginBottom="2">
{variant === "unexpected" && (
<>
There was an error displaying this page. I'll get info about it
automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "network" && (
<>
There was an error displaying this page. Check your internet
connection and try againand if you keep having trouble, please
tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "not-found" && (
<>
We couldn't find this page. Maybe it's been deleted? Check the URL
and try againand if you keep having trouble, please tell me more
at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
</Box>
{error && (
<Box gridArea="details" fontSize="xs" opacity="0.8">
<WarningIcon
marginRight="1.5"
marginTop="-2px"
aria-label="Error message"
/>
"{getGraphQLErrorMessage(error)}"
</Box>
)}
</Grid>
</Flex>
);
return (
<Flex justify="center" marginTop="8">
<Grid
templateAreas='"icon title" "icon description" "icon details"'
templateColumns="auto minmax(0, 1fr)"
maxWidth="500px"
marginX="8"
columnGap="4"
>
<Box gridArea="icon" marginTop="2">
<Box
borderRadius="full"
boxShadow="md"
overflow="hidden"
width="100px"
height="100px"
>
<img
src={ErrorGrundoImg}
srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
alt="Distressed Grundo programmer"
width={100}
height={100}
/>
</Box>
</Box>
<Box gridArea="title" fontSize="lg" marginBottom="1">
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
{variant === "not-found" && <>Oops, page not found 😖</>}
</Box>
<Box gridArea="description" marginBottom="2">
{variant === "unexpected" && (
<>
There was an error displaying this page. I'll get info about it
automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "network" && (
<>
There was an error displaying this page. Check your internet
connection and try againand if you keep having trouble, please
tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
{variant === "not-found" && (
<>
We couldn't find this page. Maybe it's been deleted? Check the URL
and try againand if you keep having trouble, please tell me more
at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net
</Link>
!
</>
)}
</Box>
{error && (
<Box gridArea="details" fontSize="xs" opacity="0.8">
<WarningIcon
marginRight="1.5"
marginTop="-2px"
aria-label="Error message"
/>
"{getGraphQLErrorMessage(error)}"
</Box>
)}
</Grid>
</Flex>
);
}
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;
}

View file

@ -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"
}