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 () { (function () {
var CSRFProtection; var CSRFProtection;
var token = $('meta[name="csrf-token"]').attr("content"); var token = $('meta[name="csrf-token"]').attr("content");
if (token) { if (token) {
CSRFProtection = function (xhr, settings) { CSRFProtection = function (xhr, settings) {
var sendToken = var sendToken =
typeof settings.useCSRFProtection === "undefined" || // default to true typeof settings.useCSRFProtection === "undefined" || // default to true
settings.useCSRFProtection; settings.useCSRFProtection;
if (sendToken) { if (sendToken) {
xhr.setRequestHeader("X-CSRF-Token", token); xhr.setRequestHeader("X-CSRF-Token", token);
} }
}; };
} else { } else {
CSRFProtection = $.noop; CSRFProtection = $.noop;
} }
$.ajaxSetup({ $.ajaxSetup({
beforeSend: CSRFProtection, beforeSend: CSRFProtection,
}); });
})(); })();

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,8 @@
(function () { (function () {
function setChecked() { function setChecked() {
var el = $(this); var el = $(this);
el.closest("li").toggleClass("checked", el.is(":checked")); 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 }) => { document.addEventListener("change", ({ target }) => {
if (target.matches('select[name="closet_list[visibility]"]')) { if (target.matches('select[name="closet_list[visibility]"]')) {
target target.closest("form").setAttribute("data-list-visibility", target.value);
.closest("form")
.setAttribute("data-list-visibility", target.value);
} }
}); });

View file

@ -1,6 +1,6 @@
(function() { (function () {
$('span.choose-outfit select').change(function(e) { $("span.choose-outfit select").change(function (e) {
var select = $(this); var select = $(this);
select.closest('li').find('input[type=text]').val(select.val()); 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. // When the species face picker changes, update and submit the main picker form.
document.addEventListener("change", (e) => { document.addEventListener("change", (e) => {
if (!e.target.matches("species-face-picker")) return; if (!e.target.matches("species-face-picker")) return;
try { try {
const mainPickerForm = document.querySelector( const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form", "#item-preview species-color-picker form",
); );
const mainSpeciesField = mainPickerForm.querySelector( const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']", "[name='preview[species_id]']",
); );
mainSpeciesField.value = e.target.value; mainSpeciesField.value = e.target.value;
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo! mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
} catch (error) { } catch (error) {
console.error("Couldn't update species picker: ", error); console.error("Couldn't update species picker: ", error);
} }
}); });
// If the preview frame fails to load, try a full pageload. // If the preview frame fails to load, try a full pageload.
document.addEventListener("turbo:frame-missing", (e) => { 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.detail.visit(e.detail.response.url);
e.preventDefault(); e.preventDefault();
}); });
class SpeciesColorPicker extends HTMLElement { class SpeciesColorPicker extends HTMLElement {
#internals; #internals;
constructor() { constructor() {
super(); super();
this.#internals = this.attachInternals(); this.#internals = this.attachInternals();
} }
connectedCallback() { connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it! // Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange); this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading"); this.#internals.states.add("auto-loading");
} }
#handleChange(e) { #handleChange(e) {
this.querySelector("form").requestSubmit(); this.querySelector("form").requestSubmit();
} }
} }
class SpeciesFacePicker extends HTMLElement { class SpeciesFacePicker extends HTMLElement {
connectedCallback() { connectedCallback() {
this.addEventListener("click", this.#handleClick); this.addEventListener("click", this.#handleClick);
} }
get value() { get value() {
return this.querySelector("input[type=radio]:checked")?.value; return this.querySelector("input[type=radio]:checked")?.value;
} }
#handleClick(e) { #handleClick(e) {
if (e.target.matches("input[type=radio]")) { if (e.target.matches("input[type=radio]")) {
this.dispatchEvent(new Event("change", { bubbles: true })); this.dispatchEvent(new Event("change", { bubbles: true }));
} }
} }
} }
class SpeciesFacePickerOptions extends HTMLElement { class SpeciesFacePickerOptions extends HTMLElement {
static observedAttributes = ["inert", "aria-hidden"]; static observedAttributes = ["inert", "aria-hidden"];
connectedCallback() { connectedCallback() {
// Once this component is loaded, we stop being inert and aria-hidden. We're ready! // Once this component is loaded, we stop being inert and aria-hidden. We're ready!
this.#activate(); this.#activate();
} }
attributeChangedCallback() { attributeChangedCallback() {
// If a Turbo Frame tries to morph us into being inert again, activate again! // 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 // (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 // 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!) // is preserved. To thread that needle, we have to monitor and remove!)
this.#activate(); this.#activate();
} }
#activate() { #activate() {
this.removeAttribute("inert"); this.removeAttribute("inert");
this.removeAttribute("aria-hidden"); this.removeAttribute("aria-hidden");
} }
} }
class MeasuredContent extends HTMLElement { class MeasuredContent extends HTMLElement {
connectedCallback() { connectedCallback() {
setTimeout(() => this.#measure(), 0); setTimeout(() => this.#measure(), 0);
} }
#measure() { #measure() {
// Find our `<measured-container>` parent, and set our natural width // Find our `<measured-container>` parent, and set our natural width
// as `var(--natural-width)` in the context of its CSS styles. // as `var(--natural-width)` in the context of its CSS styles.
const container = this.closest("measured-container"); const container = this.closest("measured-container");
if (container == null) { if (container == null) {
throw new Error( throw new Error(`<measured-content> must be in a <measured-container>`);
`<measured-content> must be in a <measured-container>`, }
); container.style.setProperty("--natural-width", this.offsetWidth + "px");
} }
container.style.setProperty("--natural-width", this.offsetWidth + "px");
}
} }
customElements.define("species-color-picker", SpeciesColorPicker); customElements.define("species-color-picker", SpeciesColorPicker);

View file

@ -108,9 +108,7 @@ class OutfitLayer extends HTMLElement {
this.#setStatus("loading"); this.#setStatus("loading");
this.#sendMessageToIframe({ type: "requestStatus" }); this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m)); window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.iframe.addEventListener("error", () => this.#setStatus("error"));
this.#setStatus("error"),
);
} else { } else {
console.warn(`<outfit-layer> contained no image or iframe: `, this); console.warn(`<outfit-layer> contained no image or iframe: `, this);
} }
@ -137,8 +135,7 @@ class OutfitLayer extends HTMLElement {
} }
} else { } else {
throw new Error( throw new Error(
`<outfit-layer> got unexpected message: ` + `<outfit-layer> got unexpected message: ` + JSON.stringify(data),
JSON.stringify(data),
); );
} }
} }

View file

@ -1,272 +1,272 @@
(function () { (function () {
function petImage(id, size) { function petImage(id, size) {
return "https://pets.neopets.com/" + id + "/1/" + size + ".png"; return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
} }
var PetQuery = {}, var PetQuery = {},
query_string = document.location.hash || document.location.search; query_string = document.location.hash || document.location.search;
$.each(query_string.substr(1).split("&"), function () { $.each(query_string.substr(1).split("&"), function () {
var split_piece = this.split("="); var split_piece = this.split("=");
if (split_piece.length == 2) { if (split_piece.length == 2) {
PetQuery[split_piece[0]] = split_piece[1]; PetQuery[split_piece[0]] = split_piece[1];
} }
}); });
if (PetQuery.name) { if (PetQuery.name) {
if (PetQuery.species && PetQuery.color) { if (PetQuery.species && PetQuery.color) {
$("#pet-query-notice-template") $("#pet-query-notice-template")
.tmpl({ .tmpl({
pet_name: PetQuery.name, pet_name: PetQuery.name,
pet_image_url: petImage("cpn/" + PetQuery.name, 1), pet_image_url: petImage("cpn/" + PetQuery.name, 1),
}) })
.prependTo("#container"); .prependTo("#container");
} }
} }
var preview_el = $("#pet-preview"), var preview_el = $("#pet-preview"),
img_el = preview_el.find("img"), img_el = preview_el.find("img"),
response_el = preview_el.find("span"); response_el = preview_el.find("span");
var defaultPreviewUrl = img_el.attr("src"); var defaultPreviewUrl = img_el.attr("src");
preview_el.click(function () { preview_el.click(function () {
Preview.Job.current.visit(); Preview.Job.current.visit();
}); });
var Preview = { var Preview = {
clear: function () { clear: function () {
if (typeof Preview.Job.fallback != "undefined") if (typeof Preview.Job.fallback != "undefined")
Preview.Job.fallback.setAsCurrent(); Preview.Job.fallback.setAsCurrent();
}, },
displayLoading: function () { displayLoading: function () {
preview_el.addClass("loading"); preview_el.addClass("loading");
response_el.text("Loading..."); response_el.text("Loading...");
}, },
failed: function () { failed: function () {
preview_el.addClass("hidden"); preview_el.addClass("hidden");
}, },
notFound: function (key, options) { notFound: function (key, options) {
Preview.failed(); Preview.failed();
response_el.empty(); response_el.empty();
$("#preview-" + key + "-template") $("#preview-" + key + "-template")
.tmpl(options) .tmpl(options)
.appendTo(response_el); .appendTo(response_el);
}, },
updateWithName: function (name_el) { updateWithName: function (name_el) {
var name = name_el.val(), var name = name_el.val(),
job; job;
if (name) { if (name) {
currentName = name; currentName = name;
if (!Preview.Job.current || name != Preview.Job.current.name) { if (!Preview.Job.current || name != Preview.Job.current.name) {
job = new Preview.Job.Name(name); job = new Preview.Job.Name(name);
job.setAsCurrent(); job.setAsCurrent();
Preview.displayLoading(); Preview.displayLoading();
} }
} else { } else {
Preview.clear(); Preview.clear();
} }
}, },
}; };
function loadNotable() { function loadNotable() {
// TODO: add HTTPS to notables // TODO: add HTTPS to notables
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) { // $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
// var notables = response.notables; // var notables = response.notables;
// var i = Math.floor(Math.random() * notables.length); // var i = Math.floor(Math.random() * notables.length);
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName); // Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
// if(!Preview.Job.current) { // if(!Preview.Job.current) {
// Preview.Job.fallback.setAsCurrent(); // Preview.Job.fallback.setAsCurrent();
// } // }
// }); // });
if (!Preview.Job.current) { if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent(); Preview.Job.fallback.setAsCurrent();
} }
} }
function loadFeature() { function loadFeature() {
$.getJSON("/donations/features", function (features) { $.getJSON("/donations/features", function (features) {
if (features.length > 0) { if (features.length > 0) {
var feature = features[Math.floor(Math.random() * features.length)]; var feature = features[Math.floor(Math.random() * features.length)];
Preview.Job.fallback = new Preview.Job.Feature(feature); Preview.Job.fallback = new Preview.Job.Feature(feature);
if (!Preview.Job.current) { if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent(); Preview.Job.fallback.setAsCurrent();
} }
} else { } else {
loadNotable(); loadNotable();
} }
}); });
} }
loadFeature(); loadFeature();
Preview.Job = function (key, base) { Preview.Job = function (key, base) {
var job = this, var job = this,
quality = 2; quality = 2;
job.loading = false; job.loading = false;
function getImageSrc() { function getImageSrc() {
if (key.substr(0, 3) === "a:-") { if (key.substr(0, 3) === "a:-") {
// lol lazy code for prank image :P // lol lazy code for prank image :P
// TODO: HTTPS? // TODO: HTTPS?
return ( return (
"https://swfimages.impress.openneo.net" + "https://swfimages.impress.openneo.net" +
"/biology/000/000/0-2/" + "/biology/000/000/0-2/" +
key.substr(2) + key.substr(2) +
"/300x300.png" "/300x300.png"
); );
} else if (base === "cp" || base === "cpn") { } else if (base === "cp" || base === "cpn") {
return petImage(base + "/" + key, quality); return petImage(base + "/" + key, quality);
} else if (base === "url") { } else if (base === "url") {
return key; return key;
} else { } else {
throw new Error("unrecognized image base " + base); throw new Error("unrecognized image base " + base);
} }
} }
function load() { function load() {
job.loading = true; job.loading = true;
img_el.attr("src", getImageSrc()); img_el.attr("src", getImageSrc());
} }
this.increaseQualityIfPossible = function () { this.increaseQualityIfPossible = function () {
if (quality == 2) { if (quality == 2) {
quality = 4; quality = 4;
load(); load();
} }
}; };
this.setAsCurrent = function () { this.setAsCurrent = function () {
Preview.Job.current = job; Preview.Job.current = job;
load(); load();
}; };
this.notFound = function () { this.notFound = function () {
Preview.notFound("pet-not-found"); Preview.notFound("pet-not-found");
}; };
}; };
Preview.Job.Name = function (name) { Preview.Job.Name = function (name) {
this.name = name; this.name = name;
Preview.Job.apply(this, [name, "cpn"]); Preview.Job.apply(this, [name, "cpn"]);
this.visit = function () { this.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit(); $(".main-pet-name").val(this.name).closest("form").submit();
}; };
}; };
Preview.Job.Hash = function (hash, form) { Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]); Preview.Job.apply(this, [hash, "cp"]);
this.visit = function () { this.visit = function () {
window.location = window.location =
"/wardrobe?color=" + "/wardrobe?color=" +
form.find(".color").val() + form.find(".color").val() +
"&species=" + "&species=" +
form.find(".species").val(); form.find(".species").val();
}; };
}; };
Preview.Job.Feature = function (feature) { Preview.Job.Feature = function (feature) {
Preview.Job.apply(this, [feature.outfit_image_url, "url"]); Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
this.visit = function () { this.visit = function () {
window.location = "/donate"; window.location = "/donate";
}; };
this.notFound = function () { this.notFound = function () {
// The outfit thumbnail hasn't generated or is missing or something. // The outfit thumbnail hasn't generated or is missing or something.
// Let's fall back to a boring image for now. // Let's fall back to a boring image for now.
var boring = new Preview.Job.Feature({ var boring = new Preview.Job.Feature({
donor_name: feature.donor_name, donor_name: feature.donor_name,
outfit_image_url: defaultPreviewUrl, outfit_image_url: defaultPreviewUrl,
}); });
boring.setAsCurrent(); boring.setAsCurrent();
}; };
}; };
$(function () { $(function () {
var previewWithNameTimeout; var previewWithNameTimeout;
var name_el = $(".main-pet-name"); var name_el = $(".main-pet-name");
name_el.val(PetQuery.name); name_el.val(PetQuery.name);
Preview.updateWithName(name_el); Preview.updateWithName(name_el);
name_el.keyup(function () { name_el.keyup(function () {
if (previewWithNameTimeout && Preview.Job.current) { if (previewWithNameTimeout && Preview.Job.current) {
clearTimeout(previewWithNameTimeout); clearTimeout(previewWithNameTimeout);
Preview.Job.current.loading = false; Preview.Job.current.loading = false;
} }
var name_el = $(this); var name_el = $(this);
previewWithNameTimeout = setTimeout(function () { previewWithNameTimeout = setTimeout(function () {
Preview.updateWithName(name_el); Preview.updateWithName(name_el);
}, 250); }, 250);
}); });
img_el img_el
.load(function () { .load(function () {
if (Preview.Job.current.loading) { if (Preview.Job.current.loading) {
Preview.Job.loading = false; Preview.Job.loading = false;
Preview.Job.current.increaseQualityIfPossible(); Preview.Job.current.increaseQualityIfPossible();
preview_el preview_el
.removeClass("loading") .removeClass("loading")
.removeClass("hidden") .removeClass("hidden")
.addClass("loaded"); .addClass("loaded");
response_el.text(Preview.Job.current.name); response_el.text(Preview.Job.current.name);
} }
}) })
.error(function () { .error(function () {
if (Preview.Job.current.loading) { if (Preview.Job.current.loading) {
Preview.Job.loading = false; Preview.Job.loading = false;
Preview.Job.current.notFound(); Preview.Job.current.notFound();
} }
}); });
$(".species, .color").change(function () { $(".species, .color").change(function () {
var type = {}, var type = {},
nameComponents = {}; nameComponents = {};
var form = $(this).closest("form"); var form = $(this).closest("form");
form.find("select").each(function () { form.find("select").each(function () {
var el = $(this), var el = $(this),
selectedEl = el.children(":selected"), selectedEl = el.children(":selected"),
key = el.attr("name"); key = el.attr("name");
type[key] = selectedEl.val(); type[key] = selectedEl.val();
nameComponents[key] = selectedEl.text(); nameComponents[key] = selectedEl.text();
}); });
name = nameComponents.color + " " + nameComponents.species; name = nameComponents.color + " " + nameComponents.species;
Preview.displayLoading(); Preview.displayLoading();
$.ajax({ $.ajax({
url: url:
"/species/" + "/species/" +
type.species + type.species +
"/colors/" + "/colors/" +
type.color + type.color +
"/pet_type.json", "/pet_type.json",
dataType: "json", dataType: "json",
success: function (data) { success: function (data) {
var job; var job;
if (data) { if (data) {
job = new Preview.Job.Hash(data.image_hash, form); job = new Preview.Job.Hash(data.image_hash, form);
job.name = name; job.name = name;
job.setAsCurrent(); job.setAsCurrent();
} else { } else {
Preview.notFound("pet-type-not-found", { Preview.notFound("pet-type-not-found", {
color_name: nameComponents.color, color_name: nameComponents.color,
species_name: nameComponents.species, species_name: nameComponents.species,
}); });
} }
}, },
}); });
}); });
$(".load-pet-to-wardrobe").submit(function (e) { $(".load-pet-to-wardrobe").submit(function (e) {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) { if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
e.preventDefault(); e.preventDefault();
Preview.Job.current.visit(); 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"; var DEBUG = document.location.search.substr(0, 6) == "?debug";
function petThumbnailUrl(pet_name) { function petThumbnailUrl(pet_name) {
// if first character is "@", use the hash url // if first character is "@", use the hash url
if (pet_name[0] == "@") { if (pet_name[0] == "@") {
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png"; 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 */ /* Needed items form */
(function () { (function () {
var UI = {}; var UI = {};
UI.form = $("#needed-items-form"); UI.form = $("#needed-items-form");
UI.alert = $("#needed-items-alert"); UI.alert = $("#needed-items-alert");
UI.pet_name_field = $("#needed-items-pet-name-field"); UI.pet_name_field = $("#needed-items-pet-name-field");
UI.pet_thumbnail = $("#needed-items-pet-thumbnail"); UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
UI.pet_header = $("#needed-items-pet-header"); UI.pet_header = $("#needed-items-pet-header");
UI.reload = $("#needed-items-reload"); UI.reload = $("#needed-items-reload");
UI.pet_items = $("#needed-items-pet-items"); UI.pet_items = $("#needed-items-pet-items");
UI.item_template = $("#item-template"); UI.item_template = $("#item-template");
var current_request = { abort: function () {} }; var current_request = { abort: function () {} };
function sendRequest(options) { function sendRequest(options) {
current_request = $.ajax(options); current_request = $.ajax(options);
} }
function cancelRequest() { function cancelRequest() {
if (DEBUG) console.log("Canceling request", current_request); if (DEBUG) console.log("Canceling request", current_request);
current_request.abort(); current_request.abort();
} }
/* Pet */ /* Pet */
var last_successful_pet_name = null; var last_successful_pet_name = null;
function loadPet(pet_name) { function loadPet(pet_name) {
// If there is a request in progress, kill it. Our new pet request takes // 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 // priority, and, if I submit a name while the previous name is loading, I
// don't want to process both responses. // don't want to process both responses.
cancelRequest(); cancelRequest();
sendRequest({ sendRequest({
url: UI.form.attr("action") + ".json", url: UI.form.attr("action") + ".json",
dataType: "json", dataType: "json",
data: { name: pet_name }, data: { name: pet_name },
error: petError, error: petError,
success: function (data) { success: function (data) {
petSuccess(data, pet_name); petSuccess(data, pet_name);
}, },
complete: petComplete, complete: petComplete,
}); });
UI.form.removeClass("failed").addClass("loading-pet"); UI.form.removeClass("failed").addClass("loading-pet");
} }
function petComplete() { function petComplete() {
UI.form.removeClass("loading-pet"); UI.form.removeClass("loading-pet");
} }
function petError(xhr) { function petError(xhr) {
UI.alert.text(xhr.responseText); UI.alert.text(xhr.responseText);
UI.form.addClass("failed"); UI.form.addClass("failed");
} }
function petSuccess(data, pet_name) { function petSuccess(data, pet_name) {
last_successful_pet_name = pet_name; last_successful_pet_name = pet_name;
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name)); UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
UI.pet_header.empty(); UI.pet_header.empty();
$("#needed-items-pet-header-template") $("#needed-items-pet-header-template")
.tmpl({ pet_name: pet_name }) .tmpl({ pet_name: pet_name })
.appendTo(UI.pet_header); .appendTo(UI.pet_header);
loadItems(data.query); loadItems(data.query);
} }
/* Items */ /* Items */
function loadItems(query) { function loadItems(query) {
UI.form.addClass("loading-items"); UI.form.addClass("loading-items");
sendRequest({ sendRequest({
url: "/items/needed.json", url: "/items/needed.json",
dataType: "json", dataType: "json",
data: query, data: query,
success: itemsSuccess, success: itemsSuccess,
}); });
} }
function itemsSuccess(items) { function itemsSuccess(items) {
if (DEBUG) { if (DEBUG) {
// The dev server is missing lots of data, so sends me 2000+ needed // 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 // items. We don't need that many for styling, so limit it to 100 to make
// my browser happier. // my browser happier.
items = items.slice(0, 100); items = items.slice(0, 100);
} }
UI.pet_items.empty(); UI.pet_items.empty();
UI.item_template.tmpl(items).appendTo(UI.pet_items); 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) { UI.form.submit(function (e) {
e.preventDefault(); e.preventDefault();
loadPet(UI.pet_name_field.val()); loadPet(UI.pet_name_field.val());
}); });
UI.reload.click(function (e) { UI.reload.click(function (e) {
e.preventDefault(); e.preventDefault();
loadPet(last_successful_pet_name); loadPet(last_successful_pet_name);
}); });
})(); })();
/* Bulk pets form */ /* Bulk pets form */
(function () { (function () {
var form = $("#bulk-pets-form"), var form = $("#bulk-pets-form"),
queue_el = form.find("ul"), queue_el = form.find("ul"),
names_el = form.find("textarea"), names_el = form.find("textarea"),
add_el = $("#bulk-pets-form-add"), add_el = $("#bulk-pets-form-add"),
clear_el = $("#bulk-pets-form-clear"), clear_el = $("#bulk-pets-form-clear"),
bulk_load_queue; bulk_load_queue;
$(document.body).addClass("js"); $(document.body).addClass("js");
bulk_load_queue = new (function BulkLoadQueue() { bulk_load_queue = new (function BulkLoadQueue() {
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30; var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
var RECENTLY_SENT_MAX = 3; var RECENTLY_SENT_MAX = 3;
var pets = [], var pets = [],
url = form.attr("action") + ".json", url = form.attr("action") + ".json",
recently_sent_count = 0, recently_sent_count = 0,
loading = false; loading = false;
function Pet(name) { function Pet(name) {
var el = $("#bulk-pets-submission-template") var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) }) .tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el); .appendTo(queue_el);
this.load = function () { this.load = function () {
el.removeClass("waiting").addClass("loading"); el.removeClass("waiting").addClass("loading");
var response_el = el.find("span.response"); var response_el = el.find("span.response");
pets.shift(); pets.shift();
loading = true; loading = true;
$.ajax({ $.ajax({
complete: function (data) { complete: function (data) {
loading = false; loading = false;
loadNextIfReady(); loadNextIfReady();
}, },
data: { name: name }, data: { name: name },
dataType: "json", dataType: "json",
error: function (xhr) { error: function (xhr) {
el.removeClass("loading").addClass("failed"); el.removeClass("loading").addClass("failed");
response_el.text(xhr.responseText); response_el.text(xhr.responseText);
}, },
success: function (data) { success: function (data) {
var points = data.points; var points = data.points;
el.removeClass("loading").addClass("loaded"); el.removeClass("loading").addClass("loaded");
$("#bulk-pets-submission-success-template") $("#bulk-pets-submission-success-template")
.tmpl({ points: points }) .tmpl({ points: points })
.appendTo(response_el); .appendTo(response_el);
}, },
type: "post", type: "post",
url: url, url: url,
}); });
recently_sent_count++; recently_sent_count++;
setTimeout(function () { setTimeout(function () {
recently_sent_count--; recently_sent_count--;
loadNextIfReady(); loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000); }, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
}; };
} }
this.add = function (name) { this.add = function (name) {
name = name.replace(/^\s+|\s+$/g, ""); name = name.replace(/^\s+|\s+$/g, "");
if (name.length) { if (name.length) {
var pet = new Pet(name); var pet = new Pet(name);
pets.push(pet); pets.push(pet);
if (pets.length == 1) loadNextIfReady(); if (pets.length == 1) loadNextIfReady();
} }
}; };
function loadNextIfReady() { function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) { if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load(); pets[0].load();
} }
} }
})(); })();
names_el.keyup(function () { names_el.keyup(function () {
var names = this.value.split("\n"), var names = this.value.split("\n"),
x = names.length - 1, x = names.length - 1,
i, i,
name; name;
for (i = 0; i < x; i++) { for (i = 0; i < x; i++) {
bulk_load_queue.add(names[i]); bulk_load_queue.add(names[i]);
} }
this.value = x >= 0 ? names[x] : ""; this.value = x >= 0 ? names[x] : "";
}); });
add_el.click(function () { add_el.click(function () {
bulk_load_queue.add(names_el.val()); bulk_load_queue.add(names_el.val());
names_el.val(""); names_el.val("");
}); });
clear_el.click(function () { clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove(); queue_el.children("li.loaded, li.failed").remove();
}); });
})(); })();

View file

@ -1,5 +1,5 @@
import "@hotwired/turbo-rails"; import "@hotwired/turbo-rails";
document.getElementById("locale").addEventListener("change", function () { 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! // TODO: Use the new React 18 APIs instead!
// eslint-disable-next-line react/no-deprecated // eslint-disable-next-line react/no-deprecated
ReactDOM.render( ReactDOM.render(
<AppProvider> <AppProvider>
<WardrobePage /> <WardrobePage />
</AppProvider>, </AppProvider>,
rootNode, rootNode,
); );

View file

@ -2,12 +2,12 @@ import React from "react";
import * as Sentry from "@sentry/react"; import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing"; import { Integrations } from "@sentry/tracing";
import { import {
ChakraProvider, ChakraProvider,
Box, Box,
css as resolveCSS, css as resolveCSS,
extendTheme, extendTheme,
useColorMode, useColorMode,
useTheme, useTheme,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { mode } from "@chakra-ui/theme-tools"; import { mode } from "@chakra-ui/theme-tools";
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
@ -20,15 +20,15 @@ import apolloClient from "./apolloClient";
const reactQueryClient = new QueryClient(); const reactQueryClient = new QueryClient();
let theme = extendTheme({ let theme = extendTheme({
styles: { styles: {
global: (props) => ({ global: (props) => ({
body: { body: {
background: mode("gray.50", "gray.800")(props), background: mode("gray.50", "gray.800")(props),
color: mode("green.800", "green.50")(props), color: mode("green.800", "green.50")(props),
transition: "all 0.25s", transition: "all 0.25s",
}, },
}), }),
}, },
}); });
// Capture the global styles function from our theme, but remove it from the // 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 = {}; theme.styles.global = {};
export default function AppProvider({ children }) { export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []); React.useEffect(() => setupLogging(), []);
return ( return (
<BrowserRouter> <BrowserRouter>
<QueryClientProvider client={reactQueryClient}> <QueryClientProvider client={reactQueryClient}>
<ApolloProvider client={apolloClient}> <ApolloProvider client={apolloClient}>
<ChakraProvider resetCSS={false} theme={theme}> <ChakraProvider resetCSS={false} theme={theme}>
<ScopedCSSReset>{children}</ScopedCSSReset> <ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider> </ChakraProvider>
</ApolloProvider> </ApolloProvider>
</QueryClientProvider> </QueryClientProvider>
</BrowserRouter> </BrowserRouter>
); );
} }
function setupLogging() { function setupLogging() {
Sentry.init({ Sentry.init({
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379", dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
autoSessionTracking: true, autoSessionTracking: true,
integrations: [ integrations: [
new Integrations.BrowserTracing({ new Integrations.BrowserTracing({
beforeNavigate: (context) => ({ beforeNavigate: (context) => ({
...context, ...context,
// Assume any path segment starting with a digit is an ID, and replace // 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. // 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 // NOTE: I'm a bit uncertain about the timing on this for tracking
// client-side navs... but we now only track first-time // client-side navs... but we now only track first-time
// pageloads, and it definitely works correctly for them! // pageloads, and it definitely works correctly for them!
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"), name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
}), }),
// We have a _lot_ of location changes that don't actually signify useful // 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 // 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 // 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 // features besides Web Vitals right now, and those only get tracked on
// first-time pageloads, anyway. So, don't track client-side navs! // first-time pageloads, anyway. So, don't track client-side navs!
startTransactionOnLocationChange: false, startTransactionOnLocationChange: false,
}), }),
], ],
denyUrls: [ denyUrls: [
// Don't log errors that were probably triggered by extensions and not by // Don't log errors that were probably triggered by extensions and not by
// our own app. (Apparently Sentry's setting to ignore browser extension // our own app. (Apparently Sentry's setting to ignore browser extension
// errors doesn't do this anywhere near as consistently as I'd expect?) // errors doesn't do this anywhere near as consistently as I'd expect?)
// //
// Adapted from https://gist.github.com/impressiver/5092952, as linked in // Adapted from https://gist.github.com/impressiver/5092952, as linked in
// https://docs.sentry.io/platforms/javascript/configuration/filtering/. // https://docs.sentry.io/platforms/javascript/configuration/filtering/.
/^chrome-extension:\/\//, /^chrome-extension:\/\//,
/^moz-extension:\/\//, /^moz-extension:\/\//,
], ],
// Since we're only tracking first-page loads and not navigations, 100% // 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. // sampling isn't actually so much! Tune down if it becomes a problem, tho.
tracesSampleRate: 1.0, tracesSampleRate: 1.0,
}); });
} }
/** /**
@ -112,308 +112,308 @@ function setupLogging() {
* the selector `:where(.chakra-css-reset) h1` is lower specificity. * the selector `:where(.chakra-css-reset) h1` is lower specificity.
*/ */
function ScopedCSSReset({ children }) { function ScopedCSSReset({ children }) {
// Get the current theme and color mode. // Get the current theme and color mode.
// //
// NOTE: The theme object returned by `useTheme` has some extensions that are // 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 // 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`. // returned by `extendTheme`! That's why we use this here instead of `theme`.
const liveTheme = useTheme(); const liveTheme = useTheme();
const colorMode = useColorMode(); const colorMode = useColorMode();
// Resolve the theme's global styles into CSS objects for Emotion. // Resolve the theme's global styles into CSS objects for Emotion.
const globalStylesCSS = resolveCSS( const globalStylesCSS = resolveCSS(
globalStyles({ theme: liveTheme, colorMode }), globalStyles({ theme: liveTheme, colorMode }),
)(liveTheme); )(liveTheme);
// Prepend our special scope selector to the global styles. // Prepend our special scope selector to the global styles.
const scopedGlobalStylesCSS = {}; const scopedGlobalStylesCSS = {};
for (let [selector, rules] of Object.entries(globalStylesCSS)) { for (let [selector, rules] of Object.entries(globalStylesCSS)) {
// The `body` selector is where typography etc rules go, but `body` isn't // The `body` selector is where typography etc rules go, but `body` isn't
// actually *inside* our scoped element! Instead, ignore the `body` part, // actually *inside* our scoped element! Instead, ignore the `body` part,
// and just apply it to the scoping element itself. // and just apply it to the scoping element itself.
if (selector.trim() === "body") { if (selector.trim() === "body") {
selector = ""; selector = "";
} }
const scopedSelector = const scopedSelector =
":where(.chakra-css-reset, .chakra-portal) " + selector; ":where(.chakra-css-reset, .chakra-portal) " + selector;
scopedGlobalStylesCSS[scopedSelector] = rules; scopedGlobalStylesCSS[scopedSelector] = rules;
} }
return ( return (
<> <>
<Box className="chakra-css-reset">{children}</Box> <Box className="chakra-css-reset">{children}</Box>
<Global <Global
styles={css` styles={css`
/* Chakra's default global styles, placed here so we can override /* Chakra's default global styles, placed here so we can override
* the actual _global_ styles in the theme to be empty. That way, * the actual _global_ styles in the theme to be empty. That way,
* it only affects Chakra stuff, not all elements! */ * 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) { :where(.chakra-css-reset, .chakra-portal) {
*, *,
*::before, *::before,
*::after { *::after {
border-width: 0; border-width: 0;
border-style: solid; border-style: solid;
box-sizing: border-box; box-sizing: border-box;
} }
main { main {
display: block; display: block;
} }
hr { hr {
border-top-width: 1px; border-top-width: 1px;
box-sizing: content-box; box-sizing: content-box;
height: 0; height: 0;
overflow: visible; overflow: visible;
} }
pre, pre,
code, code,
kbd, kbd,
samp { samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em; font-size: 1em;
} }
a { a {
background-color: transparent; background-color: transparent;
color: inherit; color: inherit;
text-decoration: inherit; text-decoration: inherit;
} }
abbr[title] { abbr[title] {
border-bottom: none; border-bottom: none;
text-decoration: underline; text-decoration: underline;
-webkit-text-decoration: underline dotted; -webkit-text-decoration: underline dotted;
text-decoration: underline dotted; text-decoration: underline dotted;
} }
b, b,
strong { strong {
font-weight: bold; font-weight: bold;
} }
small { small {
font-size: 80%; font-size: 80%;
} }
sub, sub,
sup { sup {
font-size: 75%; font-size: 75%;
line-height: 0; line-height: 0;
position: relative; position: relative;
vertical-align: baseline; vertical-align: baseline;
} }
sub { sub {
bottom: -0.25em; bottom: -0.25em;
} }
sup { sup {
top: -0.5em; top: -0.5em;
} }
img { img {
border-style: none; border-style: none;
} }
button, button,
input, input,
optgroup, optgroup,
select, select,
textarea { textarea {
font-family: inherit; font-family: inherit;
font-size: 100%; font-size: 100%;
line-height: 1.15; line-height: 1.15;
margin: 0; margin: 0;
} }
button, button,
input { input {
overflow: visible; overflow: visible;
} }
button, button,
select { select {
text-transform: none; text-transform: none;
} }
button::-moz-focus-inner, button::-moz-focus-inner,
[type="button"]::-moz-focus-inner, [type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner, [type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner { [type="submit"]::-moz-focus-inner {
border-style: none; border-style: none;
padding: 0; padding: 0;
} }
fieldset { fieldset {
padding: 0.35em 0.75em 0.625em; padding: 0.35em 0.75em 0.625em;
} }
legend { legend {
box-sizing: border-box; box-sizing: border-box;
color: inherit; color: inherit;
display: table; display: table;
max-width: 100%; max-width: 100%;
padding: 0; padding: 0;
white-space: normal; white-space: normal;
} }
progress { progress {
vertical-align: baseline; vertical-align: baseline;
} }
textarea { textarea {
overflow: auto; overflow: auto;
} }
[type="checkbox"], [type="checkbox"],
[type="radio"] { [type="radio"] {
box-sizing: border-box; box-sizing: border-box;
padding: 0; padding: 0;
} }
[type="number"]::-webkit-inner-spin-button, [type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button { [type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important; -webkit-appearance: none !important;
} }
input[type="number"] { input[type="number"] {
-moz-appearance: textfield; -moz-appearance: textfield;
} }
[type="search"] { [type="search"] {
-webkit-appearance: textfield; -webkit-appearance: textfield;
outline-offset: -2px; outline-offset: -2px;
} }
[type="search"]::-webkit-search-decoration { [type="search"]::-webkit-search-decoration {
-webkit-appearance: none !important; -webkit-appearance: none !important;
} }
::-webkit-file-upload-button { ::-webkit-file-upload-button {
-webkit-appearance: button; -webkit-appearance: button;
font: inherit; font: inherit;
} }
details { details {
display: block; display: block;
} }
summary { summary {
display: list-item; display: list-item;
} }
template { template {
display: none; display: none;
} }
[hidden] { [hidden] {
display: none !important; display: none !important;
} }
body, body,
blockquote, blockquote,
dl, dl,
dd, dd,
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6, h6,
hr, hr,
figure, figure,
p, p,
pre { pre {
margin: 0; margin: 0;
} }
button { button {
background: transparent; background: transparent;
padding: 0; padding: 0;
} }
fieldset { fieldset {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
ol, ol,
ul { ul {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
textarea { textarea {
resize: vertical; resize: vertical;
} }
button, button,
[role="button"] { [role="button"] {
cursor: pointer; cursor: pointer;
} }
button::-moz-focus-inner { button::-moz-focus-inner {
border: 0 !important; border: 0 !important;
} }
table { table {
border-collapse: collapse; border-collapse: collapse;
} }
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
font-size: inherit; font-size: inherit;
font-weight: inherit; font-weight: inherit;
} }
button, button,
input, input,
optgroup, optgroup,
select, select,
textarea { textarea {
padding: 0; padding: 0;
line-height: inherit; line-height: inherit;
color: inherit; color: inherit;
} }
img, img,
svg, svg,
video, video,
canvas, canvas,
audio, audio,
iframe, iframe,
embed, embed,
object { object {
display: block; display: block;
} }
img, img,
video { video {
max-width: 100%; max-width: 100%;
height: auto; height: auto;
} }
[data-js-focus-visible] :focus:not([data-focus-visible-added]) { [data-js-focus-visible] :focus:not([data-focus-visible-added]) {
outline: none; outline: none;
box-shadow: none; box-shadow: none;
} }
select::-ms-expand { select::-ms-expand {
display: none; display: none;
} }
} }
`} `}
/> />
</> </>
); );
} }

View file

@ -1,31 +1,31 @@
import React from "react"; import React from "react";
import { ClassNames } from "@emotion/react"; import { ClassNames } from "@emotion/react";
import { import {
Box, Box,
Flex, Flex,
IconButton, IconButton,
Skeleton, Skeleton,
Tooltip, Tooltip,
useColorModeValue, useColorModeValue,
useTheme, useTheme,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons"; import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import { loadable } from "../util"; import { loadable } from "../util";
import { import {
ItemCardContent, ItemCardContent,
ItemBadgeList, ItemBadgeList,
ItemKindBadge, ItemKindBadge,
MaybeAnimatedBadge, MaybeAnimatedBadge,
YouOwnThisBadge, YouOwnThisBadge,
YouWantThisBadge, YouWantThisBadge,
getZoneBadges, getZoneBadges,
} from "../components/ItemCard"; } from "../components/ItemCard";
import SupportOnly from "./support/SupportOnly"; import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport"; import useSupport from "./support/useSupport";
const LoadableItemSupportDrawer = loadable(() => const LoadableItemSupportDrawer = loadable(
import("./support/ItemSupportDrawer"), () => import("./support/ItemSupportDrawer"),
); );
/** /**
@ -48,79 +48,79 @@ const LoadableItemSupportDrawer = loadable(() =>
* devices. * devices.
*/ */
function Item({ function Item({
item, item,
itemNameId, itemNameId,
isWorn, isWorn,
isInOutfit, isInOutfit,
onRemove, onRemove,
isDisabled = false, isDisabled = false,
}) { }) {
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false); const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return ( return (
<> <>
<ItemContainer isDisabled={isDisabled}> <ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0"> <Box flex="1 1 0" minWidth="0">
<ItemCardContent <ItemCardContent
item={item} item={item}
badges={<ItemBadges item={item} />} badges={<ItemBadges item={item} />}
itemNameId={itemNameId} itemNameId={itemNameId}
isWorn={isWorn} isWorn={isWorn}
isDiabled={isDisabled} isDiabled={isDisabled}
focusSelector={containerHasFocus} focusSelector={containerHasFocus}
/> />
</Box> </Box>
<Box flex="0 0 auto" marginTop="5px"> <Box flex="0 0 auto" marginTop="5px">
{isInOutfit && ( {isInOutfit && (
<ItemActionButton <ItemActionButton
icon={<DeleteIcon />} icon={<DeleteIcon />}
label="Remove" label="Remove"
onClick={(e) => { onClick={(e) => {
onRemove(item.id); onRemove(item.id);
e.preventDefault(); e.preventDefault();
}} }}
/> />
)} )}
<SupportOnly> <SupportOnly>
<ItemActionButton <ItemActionButton
icon={<EditIcon />} icon={<EditIcon />}
label="Support" label="Support"
onClick={(e) => { onClick={(e) => {
setSupportDrawerIsOpen(true); setSupportDrawerIsOpen(true);
e.preventDefault(); e.preventDefault();
}} }}
/> />
</SupportOnly> </SupportOnly>
<ItemActionButton <ItemActionButton
icon={<InfoIcon />} icon={<InfoIcon />}
label="More info" label="More info"
to={`/items/${item.id}`} to={`/items/${item.id}`}
target="_blank" target="_blank"
/> />
</Box> </Box>
</ItemContainer> </ItemContainer>
<SupportOnly> <SupportOnly>
<LoadableItemSupportDrawer <LoadableItemSupportDrawer
item={item} item={item}
isOpen={supportDrawerIsOpen} isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)} onClose={() => setSupportDrawerIsOpen(false)}
/> />
</SupportOnly> </SupportOnly>
</> </>
); );
} }
/** /**
* ItemSkeleton is a placeholder for when an Item is loading. * ItemSkeleton is a placeholder for when an Item is loading.
*/ */
function ItemSkeleton() { function ItemSkeleton() {
return ( return (
<ItemContainer isDisabled> <ItemContainer isDisabled>
<Skeleton width="50px" height="50px" /> <Skeleton width="50px" height="50px" />
<Box width="3" /> <Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" /> <Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer> </ItemContainer>
); );
} }
/** /**
@ -131,152 +131,152 @@ function ItemSkeleton() {
* .item-container parent! * .item-container parent!
*/ */
function ItemContainer({ children, isDisabled = false }) { function ItemContainer({ children, isDisabled = false }) {
const theme = useTheme(); const theme = useTheme();
const focusBackgroundColor = useColorModeValue( const focusBackgroundColor = useColorModeValue(
theme.colors.gray["100"], theme.colors.gray["100"],
theme.colors.gray["700"], theme.colors.gray["700"],
); );
const activeBorderColor = useColorModeValue( const activeBorderColor = useColorModeValue(
theme.colors.green["400"], theme.colors.green["400"],
theme.colors.green["500"], theme.colors.green["500"],
); );
const focusCheckedBorderColor = useColorModeValue( const focusCheckedBorderColor = useColorModeValue(
theme.colors.green["800"], theme.colors.green["800"],
theme.colors.green["300"], theme.colors.green["300"],
); );
return ( return (
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
<Box <Box
p="1" p="1"
my="1" my="1"
borderRadius="lg" borderRadius="lg"
d="flex" d="flex"
cursor={isDisabled ? undefined : "pointer"} cursor={isDisabled ? undefined : "pointer"}
border="1px" border="1px"
borderColor="transparent" borderColor="transparent"
className={cx([ className={cx([
"item-container", "item-container",
!isDisabled && !isDisabled &&
css` css`
&:hover, &:hover,
input:focus + & { input:focus + & {
background-color: ${focusBackgroundColor}; background-color: ${focusBackgroundColor};
} }
input:active + & { input:active + & {
border-color: ${activeBorderColor}; border-color: ${activeBorderColor};
} }
input:checked:focus + & { input:checked:focus + & {
border-color: ${focusCheckedBorderColor}; border-color: ${focusCheckedBorderColor};
} }
`, `,
])} ])}
> >
{children} {children}
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
function ItemBadges({ item }) { function ItemBadges({ item }) {
const { isSupportUser } = useSupport(); const { isSupportUser } = useSupport();
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone); const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
const restrictedZones = item.appearanceOn.restrictedZones.filter( const restrictedZones = item.appearanceOn.restrictedZones.filter(
(z) => z.isCommonlyUsedByItems, (z) => z.isCommonlyUsedByItems,
); );
const isMaybeAnimated = item.appearanceOn.layers.some( const isMaybeAnimated = item.appearanceOn.layers.some(
(l) => l.canvasMovieLibraryUrl, (l) => l.canvasMovieLibraryUrl,
); );
return ( return (
<ItemBadgeList> <ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} /> <ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{ {
// This badge is unreliable, but it's helpful for looking for animated // 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 // items to test, so we show it only to support. We use this form
// instead of <SupportOnly />, to avoid adding extra badge list spacing // instead of <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child. // on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge /> isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
} }
{getZoneBadges(occupiedZones, { variant: "occupies" })} {getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })} {getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />} {item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />} {item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList> </ItemBadgeList>
); );
} }
/** /**
* ItemActionButton is one of a list of actions a user can take for this item. * ItemActionButton is one of a list of actions a user can take for this item.
*/ */
function ItemActionButton({ icon, label, to, onClick, ...props }) { function ItemActionButton({ icon, label, to, onClick, ...props }) {
const theme = useTheme(); const theme = useTheme();
const focusBackgroundColor = useColorModeValue( const focusBackgroundColor = useColorModeValue(
theme.colors.gray["300"], theme.colors.gray["300"],
theme.colors.gray["800"], theme.colors.gray["800"],
); );
const focusColor = useColorModeValue( const focusColor = useColorModeValue(
theme.colors.gray["700"], theme.colors.gray["700"],
theme.colors.gray["200"], theme.colors.gray["200"],
); );
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Tooltip label={label} placement="top"> <Tooltip label={label} placement="top">
<LinkOrButton <LinkOrButton
{...props} {...props}
component={IconButton} component={IconButton}
href={to} href={to}
icon={icon} icon={icon}
aria-label={label} aria-label={label}
variant="ghost" variant="ghost"
color="gray.400" color="gray.400"
onClick={onClick} onClick={onClick}
className={css` className={css`
opacity: 0; opacity: 0;
transition: all 0.2s; transition: all 0.2s;
${containerHasFocus} { ${containerHasFocus} {
opacity: 1; opacity: 1;
} }
&:focus, &:focus,
&:hover { &:hover {
opacity: 1; opacity: 1;
background-color: ${focusBackgroundColor}; background-color: ${focusBackgroundColor};
color: ${focusColor}; 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, * tap to reveal them (which toggles the item), or worse,
* accidentally tapping a hidden button without realizing! */ * accidentally tapping a hidden button without realizing! */
@media (hover: none) { @media (hover: none) {
opacity: 1; opacity: 1;
} }
`} `}
/> />
</Tooltip> </Tooltip>
)} )}
</ClassNames> </ClassNames>
); );
} }
function LinkOrButton({ href, component, ...props }) { function LinkOrButton({ href, component, ...props }) {
const ButtonComponent = component; const ButtonComponent = component;
if (href != null) { if (href != null) {
return <ButtonComponent as="a" href={href} {...props} />; return <ButtonComponent as="a" href={href} {...props} />;
} else { } else {
return <ButtonComponent {...props} />; return <ButtonComponent {...props} />;
} }
} }
/** /**
@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
* components in this to ensure a consistent list layout. * components in this to ensure a consistent list layout.
*/ */
export function ItemListContainer({ children, ...props }) { export function ItemListContainer({ children, ...props }) {
return ( return (
<Flex direction="column" {...props}> <Flex direction="column" {...props}>
{children} {children}
</Flex> </Flex>
); );
} }
/** /**
@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
* Items are loading. * Items are loading.
*/ */
export function ItemListSkeleton({ count, ...props }) { export function ItemListSkeleton({ count, ...props }) {
return ( return (
<ItemListContainer {...props}> <ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} /> <ItemSkeleton key={i} />
))} ))}
</ItemListContainer> </ItemListContainer>
); );
} }
/** /**
@ -311,6 +311,6 @@ export function ItemListSkeleton({ count, ...props }) {
* focused. * focused.
*/ */
const containerHasFocus = const containerHasFocus =
".item-container:hover &, input:focus + .item-container &"; ".item-container:hover &, input:focus + .item-container &";
export default React.memo(Item); export default React.memo(Item);

View file

@ -21,72 +21,72 @@ import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
* state and refs. * state and refs.
*/ */
function ItemsAndSearchPanels({ function ItemsAndSearchPanels({
loading, loading,
searchQuery, searchQuery,
onChangeSearchQuery, onChangeSearchQuery,
outfitState, outfitState,
outfitSaving, outfitSaving,
dispatchToOutfit, dispatchToOutfit,
}) { }) {
const scrollContainerRef = React.useRef(); const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef(); const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef(); const firstSearchResultRef = React.useRef();
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true }); const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
const [canUseSearchFooter] = useLocalStorage( const [canUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter", "DTIFeatureFlagCanUseSearchFooter",
false, false,
); );
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter; const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
return ( return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Flex direction="column" height="100%"> <Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />} {isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && ( {!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm"> <Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar <SearchToolbar
query={searchQuery} query={searchQuery}
searchQueryRef={searchQueryRef} searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef} firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery} onChange={onChangeSearchQuery}
/> />
</Box> </Box>
)} )}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? ( {!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box <Box
key="search-panel" key="search-panel"
flex="1 0 0" flex="1 0 0"
position="relative" position="relative"
overflowY="scroll" overflowY="scroll"
ref={scrollContainerRef} ref={scrollContainerRef}
data-test-id="search-panel-scroll-container" data-test-id="search-panel-scroll-container"
> >
<SearchPanel <SearchPanel
query={searchQuery} query={searchQuery}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef} scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef} searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef} firstSearchResultRef={firstSearchResultRef}
/> />
</Box> </Box>
) : ( ) : (
<Box position="relative" overflow="auto" key="items-panel"> <Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2"> <Box px="4" py="2">
<ItemsPanel <ItemsPanel
loading={loading} loading={loading}
outfitState={outfitState} outfitState={outfitState}
outfitSaving={outfitSaving} outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
</Box> </Box>
)} )}
</Flex> </Flex>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }
export default ItemsAndSearchPanels; export default ItemsAndSearchPanels;

View file

@ -1,38 +1,38 @@
import React from "react"; import React from "react";
import { ClassNames } from "@emotion/react"; import { ClassNames } from "@emotion/react";
import { import {
Box, Box,
Editable, Editable,
EditablePreview, EditablePreview,
EditableInput, EditableInput,
Flex, Flex,
IconButton, IconButton,
Skeleton, Skeleton,
Tooltip, Tooltip,
VisuallyHidden, VisuallyHidden,
Menu, Menu,
MenuButton, MenuButton,
MenuList, MenuList,
MenuItem, MenuItem,
Portal, Portal,
Button, Button,
Spinner, Spinner,
useColorModeValue, useColorModeValue,
Modal, Modal,
ModalContent, ModalContent,
ModalOverlay, ModalOverlay,
ModalHeader, ModalHeader,
ModalBody, ModalBody,
ModalFooter, ModalFooter,
useDisclosure, useDisclosure,
ModalCloseButton, ModalCloseButton,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckIcon, CheckIcon,
DeleteIcon, DeleteIcon,
EditIcon, EditIcon,
QuestionIcon, QuestionIcon,
WarningTwoIcon, WarningTwoIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { IoBagCheck } from "react-icons/io5"; import { IoBagCheck } from "react-icons/io5";
import { CSSTransition, TransitionGroup } from "react-transition-group"; 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! * full width of the container, it doesn't look like it!
*/ */
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) { function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState; const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box> <Box>
<Box px="1"> <Box px="1">
<OutfitHeading <OutfitHeading
outfitState={outfitState} outfitState={outfitState}
outfitSaving={outfitSaving} outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
<Flex direction="column"> <Flex direction="column">
{loading ? ( {loading ? (
<ItemZoneGroupsSkeleton <ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length} itemCount={outfitState.allItemIds.length}
/> />
) : ( ) : (
<> <>
<TransitionGroup component={null}> <TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => ( {zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition <CSSTransition
key={zoneId} key={zoneId}
{...fadeOutAndRollUpTransition(css)} {...fadeOutAndRollUpTransition(css)}
> >
<ItemZoneGroup <ItemZoneGroup
zoneLabel={zoneLabel} zoneLabel={zoneLabel}
items={items} items={items}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</CSSTransition> </CSSTransition>
))} ))}
</TransitionGroup> </TransitionGroup>
{incompatibleItems.length > 0 && ( {incompatibleItems.length > 0 && (
<ItemZoneGroup <ItemZoneGroup
zoneLabel="Incompatible" zoneLabel="Incompatible"
afterHeader={ afterHeader={
<Tooltip <Tooltip
label={ label={
altStyleId != null altStyleId != null
? "Many items don't fit Alt Style pets" ? "Many items don't fit Alt Style pets"
: "These items don't fit this pet" : "These items don't fit this pet"
} }
placement="top" placement="top"
openDelay={100} openDelay={100}
> >
<QuestionIcon fontSize="sm" /> <QuestionIcon fontSize="sm" />
</Tooltip> </Tooltip>
} }
items={incompatibleItems} items={incompatibleItems}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
isDisabled isDisabled
/> />
)} )}
</> </>
)} )}
</Flex> </Flex>
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
/** /**
@ -134,102 +134,102 @@ function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
* makes the list screen-reader- and keyboard-accessible! * makes the list screen-reader- and keyboard-accessible!
*/ */
function ItemZoneGroup({ function ItemZoneGroup({
zoneLabel, zoneLabel,
items, items,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
isDisabled = false, isDisabled = false,
afterHeader = null, afterHeader = null,
}) { }) {
// onChange is fired when the radio button becomes checked, not unchecked! // onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => { const onChange = (e) => {
const itemId = e.target.value; const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId }); dispatchToOutfit({ type: "wearItem", itemId });
}; };
// Clicking the radio button when already selected deselects it - this is how // Clicking the radio button when already selected deselects it - this is how
// you can select none! // you can select none!
const onClick = (e) => { const onClick = (e) => {
const itemId = e.target.value; const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) { if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated // 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 // 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 // solve that with `preventDefault`, because it breaks the radio's
// intended visual updates when we unwear. So, we `setTimeout` to do it // intended visual updates when we unwear. So, we `setTimeout` to do it
// after all event handlers resolve! // after all event handlers resolve!
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0); setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
} }
}; };
const onRemove = React.useCallback( const onRemove = React.useCallback(
(itemId) => { (itemId) => {
dispatchToOutfit({ type: "removeItem", itemId }); dispatchToOutfit({ type: "removeItem", itemId });
}, },
[dispatchToOutfit], [dispatchToOutfit],
); );
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box mb="10"> <Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1"> <Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel} {zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>} {afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2> </Heading2>
<ItemListContainer> <ItemListContainer>
<TransitionGroup component={null}> <TransitionGroup component={null}>
{items.map((item) => { {items.map((item) => {
const itemNameId = const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`; zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = ( const itemNode = (
<Item <Item
item={item} item={item}
itemNameId={itemNameId} itemNameId={itemNameId}
isWorn={ isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id) !isDisabled && outfitState.wornItemIds.includes(item.id)
} }
isInOutfit={outfitState.allItemIds.includes(item.id)} isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove} onRemove={onRemove}
isDisabled={isDisabled} isDisabled={isDisabled}
/> />
); );
return ( return (
<CSSTransition <CSSTransition
key={item.id} key={item.id}
{...fadeOutAndRollUpTransition(css)} {...fadeOutAndRollUpTransition(css)}
> >
{isDisabled ? ( {isDisabled ? (
itemNode itemNode
) : ( ) : (
<label> <label>
<VisuallyHidden <VisuallyHidden
as="input" as="input"
type="radio" type="radio"
aria-labelledby={itemNameId} aria-labelledby={itemNameId}
name={zoneLabel} name={zoneLabel}
value={item.id} value={item.id}
checked={outfitState.wornItemIds.includes(item.id)} checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange} onChange={onChange}
onClick={onClick} onClick={onClick}
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key === " ") { if (e.key === " ") {
onClick(e); onClick(e);
} }
}} }}
/> />
{itemNode} {itemNode}
</label> </label>
)} )}
</CSSTransition> </CSSTransition>
); );
})} })}
</TransitionGroup> </TransitionGroup>
</ItemListContainer> </ItemListContainer>
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
/** /**
@ -240,35 +240,35 @@ function ItemZoneGroup({
* we don't show skeleton items that just clear away! * we don't show skeleton items that just clear away!
*/ */
function ItemZoneGroupsSkeleton({ itemCount }) { function ItemZoneGroupsSkeleton({ itemCount }) {
const groups = []; const groups = [];
for (let i = 0; i < itemCount; i++) { for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for // 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 // 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 // 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 // to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits? // maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />); groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
} }
return groups; return groups;
} }
/** /**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading. * ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/ */
function ItemZoneGroupSkeleton({ itemCount }) { function ItemZoneGroupSkeleton({ itemCount }) {
return ( return (
<Box mb="10"> <Box mb="10">
<Delay> <Delay>
<Skeleton <Skeleton
mx="1" mx="1"
// 2.25rem font size, 1.25rem line height // 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`} height={`${2.25 * 1.25}rem`}
width="12rem" width="12rem"
/> />
<ItemListSkeleton count={itemCount} /> <ItemListSkeleton count={itemCount} />
</Delay> </Delay>
</Box> </Box>
); );
} }
/** /**
@ -277,36 +277,36 @@ function ItemZoneGroupSkeleton({ itemCount }) {
* this is disabled. * this is disabled.
*/ */
function ShoppingListButton({ outfitState }) { function ShoppingListButton({ outfitState }) {
const itemIds = [...outfitState.wornItemIds].sort(); const itemIds = [...outfitState.wornItemIds].sort();
const isDisabled = itemIds.length === 0; const isDisabled = itemIds.length === 0;
let targetUrl = `/items/sources/${itemIds.join(",")}`; let targetUrl = `/items/sources/${itemIds.join(",")}`;
if (outfitState.name != null && outfitState.name.trim().length > 0) { if (outfitState.name != null && outfitState.name.trim().length > 0) {
const params = new URLSearchParams(); const params = new URLSearchParams();
params.append("for", outfitState.name); params.append("for", outfitState.name);
targetUrl += "?" + params.toString(); targetUrl += "?" + params.toString();
} }
return ( return (
<Tooltip <Tooltip
label="Shopping list" label="Shopping list"
placement="top" placement="top"
background="purple.500" background="purple.500"
color="white" color="white"
> >
<IconButton <IconButton
aria-label="Shopping list" aria-label="Shopping list"
as={isDisabled ? "button" : "a"} as={isDisabled ? "button" : "a"}
href={isDisabled ? undefined : targetUrl} href={isDisabled ? undefined : targetUrl}
target={isDisabled ? undefined : "_blank"} target={isDisabled ? undefined : "_blank"}
icon={<IoBagCheck />} icon={<IoBagCheck />}
colorScheme="purple" colorScheme="purple"
size="sm" size="sm"
isRound isRound
isDisabled={isDisabled} isDisabled={isDisabled}
/> />
</Tooltip> </Tooltip>
); );
} }
/** /**
@ -314,100 +314,100 @@ function ShoppingListButton({ outfitState }) {
* if the user can save this outfit. If not, this is empty! * if the user can save this outfit. If not, this is empty!
*/ */
function OutfitSavingIndicator({ outfitSaving }) { function OutfitSavingIndicator({ outfitSaving }) {
const { const {
canSaveOutfit, canSaveOutfit,
isNewOutfit, isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved, latestVersionIsSaved,
saveError, saveError,
saveOutfit, saveOutfit,
} = outfitSaving; } = outfitSaving;
const errorTextColor = useColorModeValue("red.600", "red.400"); const errorTextColor = useColorModeValue("red.600", "red.400");
if (!canSaveOutfit) { if (!canSaveOutfit) {
return null; return null;
} }
if (isNewOutfit) { if (isNewOutfit) {
return ( return (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
isLoading={isSaving} isLoading={isSaving}
loadingText="Saving…" loadingText="Saving…"
leftIcon={ leftIcon={
<Box <Box
// Adjust the visual balance toward the cloud // Adjust the visual balance toward the cloud
marginBottom="-2px" marginBottom="-2px"
> >
<IoCloudUploadOutline /> <IoCloudUploadOutline />
</Box> </Box>
} }
onClick={saveOutfit} onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button" data-test-id="wardrobe-save-outfit-button"
> >
Save Save
</Button> </Button>
); );
} }
if (isSaving) { if (isSaving) {
return ( return (
<Flex <Flex
align="center" align="center"
fontSize="xs" fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator" data-test-id="wardrobe-outfit-is-saving-indicator"
> >
<Spinner <Spinner
size="xs" size="xs"
marginRight="1.5" marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong... // HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px" marginBottom="-2px"
/> />
Saving Saving
</Flex> </Flex>
); );
} }
if (latestVersionIsSaved) { if (latestVersionIsSaved) {
return ( return (
<Flex <Flex
align="center" align="center"
fontSize="xs" fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator" data-test-id="wardrobe-outfit-is-saved-indicator"
> >
<CheckIcon <CheckIcon
marginRight="1" marginRight="1"
// HACK: Not sure why my various centering things always feel wrong... // HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px" marginBottom="-2px"
/> />
Saved Saved
</Flex> </Flex>
); );
} }
if (saveError) { if (saveError) {
return ( return (
<Flex <Flex
align="center" align="center"
fontSize="xs" fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator" data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor} color={errorTextColor}
> >
<WarningTwoIcon <WarningTwoIcon
marginRight="1" marginRight="1"
// HACK: Not sure why my various centering things always feel wrong... // HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px" marginBottom="-2px"
/> />
Error saving Error saving
</Flex> </Flex>
); );
} }
// The most common way we'll hit this null is when the outfit is changing, // 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. // but the debouncing isn't done yet, so it's not saving yet.
return null; return null;
} }
/** /**
@ -415,133 +415,133 @@ function OutfitSavingIndicator({ outfitSaving }) {
* It also contains the outfit menu, for saving etc. * It also contains the outfit menu, for saving etc.
*/ */
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
const { canDeleteOutfit } = outfitSaving; const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true }); const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
return ( return (
// The Editable wraps everything, including the menu, because the menu has // The Editable wraps everything, including the menu, because the menu has
// a Rename option. // a Rename option.
<Editable <Editable
// Make sure not to ever pass `undefined` into here, or else the // Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value // component enters uncontrolled mode, and changing the value
// later won't fix it! // later won't fix it!
value={outfitState.name || ""} value={outfitState.name || ""}
placeholder="Untitled outfit" placeholder="Untitled outfit"
onChange={(value) => onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value }) dispatchToOutfit({ type: "rename", outfitName: value })
} }
> >
{({ onEdit }) => ( {({ onEdit }) => (
<Flex align="center" marginBottom="6"> <Flex align="center" marginBottom="6">
<Box> <Box>
<Box role="group" d="inline-block" position="relative" width="100%"> <Box role="group" d="inline-block" position="relative" width="100%">
<Heading1> <Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" /> <EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" /> <EditableInput lineHeight="48px" />
</Heading1> </Heading1>
</Box> </Box>
</Box> </Box>
<Box width="4" flex="1 0 auto" /> <Box width="4" flex="1 0 auto" />
<OutfitSavingIndicator outfitSaving={outfitSaving} /> <OutfitSavingIndicator outfitSaving={outfitSaving} />
<Box width="3" flex="0 0 auto" /> <Box width="3" flex="0 0 auto" />
<ShoppingListButton outfitState={outfitState} /> <ShoppingListButton outfitState={outfitState} />
<Box width="2" flex="0 0 auto" /> <Box width="2" flex="0 0 auto" />
<Menu placement="bottom-end"> <Menu placement="bottom-end">
<MenuButton <MenuButton
as={IconButton} as={IconButton}
variant="ghost" variant="ghost"
icon={<MdMoreVert />} icon={<MdMoreVert />}
aria-label="Outfit menu" aria-label="Outfit menu"
isRound isRound
size="sm" size="sm"
fontSize="24px" fontSize="24px"
opacity="0.8" opacity="0.8"
/> />
<Portal> <Portal>
<MenuList> <MenuList>
{outfitState.id && ( {outfitState.id && (
<MenuItem <MenuItem
icon={<EditIcon />} icon={<EditIcon />}
as="a" as="a"
href={outfitCopyUrl} href={outfitCopyUrl}
target="_blank" target="_blank"
> >
Edit a copy Edit a copy
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
icon={<BiRename />} icon={<BiRename />}
onClick={() => { onClick={() => {
// Start the rename after a tick, so finishing up the click // Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable. // won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0); setTimeout(onEdit, 0);
}} }}
> >
Rename Rename
</MenuItem> </MenuItem>
{canDeleteOutfit && ( {canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} /> <DeleteOutfitMenuItem outfitState={outfitState} />
)} )}
</MenuList> </MenuList>
</Portal> </Portal>
</Menu> </Menu>
</Flex> </Flex>
)} )}
</Editable> </Editable>
); );
} }
function DeleteOutfitMenuItem({ outfitState }) { function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState; const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const { status, error, mutateAsync } = useDeleteOutfitMutation(); const { status, error, mutateAsync } = useDeleteOutfitMutation();
return ( return (
<> <>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}> <MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete Delete
</MenuItem> </MenuItem>
<Modal isOpen={isOpen} onClose={onClose}> <Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay /> <ModalOverlay />
<ModalContent> <ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader> <ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
We'll delete this data and remove it from your list of outfits. 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 Links and image embeds pointing to this outfit will break. Is that
okay? okay?
{status === "error" && ( {status === "error" && (
<ErrorMessage marginTop="1em"> <ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again? Error deleting outfit: "{error.message}". Try again?
</ErrorMessage> </ErrorMessage>
)} )}
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button> <Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" /> <Box flex="1 0 auto" width="2" />
<Button <Button
colorScheme="red" colorScheme="red"
onClick={() => onClick={() =>
mutateAsync(id) mutateAsync(id)
.then(() => { .then(() => {
window.location = "/your-outfits"; window.location = "/your-outfits";
}) })
.catch((e) => { .catch((e) => {
/* handled in error UI */ /* handled in error UI */
}) })
} }
// We continue to show the loading spinner in the success case, // We continue to show the loading spinner in the success case,
// while we redirect away! // while we redirect away!
isLoading={status === "pending" || status === "success"} isLoading={status === "pending" || status === "success"}
> >
Delete Delete
</Button> </Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>
</> </>
); );
} }
/** /**
@ -555,24 +555,24 @@ function DeleteOutfitMenuItem({ outfitState }) {
* See react-transition-group docs for more info! * See react-transition-group docs for more info!
*/ */
const fadeOutAndRollUpTransition = (css) => ({ const fadeOutAndRollUpTransition = (css) => ({
classNames: css` classNames: css`
&-exit { &-exit {
opacity: 1; opacity: 1;
height: auto; height: auto;
} }
&-exit-active { &-exit-active {
opacity: 0; opacity: 0;
height: 0 !important; height: 0 !important;
margin-top: 0 !important; margin-top: 0 !important;
margin-bottom: 0 !important; margin-bottom: 0 !important;
transition: all 0.5s; transition: all 0.5s;
} }
`, `,
timeout: 500, timeout: 500,
onExit: (e) => { onExit: (e) => {
e.style.height = e.offsetHeight + "px"; e.style.height = e.offsetHeight + "px";
}, },
}); });
export default ItemsPanel; export default ItemsPanel;

View file

@ -1,92 +1,92 @@
import React from "react"; import React from "react";
import { import {
Box, Box,
Button, Button,
Modal, Modal,
ModalBody, ModalBody,
ModalCloseButton, ModalCloseButton,
ModalContent, ModalContent,
ModalHeader, ModalHeader,
ModalOverlay, ModalOverlay,
Table, Table,
Tbody, Tbody,
Td, Td,
Th, Th,
Thead, Thead,
Tr, Tr,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
function LayersInfoModal({ isOpen, onClose, visibleLayers }) { function LayersInfoModal({ isOpen, onClose, visibleLayers }) {
return ( return (
<Modal isOpen={isOpen} onClose={onClose} size="xl"> <Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay> <ModalOverlay>
<ModalContent maxWidth="800px"> <ModalContent maxWidth="800px">
<ModalHeader>Outfit layers</ModalHeader> <ModalHeader>Outfit layers</ModalHeader>
<ModalCloseButton /> <ModalCloseButton />
<ModalBody> <ModalBody>
<LayerTable layers={visibleLayers} /> <LayerTable layers={visibleLayers} />
</ModalBody> </ModalBody>
</ModalContent> </ModalContent>
</ModalOverlay> </ModalOverlay>
</Modal> </Modal>
); );
} }
function LayerTable({ layers }) { function LayerTable({ layers }) {
return ( return (
<Table> <Table>
<Thead> <Thead>
<Tr> <Tr>
<Th>Preview</Th> <Th>Preview</Th>
<Th>DTI ID</Th> <Th>DTI ID</Th>
<Th>Zone</Th> <Th>Zone</Th>
<Th>Links</Th> <Th>Links</Th>
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{layers.map((layer) => ( {layers.map((layer) => (
<LayerTableRow key={layer.id} layer={layer} /> <LayerTableRow key={layer.id} layer={layer} />
))} ))}
</Tbody> </Tbody>
</Table> </Table>
); );
} }
function LayerTableRow({ layer, ...props }) { function LayerTableRow({ layer, ...props }) {
return ( return (
<Tr {...props}> <Tr {...props}>
<Td> <Td>
<Box <Box
as="img" as="img"
src={layer.imageUrl} src={layer.imageUrl}
width="60px" width="60px"
height="60px" height="60px"
boxShadow="md" boxShadow="md"
/> />
</Td> </Td>
<Td>{layer.id}</Td> <Td>{layer.id}</Td>
<Td>{layer.zone.label}</Td> <Td>{layer.zone.label}</Td>
<Td> <Td>
<Box display="flex" gap=".5em"> <Box display="flex" gap=".5em">
{layer.imageUrl && ( {layer.imageUrl && (
<Button as="a" href={layer.imageUrl} target="_blank" size="sm"> <Button as="a" href={layer.imageUrl} target="_blank" size="sm">
PNG PNG
</Button> </Button>
)} )}
{layer.swfUrl && ( {layer.swfUrl && (
<Button as="a" href={layer.swfUrl} size="sm" download> <Button as="a" href={layer.swfUrl} size="sm" download>
SWF SWF
</Button> </Button>
)} )}
{layer.svgUrl && ( {layer.svgUrl && (
<Button as="a" href={layer.svgUrl} target="_blank" size="sm"> <Button as="a" href={layer.svgUrl} target="_blank" size="sm">
SVG SVG
</Button> </Button>
)} )}
</Box> </Box>
</Td> </Td>
</Tr> </Tr>
); );
} }
export default LayersInfoModal; 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"; import { useLocalStorage } from "../util";
function OutfitKnownGlitchesBadge({ appearance }) { function OutfitKnownGlitchesBadge({ appearance }) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false); const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const { petAppearance, items } = appearance; const { petAppearance, items } = appearance;
const glitchMessages = []; const glitchMessages = [];
// Look for UC/Invisible/etc incompatibilities that we hid, that we should // Look for UC/Invisible/etc incompatibilities that we hid, that we should
// just mark Incompatible someday instead; or with correctly partially-hidden // just mark Incompatible someday instead; or with correctly partially-hidden
// art. // art.
// //
// NOTE: This particular glitch is checking for the *absence* of layers, so // NOTE: This particular glitch is checking for the *absence* of layers, so
// we skip it if we're still loading! // we skip it if we're still loading!
if (!appearance.loading) { if (!appearance.loading) {
for (const item of items) { for (const item of items) {
// HACK: We use `getVisibleLayers` with just this pet appearance and item // HACK: We use `getVisibleLayers` with just this pet appearance and item
// appearance, to run the logic for which layers are compatible with // appearance, to run the logic for which layers are compatible with
// this pet. But `getVisibleLayers` does other things too, so it's // this pet. But `getVisibleLayers` does other things too, so it's
// plausible that this could do not quite what we want in some cases! // plausible that this could do not quite what we want in some cases!
const allItemLayers = item.appearance.layers; const allItemLayers = item.appearance.layers;
const compatibleItemLayers = getVisibleLayers(petAppearance, [ const compatibleItemLayers = getVisibleLayers(petAppearance, [
item.appearance, item.appearance,
]).filter((l) => l.source === "item"); ]).filter((l) => l.source === "item");
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) { if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
glitchMessages.push( glitchMessages.push(
<Box key={`total-uc-conflict-for-item-${item.id}`}> <Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> isn't actually compatible with this special pet. <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 We're hiding the item art, which is outdated behavior, and we should
instead be treating it as entirely incompatible. Fixing this is in instead be treating it as entirely incompatible. Fixing this is in
our todo list, sorry for the confusing UI! our todo list, sorry for the confusing UI!
</Box>, </Box>,
); );
} else if (compatibleItemLayers.length < allItemLayers.length) { } else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push( glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}> <Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'s compatibility with this pet is complicated, but <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 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 zones are hidden. If this isn't quite right, please email me at
matchu@openneo.net and let me know! matchu@openneo.net and let me know!
</Box>, </Box>,
); );
} }
} }
} }
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch. // Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const item of items) { for (const item of items) {
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) => const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"), (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
); );
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some( const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
(l) => (l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") && (l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
!layerUsesHTML5(l), !layerUsesHTML5(l),
); );
if (itemHasBrokenOnNeopetsDotCom) { if (itemHasBrokenOnNeopetsDotCom) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}> <Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? ( {itemHasBrokenUnconvertedLayers ? (
<> <>
We're aware of a glitch affecting the art for <i>{item.name}</i>. We're aware of a glitch affecting the art for <i>{item.name}</i>.
Last time we checked, this glitch affected its appearance on Last time we checked, this glitch affected its appearance on
Neopets.com, too. Hopefully this will be fixed once it's converted Neopets.com, too. Hopefully this will be fixed once it's converted
to HTML5! to HTML5!
</> </>
) : ( ) : (
<> <>
We're aware of a previous glitch affecting the art for{" "} We're aware of a previous glitch affecting the art for{" "}
<i>{item.name}</i>, but it might have been resolved during HTML5 <i>{item.name}</i>, but it might have been resolved during HTML5
conversion. Please use the feedback form on the homepage to let us conversion. Please use the feedback form on the homepage to let us
know if it looks right, or still looks wrong! Thank you! know if it looks right, or still looks wrong! Thank you!
</> </>
)} )}
</Box>, </Box>,
); );
} }
} }
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch. // Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
for (const item of items) { for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) => const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"), (l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
); );
if (itemHasGlitch) { if (itemHasGlitch) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}> <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 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 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 you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it! matchu@openneo.net so we can fix it!
</Box>, </Box>,
); );
} }
} }
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this // 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 hi-res mode is on, because otherwise it doesn't affect the user anyway!
if (hiResMode) { if (hiResMode) {
for (const item of items) { for (const item of items) {
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) => const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"), (l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
); );
if (itemHasOfficialSvgIsIncorrect) { if (itemHasOfficialSvgIsIncorrect) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}> <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 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 from showing the SVG image for Hi-Res Mode. Instead, we're showing a
PNG, which might look a bit blurry on larger screens. PNG, which might look a bit blurry on larger screens.
</Box>, </Box>,
); );
} }
} }
} }
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch. // Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const item of items) { for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) => const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes( (l.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN", "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
), ),
); );
if (itemHasGlitch) { if (itemHasGlitch) {
glitchMessages.push( glitchMessages.push(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}> <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 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. 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 If you own this item, please email me at matchu@openneo.net to let us
know how it looks in the on-site customizer! know how it looks in the on-site customizer!
</Box>, </Box>,
); );
} }
} }
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch. // Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
for (const item of items) { for (const item of items) {
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) => const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"), (l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
); );
if (itemHasOfficialBodyIdIsIncorrect) { if (itemHasOfficialBodyIdIsIncorrect) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}> <Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> actually is compatible with this 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 pet, even though it seems like it shouldn't be. But TNT might change
this at any time, so be careful! this at any time, so be careful!
</Box>, </Box>,
); );
} }
} }
// Look for Dyeworks items that aren't converted yet. // Look for Dyeworks items that aren't converted yet.
for (const item of items) { for (const item of items) {
const itemIsDyeworks = item.name.includes("Dyeworks"); const itemIsDyeworks = item.name.includes("Dyeworks");
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5); const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
if (itemIsDyeworks && !itemIsConverted) { if (itemIsDyeworks && !itemIsConverted) {
glitchMessages.push( glitchMessages.push(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}> <Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI <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 code often shows old Dyeworks items in the wrong color. Once it's
converted, we'll display it correctly! converted, we'll display it correctly!
</Box>, </Box>,
); );
} }
} }
// Look for Baby Body Paint items. // Look for Baby Body Paint items.
for (const item of items) { for (const item of items) {
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint"); const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
if (itemIsBabyBodyPaint) { if (itemIsBabyBodyPaint) {
glitchMessages.push( glitchMessages.push(
<Box key={`baby-body-paint-warning-for-item-${item.id}`}> <Box key={`baby-body-paint-warning-for-item-${item.id}`}>
<i>{item.name}</i> seems to have new zone restriction rules that our <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 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, to how we handle zones. Until then, this item will be very buggy,
sorry! sorry!
</Box>, </Box>,
); );
} }
} }
// Check whether the pet is Invisible. If so, we'll show a blanket warning. // Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") { if (petAppearance?.color?.id === "38") {
glitchMessages.push( glitchMessages.push(
<Box key={`invisible-pet-warning`}> <Box key={`invisible-pet-warning`}>
Invisible pets are affected by a number of glitches, including faces Invisible pets are affected by a number of glitches, including faces
sometimes being visible on-site, and errors in the HTML5 conversion. If sometimes being visible on-site, and errors in the HTML5 conversion. If
this pose looks incorrect, you can try another by clicking the emoji 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 face next to the species/color picker. But be aware that Neopets.com
might look different! might look different!
</Box>, </Box>,
); );
} }
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns. // Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
if ( if (
petAppearance?.color?.id === "26" && petAppearance?.color?.id === "26" &&
petAppearance?.species?.id === "49" petAppearance?.species?.id === "49"
) { ) {
glitchMessages.push( glitchMessages.push(
<Box key={`faerie-uni-dithering-horn-warning`}> <Box key={`faerie-uni-dithering-horn-warning`}>
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and 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 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 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 designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time! get, and it will often change over time!
</Box>, </Box>,
); );
} }
// Check whether the pet appearance is marked as Glitched. // Check whether the pet appearance is marked as Glitched.
if (petAppearance?.isGlitched) { if (petAppearance?.isGlitched) {
glitchMessages.push( glitchMessages.push(
// NOTE: This message assumes that the current pet appearance is the // NOTE: This message assumes that the current pet appearance is the
// best canonical one, but it's _possible_ to view Glitched // best canonical one, but it's _possible_ to view Glitched
// appearances even if we _do_ have a better one saved... but // appearances even if we _do_ have a better one saved... but
// only the Support UI ever takes you there. // only the Support UI ever takes you there.
<Box key={`pet-appearance-is-glitched`}> <Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't 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 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 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 switching to another pose, by clicking the emoji face next to the
species/color picker! species/color picker!
</Box>, </Box>,
); );
} }
const petLayers = petAppearance?.layers || []; const petLayers = petAppearance?.layers || [];
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch. // Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const layer of petLayers) { for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes( const layerHasGlitch = (layer.knownGlitches || []).includes(
"OFFICIAL_SWF_IS_INCORRECT", "OFFICIAL_SWF_IS_INCORRECT",
); );
if (layerHasGlitch) { if (layerHasGlitch) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}> <Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "} 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 <i>{layer.zone.label}</i> zone. Last time we checked, this glitch
affected its appearance on Neopets.com, too. But our version might be 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 out of date! If you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it! matchu@openneo.net so we can fix it!
</Box>, </Box>,
); );
} }
} }
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch. // Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
if (hiResMode) { if (hiResMode) {
for (const layer of petLayers) { for (const layer of petLayers) {
const layerHasOfficialSvgIsIncorrect = ( const layerHasOfficialSvgIsIncorrect = (
layer.knownGlitches || [] layer.knownGlitches || []
).includes("OFFICIAL_SVG_IS_INCORRECT"); ).includes("OFFICIAL_SVG_IS_INCORRECT");
if (layerHasOfficialSvgIsIncorrect) { if (layerHasOfficialSvgIsIncorrect) {
glitchMessages.push( glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}> <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>{" "} 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. 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 Instead, we're showing a PNG, which might look a bit blurry on
larger screens. larger screens.
</Box>, </Box>,
); );
} }
} }
} }
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch. // Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const layer of petLayers) { for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes( const layerHasGlitch = (layer.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN", "DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
); );
if (layerHasGlitch) { if (layerHasGlitch) {
glitchMessages.push( glitchMessages.push(
<Box <Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`} 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>{" "} 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 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 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 matchu@openneo.net to let us know how it looks in the on-site
customizer! customizer!
</Box>, </Box>,
); );
} }
} }
if (glitchMessages.length === 0) { if (glitchMessages.length === 0) {
return null; return null;
} }
return ( return (
<GlitchBadgeLayout <GlitchBadgeLayout
aria-label="Has known glitches" aria-label="Has known glitches"
tooltipLabel={ tooltipLabel={
<Box> <Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1"> <Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches Known glitches
</Box> </Box>
<VStack spacing="1em">{glitchMessages}</VStack> <VStack spacing="1em">{glitchMessages}</VStack>
</Box> </Box>
} }
> >
<WarningTwoIcon fontSize="xs" marginRight="1" /> <WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug /> <FaBug />
</GlitchBadgeLayout> </GlitchBadgeLayout>
); );
} }
export default OutfitKnownGlitchesBadge; 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! * while still keeping the rest of the item screen open!
*/ */
function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) { function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage( const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter", "DTIFeatureFlagCanUseSearchFooter",
false, false,
); );
const { items, numTotalPages } = useSearchResults( const { items, numTotalPages } = useSearchResults(
searchQuery, searchQuery,
outfitState, outfitState,
1, 1,
); );
React.useEffect(() => { React.useEffect(() => {
if (window.location.search.includes("feature-flag-can-use-search-footer")) { if (window.location.search.includes("feature-flag-can-use-search-footer")) {
setCanUseSearchFooter(true); setCanUseSearchFooter(true);
} }
}, [setCanUseSearchFooter]); }, [setCanUseSearchFooter]);
// TODO: Show the new footer to other users, too! // TODO: Show the new footer to other users, too!
if (!canUseSearchFooter) { if (!canUseSearchFooter) {
return null; return null;
} }
return ( return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Box> <Box>
<Box paddingX="4" paddingY="4"> <Box paddingX="4" paddingY="4">
<Flex as="label" align="center"> <Flex as="label" align="center">
<Box fontWeight="600" flex="0 0 auto"> <Box fontWeight="600" flex="0 0 auto">
Add new items: Add new items:
</Box> </Box>
<Box width="8" /> <Box width="8" />
<SearchToolbar <SearchToolbar
query={searchQuery} query={searchQuery}
onChange={onChangeSearchQuery} onChange={onChangeSearchQuery}
flex="0 1 100%" flex="0 1 100%"
suggestionsPlacement="top" suggestionsPlacement="top"
/> />
<Box width="8" /> <Box width="8" />
{numTotalPages != null && ( {numTotalPages != null && (
<Box flex="0 0 auto"> <Box flex="0 0 auto">
<PaginationToolbar <PaginationToolbar
numTotalPages={numTotalPages} numTotalPages={numTotalPages}
currentPageNumber={1} currentPageNumber={1}
goToPageNumber={() => alert("TODO")} goToPageNumber={() => alert("TODO")}
buildPageUrl={() => null} buildPageUrl={() => null}
size="sm" size="sm"
/> />
</Box> </Box>
)} )}
</Flex> </Flex>
</Box> </Box>
<Box maxHeight="32" overflow="auto"> <Box maxHeight="32" overflow="auto">
<Box as="ul" listStyleType="disc" paddingLeft="8"> <Box as="ul" listStyleType="disc" paddingLeft="8">
{items.map((item) => ( {items.map((item) => (
<Box key={item.id} as="li"> <Box key={item.id} as="li">
{item.name} {item.name}
</Box> </Box>
))} ))}
</Box> </Box>
</Box> </Box>
</Box> </Box>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }
export default SearchFooter; export default SearchFooter;

View file

@ -16,54 +16,54 @@ export const SEARCH_PER_PAGE = 30;
* keyboard and focus interactions. * keyboard and focus interactions.
*/ */
function SearchPanel({ function SearchPanel({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
scrollContainerRef, scrollContainerRef,
searchQueryRef, searchQueryRef,
firstSearchResultRef, firstSearchResultRef,
}) { }) {
const scrollToTop = React.useCallback(() => { const scrollToTop = React.useCallback(() => {
if (scrollContainerRef.current) { if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0; scrollContainerRef.current.scrollTop = 0;
} }
}, [scrollContainerRef]); }, [scrollContainerRef]);
// Sometimes we want to give focus back to the search field! // Sometimes we want to give focus back to the search field!
const onMoveFocusUpToQuery = (e) => { const onMoveFocusUpToQuery = (e) => {
if (searchQueryRef.current) { if (searchQueryRef.current) {
searchQueryRef.current.focus(); searchQueryRef.current.focus();
e.preventDefault(); e.preventDefault();
} }
}; };
return ( return (
<Box <Box
onKeyDown={(e) => { onKeyDown={(e) => {
// This will catch any Escape presses when the user's focus is inside // This will catch any Escape presses when the user's focus is inside
// the SearchPanel. // the SearchPanel.
if (e.key === "Escape") { if (e.key === "Escape") {
onMoveFocusUpToQuery(e); onMoveFocusUpToQuery(e);
} }
}} }}
> >
<SearchResults <SearchResults
// When the query changes, replace the SearchResults component with a // When the query changes, replace the SearchResults component with a
// new instance. This resets both `currentPageNumber`, to take us back // new instance. This resets both `currentPageNumber`, to take us back
// to page 1; and also `itemIdsToReconsider`. That way, if you find an // 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 // 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 // try a conflicting item, we'll restore the item you liked from your
// first search! // first search!
key={serializeQuery(query)} key={serializeQuery(query)}
query={query} query={query}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef} firstSearchResultRef={firstSearchResultRef}
scrollToTop={scrollToTop} scrollToTop={scrollToTop}
onMoveFocusUpToQuery={onMoveFocusUpToQuery} onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/> />
</Box> </Box>
); );
} }
/** /**
@ -75,191 +75,191 @@ function SearchPanel({
* the list screen-reader- and keyboard-accessible! * the list screen-reader- and keyboard-accessible!
*/ */
function SearchResults({ function SearchResults({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
firstSearchResultRef, firstSearchResultRef,
scrollToTop, scrollToTop,
onMoveFocusUpToQuery, onMoveFocusUpToQuery,
}) { }) {
const [currentPageNumber, setCurrentPageNumber] = React.useState(1); const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
const { loading, error, items, numTotalPages } = useSearchResults( const { loading, error, items, numTotalPages } = useSearchResults(
query, query,
outfitState, outfitState,
currentPageNumber, currentPageNumber,
); );
// Preload the previous and next page of search results, with this quick // Preload the previous and next page of search results, with this quick
// ~hacky trick: just `useSearchResults` two more times, with some extra // ~hacky trick: just `useSearchResults` two more times, with some extra
// attention to skip the query when we don't know if it will exist! // attention to skip the query when we don't know if it will exist!
useSearchResults(query, outfitState, currentPageNumber - 1, { useSearchResults(query, outfitState, currentPageNumber - 1, {
skip: currentPageNumber <= 1, skip: currentPageNumber <= 1,
}); });
useSearchResults(query, outfitState, currentPageNumber + 1, { useSearchResults(query, outfitState, currentPageNumber + 1, {
skip: numTotalPages == null || currentPageNumber >= numTotalPages, skip: numTotalPages == null || currentPageNumber >= numTotalPages,
}); });
// This will save the `wornItemIds` when the SearchResults first mounts, and // 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 // 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 // these items after the user makes changes, e.g., after they try on another
// Background we want to restore the previous one! // Background we want to restore the previous one!
const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds); const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
// Whenever the page number changes, scroll back to the top! // Whenever the page number changes, scroll back to the top!
React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]); React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
// You can use UpArrow/DownArrow to navigate between items, and even back up // You can use UpArrow/DownArrow to navigate between items, and even back up
// to the search field! // to the search field!
const goToPrevItem = React.useCallback( const goToPrevItem = React.useCallback(
(e) => { (e) => {
const prevLabel = e.target.closest("label").previousSibling; const prevLabel = e.target.closest("label").previousSibling;
if (prevLabel) { if (prevLabel) {
prevLabel.querySelector("input[type=checkbox]").focus(); prevLabel.querySelector("input[type=checkbox]").focus();
prevLabel.scrollIntoView({ block: "center" }); prevLabel.scrollIntoView({ block: "center" });
e.preventDefault(); e.preventDefault();
} else { } else {
// If we're at the top of the list, move back up to the search box! // If we're at the top of the list, move back up to the search box!
onMoveFocusUpToQuery(e); onMoveFocusUpToQuery(e);
} }
}, },
[onMoveFocusUpToQuery], [onMoveFocusUpToQuery],
); );
const goToNextItem = React.useCallback((e) => { const goToNextItem = React.useCallback((e) => {
const nextLabel = e.target.closest("label").nextSibling; const nextLabel = e.target.closest("label").nextSibling;
if (nextLabel) { if (nextLabel) {
nextLabel.querySelector("input[type=checkbox]").focus(); nextLabel.querySelector("input[type=checkbox]").focus();
nextLabel.scrollIntoView({ block: "center" }); nextLabel.scrollIntoView({ block: "center" });
e.preventDefault(); e.preventDefault();
} }
}, []); }, []);
const searchPanelBackground = useColorModeValue("white", "gray.900"); const searchPanelBackground = useColorModeValue("white", "gray.900");
if (error) { if (error) {
return <MajorErrorMessage error={error} variant="network" />; return <MajorErrorMessage error={error} variant="network" />;
} }
// Finally, render the item list, with checkboxes and Item components! // Finally, render the item list, with checkboxes and Item components!
// We also render some extra skeleton items at the bottom during infinite // We also render some extra skeleton items at the bottom during infinite
// scroll loading. // scroll loading.
return ( return (
<Box> <Box>
<Box <Box
position="sticky" position="sticky"
top="0" top="0"
background={searchPanelBackground} background={searchPanelBackground}
zIndex="2" zIndex="2"
paddingX="5" paddingX="5"
paddingBottom="2" paddingBottom="2"
paddingTop="1" paddingTop="1"
> >
<PaginationToolbar <PaginationToolbar
numTotalPages={numTotalPages} numTotalPages={numTotalPages}
currentPageNumber={currentPageNumber} currentPageNumber={currentPageNumber}
goToPageNumber={setCurrentPageNumber} goToPageNumber={setCurrentPageNumber}
buildPageUrl={() => null} buildPageUrl={() => null}
size="sm" size="sm"
/> />
</Box> </Box>
<ItemListContainer paddingX="4" paddingBottom="2"> <ItemListContainer paddingX="4" paddingBottom="2">
{items.map((item, index) => ( {items.map((item, index) => (
<SearchResultItem <SearchResultItem
key={item.id} key={item.id}
item={item} item={item}
itemIdsToReconsider={itemIdsToReconsider} itemIdsToReconsider={itemIdsToReconsider}
isWorn={outfitState.wornItemIds.includes(item.id)} isWorn={outfitState.wornItemIds.includes(item.id)}
isInOutfit={outfitState.allItemIds.includes(item.id)} isInOutfit={outfitState.allItemIds.includes(item.id)}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
checkboxRef={index === 0 ? firstSearchResultRef : null} checkboxRef={index === 0 ? firstSearchResultRef : null}
goToPrevItem={goToPrevItem} goToPrevItem={goToPrevItem}
goToNextItem={goToNextItem} goToNextItem={goToNextItem}
/> />
))} ))}
</ItemListContainer> </ItemListContainer>
{loading && ( {loading && (
<ItemListSkeleton <ItemListSkeleton
count={SEARCH_PER_PAGE} count={SEARCH_PER_PAGE}
paddingX="4" paddingX="4"
paddingBottom="2" paddingBottom="2"
/> />
)} )}
{!loading && items.length === 0 && ( {!loading && items.length === 0 && (
<Text paddingX="4"> <Text paddingX="4">
We couldn't find any matching items{" "} We couldn't find any matching items{" "}
<span role="img" aria-label="(thinking emoji)"> <span role="img" aria-label="(thinking emoji)">
🤔 🤔
</span>{" "} </span>{" "}
Try again? Try again?
</Text> </Text>
)} )}
</Box> </Box>
); );
} }
function SearchResultItem({ function SearchResultItem({
item, item,
itemIdsToReconsider, itemIdsToReconsider,
isWorn, isWorn,
isInOutfit, isInOutfit,
dispatchToOutfit, dispatchToOutfit,
checkboxRef, checkboxRef,
goToPrevItem, goToPrevItem,
goToNextItem, goToNextItem,
}) { }) {
// It's important to use `useCallback` for `onRemove`, to avoid re-rendering // It's important to use `useCallback` for `onRemove`, to avoid re-rendering
// the whole list of <Item>s! // the whole list of <Item>s!
const onRemove = React.useCallback( const onRemove = React.useCallback(
() => () =>
dispatchToOutfit({ dispatchToOutfit({
type: "removeItem", type: "removeItem",
itemId: item.id, itemId: item.id,
itemIdsToReconsider, itemIdsToReconsider,
}), }),
[item.id, itemIdsToReconsider, dispatchToOutfit], [item.id, itemIdsToReconsider, dispatchToOutfit],
); );
return ( return (
// We're wrapping the control inside the label, which works just fine! // We're wrapping the control inside the label, which works just fine!
// eslint-disable-next-line jsx-a11y/label-has-associated-control // eslint-disable-next-line jsx-a11y/label-has-associated-control
<label> <label>
<VisuallyHidden <VisuallyHidden
as="input" as="input"
type="checkbox" type="checkbox"
aria-label={`Wear "${item.name}"`} aria-label={`Wear "${item.name}"`}
value={item.id} value={item.id}
checked={isWorn} checked={isWorn}
ref={checkboxRef} ref={checkboxRef}
onChange={(e) => { onChange={(e) => {
const itemId = e.target.value; const itemId = e.target.value;
const willBeWorn = e.target.checked; const willBeWorn = e.target.checked;
if (willBeWorn) { if (willBeWorn) {
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider }); dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
} else { } else {
dispatchToOutfit({ dispatchToOutfit({
type: "unwearItem", type: "unwearItem",
itemId, itemId,
itemIdsToReconsider, itemIdsToReconsider,
}); });
} }
}} }}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
e.target.click(); e.target.click();
} else if (e.key === "ArrowUp") { } else if (e.key === "ArrowUp") {
goToPrevItem(e); goToPrevItem(e);
} else if (e.key === "ArrowDown") { } else if (e.key === "ArrowDown") {
goToNextItem(e); goToNextItem(e);
} }
}} }}
/> />
<Item <Item
item={item} item={item}
isWorn={isWorn} isWorn={isWorn}
isInOutfit={isInOutfit} isInOutfit={isInOutfit}
onRemove={onRemove} onRemove={onRemove}
/> />
</label> </label>
); );
} }
/** /**
@ -267,12 +267,12 @@ function SearchResultItem({
* JS comparison. * JS comparison.
*/ */
function serializeQuery(query) { function serializeQuery(query) {
return `${JSON.stringify([ return `${JSON.stringify([
query.value, query.value,
query.filterToItemKind, query.filterToItemKind,
query.filterToZoneLabel, query.filterToZoneLabel,
query.filterToCurrentUserOwnsOrWants, query.filterToCurrentUserOwnsOrWants,
])}`; ])}`;
} }
export default SearchPanel; export default SearchPanel;

View file

@ -2,21 +2,21 @@ import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { import {
Box, Box,
IconButton, IconButton,
Input, Input,
InputGroup, InputGroup,
InputLeftAddon, InputLeftAddon,
InputLeftElement, InputLeftElement,
InputRightElement, InputRightElement,
Tooltip, Tooltip,
useColorModeValue, useColorModeValue,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
ChevronDownIcon, ChevronDownIcon,
ChevronUpIcon, ChevronUpIcon,
CloseIcon, CloseIcon,
SearchIcon, SearchIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { ClassNames } from "@emotion/react"; import { ClassNames } from "@emotion/react";
import Autosuggest from "react-autosuggest"; import Autosuggest from "react-autosuggest";
@ -25,25 +25,25 @@ import useCurrentUser from "../components/useCurrentUser";
import { logAndCapture } from "../util"; import { logAndCapture } from "../util";
export const emptySearchQuery = { export const emptySearchQuery = {
value: "", value: "",
filterToZoneLabel: null, filterToZoneLabel: null,
filterToItemKind: null, filterToItemKind: null,
filterToCurrentUserOwnsOrWants: null, filterToCurrentUserOwnsOrWants: null,
}; };
export function searchQueryIsEmpty(query) { export function searchQueryIsEmpty(query) {
return Object.values(query).every((value) => !value); return Object.values(query).every((value) => !value);
} }
const SUGGESTIONS_PLACEMENT_PROPS = { const SUGGESTIONS_PLACEMENT_PROPS = {
inline: { inline: {
borderBottomRadius: "md", borderBottomRadius: "md",
}, },
top: { top: {
position: "absolute", position: "absolute",
bottom: "100%", bottom: "100%",
borderTopRadius: "md", borderTopRadius: "md",
}, },
}; };
/** /**
@ -56,387 +56,387 @@ const SUGGESTIONS_PLACEMENT_PROPS = {
* from anywhere, or UpArrow from the first result!) * from anywhere, or UpArrow from the first result!)
*/ */
function SearchToolbar({ function SearchToolbar({
query, query,
searchQueryRef, searchQueryRef,
firstSearchResultRef = null, firstSearchResultRef = null,
onChange, onChange,
autoFocus, autoFocus,
showItemsLabel = false, showItemsLabel = false,
background = null, background = null,
boxShadow = null, boxShadow = null,
suggestionsPlacement = "inline", suggestionsPlacement = "inline",
...props ...props
}) { }) {
const [suggestions, setSuggestions] = React.useState([]); const [suggestions, setSuggestions] = React.useState([]);
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false); const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
const { isLoggedIn } = useCurrentUser(); const { isLoggedIn } = useCurrentUser();
// NOTE: This query should always load ~instantly, from the client cache. // NOTE: This query should always load ~instantly, from the client cache.
const { data } = useQuery(gql` const { data } = useQuery(gql`
query SearchToolbarZones { query SearchToolbarZones {
allZones { allZones {
id id
label label
depth depth
isCommonlyUsedByItems isCommonlyUsedByItems
} }
} }
`); `);
const zones = data?.allZones || []; const zones = data?.allZones || [];
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems); const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
let zoneLabels = itemZones.map((z) => z.label); let zoneLabels = itemZones.map((z) => z.label);
zoneLabels = [...new Set(zoneLabels)]; zoneLabels = [...new Set(zoneLabels)];
zoneLabels.sort(); zoneLabels.sort();
const onMoveFocusDownToResults = (e) => { const onMoveFocusDownToResults = (e) => {
if (firstSearchResultRef && firstSearchResultRef.current) { if (firstSearchResultRef && firstSearchResultRef.current) {
firstSearchResultRef.current.focus(); firstSearchResultRef.current.focus();
e.preventDefault(); e.preventDefault();
} }
}; };
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100"); const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300"); const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
const renderSuggestion = React.useCallback( const renderSuggestion = React.useCallback(
({ text }, { isHighlighted }) => ( ({ text }, { isHighlighted }) => (
<Box <Box
fontWeight={isHighlighted ? "bold" : "normal"} fontWeight={isHighlighted ? "bold" : "normal"}
background={isHighlighted ? highlightedBgColor : suggestionBgColor} background={isHighlighted ? highlightedBgColor : suggestionBgColor}
padding="2" padding="2"
paddingLeft="2.5rem" paddingLeft="2.5rem"
fontSize="sm" fontSize="sm"
> >
{text} {text}
</Box> </Box>
), ),
[suggestionBgColor, highlightedBgColor], [suggestionBgColor, highlightedBgColor],
); );
const renderSuggestionsContainer = React.useCallback( const renderSuggestionsContainer = React.useCallback(
({ containerProps, children }) => { ({ containerProps, children }) => {
const { className, ...otherContainerProps } = containerProps; const { className, ...otherContainerProps } = containerProps;
return ( return (
<ClassNames> <ClassNames>
{({ css, cx }) => ( {({ css, cx }) => (
<Box <Box
{...otherContainerProps} {...otherContainerProps}
boxShadow="md" boxShadow="md"
overflow="auto" overflow="auto"
transition="all 0.4s" transition="all 0.4s"
maxHeight="48" maxHeight="48"
width="100%" width="100%"
className={cx( className={cx(
className, className,
css` css`
li { li {
list-style: none; list-style: none;
} }
`, `,
)} )}
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]} {...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
> >
{children} {children}
{!children && advancedSearchIsOpen && ( {!children && advancedSearchIsOpen && (
<Box <Box
padding="4" padding="4"
fontSize="sm" fontSize="sm"
fontStyle="italic" fontStyle="italic"
textAlign="center" textAlign="center"
> >
No more filters available! No more filters available!
</Box> </Box>
)} )}
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
}, },
[advancedSearchIsOpen, suggestionsPlacement], [advancedSearchIsOpen, suggestionsPlacement],
); );
// When we change the query filters, clear out the suggestions. // When we change the query filters, clear out the suggestions.
React.useEffect(() => { React.useEffect(() => {
setSuggestions([]); setSuggestions([]);
}, [ }, [
query.filterToItemKind, query.filterToItemKind,
query.filterToZoneLabel, query.filterToZoneLabel,
query.filterToCurrentUserOwnsOrWants, query.filterToCurrentUserOwnsOrWants,
]); ]);
let queryFilterText = getQueryFilterText(query); let queryFilterText = getQueryFilterText(query);
if (showItemsLabel) { if (showItemsLabel) {
queryFilterText = queryFilterText ? ( queryFilterText = queryFilterText ? (
<> <>
<Box as="span" fontWeight="600"> <Box as="span" fontWeight="600">
Items: Items:
</Box>{" "} </Box>{" "}
{queryFilterText} {queryFilterText}
</> </>
) : ( ) : (
<Box as="span" fontWeight="600"> <Box as="span" fontWeight="600">
Items Items
</Box> </Box>
); );
} }
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, { const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
showAll: true, showAll: true,
}); });
// Once you remove the final suggestion available, close Advanced Search. We // Once you remove the final suggestion available, close Advanced Search. We
// have placeholder text available, sure, but this feels more natural! // have placeholder text available, sure, but this feels more natural!
React.useEffect(() => { React.useEffect(() => {
if (allSuggestions.length === 0) { if (allSuggestions.length === 0) {
setAdvancedSearchIsOpen(false); setAdvancedSearchIsOpen(false);
} }
}, [allSuggestions.length]); }, [allSuggestions.length]);
const focusBorderColor = useColorModeValue("green.600", "green.400"); const focusBorderColor = useColorModeValue("green.600", "green.400");
return ( return (
<Box position="relative" {...props}> <Box position="relative" {...props}>
<Autosuggest <Autosuggest
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions} suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
onSuggestionsFetchRequested={({ value }) => { onSuggestionsFetchRequested={({ value }) => {
// HACK: I'm not sure why, but apparently this gets called with value // HACK: I'm not sure why, but apparently this gets called with value
// set to the _chosen suggestion_ after choosing it? Has that // set to the _chosen suggestion_ after choosing it? Has that
// always happened? Idk? Let's just, gate around it, I guess? // always happened? Idk? Let's just, gate around it, I guess?
if (typeof value === "string") { if (typeof value === "string") {
setSuggestions( setSuggestions(
getSuggestions(value, query, zoneLabels, isLoggedIn), getSuggestions(value, query, zoneLabels, isLoggedIn),
); );
} }
}} }}
onSuggestionSelected={(e, { suggestion }) => { onSuggestionSelected={(e, { suggestion }) => {
onChange({ onChange({
...query, ...query,
// If the suggestion was from typing, remove the last word of the // If the suggestion was from typing, remove the last word of the
// query value. Or, if it was from Advanced Search, leave it alone! // query value. Or, if it was from Advanced Search, leave it alone!
value: advancedSearchIsOpen value: advancedSearchIsOpen
? query.value ? query.value
: removeLastWord(query.value), : removeLastWord(query.value),
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel, filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
filterToItemKind: suggestion.itemKind || query.filterToItemKind, filterToItemKind: suggestion.itemKind || query.filterToItemKind,
filterToCurrentUserOwnsOrWants: filterToCurrentUserOwnsOrWants:
suggestion.userOwnsOrWants || suggestion.userOwnsOrWants ||
query.filterToCurrentUserOwnsOrWants, query.filterToCurrentUserOwnsOrWants,
}); });
}} }}
getSuggestionValue={(zl) => zl} getSuggestionValue={(zl) => zl}
alwaysRenderSuggestions={true} alwaysRenderSuggestions={true}
renderSuggestion={renderSuggestion} renderSuggestion={renderSuggestion}
renderSuggestionsContainer={renderSuggestionsContainer} renderSuggestionsContainer={renderSuggestionsContainer}
renderInputComponent={(inputProps) => ( renderInputComponent={(inputProps) => (
<InputGroup boxShadow={boxShadow} borderRadius="md"> <InputGroup boxShadow={boxShadow} borderRadius="md">
{queryFilterText ? ( {queryFilterText ? (
<InputLeftAddon> <InputLeftAddon>
<SearchIcon color="gray.400" marginRight="3" /> <SearchIcon color="gray.400" marginRight="3" />
<Box fontSize="sm">{queryFilterText}</Box> <Box fontSize="sm">{queryFilterText}</Box>
</InputLeftAddon> </InputLeftAddon>
) : ( ) : (
<InputLeftElement> <InputLeftElement>
<SearchIcon color="gray.400" /> <SearchIcon color="gray.400" />
</InputLeftElement> </InputLeftElement>
)} )}
<Input <Input
background={background} background={background}
// TODO: How to improve a11y here? // TODO: How to improve a11y here?
// eslint-disable-next-line jsx-a11y/no-autofocus // eslint-disable-next-line jsx-a11y/no-autofocus
autoFocus={autoFocus} autoFocus={autoFocus}
{...inputProps} {...inputProps}
/> />
<InputRightElement <InputRightElement
width="auto" width="auto"
justifyContent="flex-end" justifyContent="flex-end"
paddingRight="2px" paddingRight="2px"
paddingY="2px" paddingY="2px"
> >
{!searchQueryIsEmpty(query) && ( {!searchQueryIsEmpty(query) && (
<Tooltip label="Clear"> <Tooltip label="Clear">
<IconButton <IconButton
icon={<CloseIcon fontSize="0.6em" />} icon={<CloseIcon fontSize="0.6em" />}
color="gray.400" color="gray.400"
variant="ghost" variant="ghost"
height="100%" height="100%"
marginLeft="1" marginLeft="1"
aria-label="Clear search" aria-label="Clear search"
onClick={() => { onClick={() => {
setSuggestions([]); setSuggestions([]);
onChange(emptySearchQuery); onChange(emptySearchQuery);
}} }}
/> />
</Tooltip> </Tooltip>
)} )}
<Tooltip label="Advanced search"> <Tooltip label="Advanced search">
<IconButton <IconButton
icon={ icon={
advancedSearchIsOpen ? ( advancedSearchIsOpen ? (
<ChevronUpIcon fontSize="1.5em" /> <ChevronUpIcon fontSize="1.5em" />
) : ( ) : (
<ChevronDownIcon fontSize="1.5em" /> <ChevronDownIcon fontSize="1.5em" />
) )
} }
color="gray.400" color="gray.400"
variant="ghost" variant="ghost"
height="100%" height="100%"
aria-label="Open advanced search" aria-label="Open advanced search"
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)} onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
/> />
</Tooltip> </Tooltip>
</InputRightElement> </InputRightElement>
</InputGroup> </InputGroup>
)} )}
inputProps={{ inputProps={{
placeholder: "Search all items…", placeholder: "Search all items…",
focusBorderColor: focusBorderColor, focusBorderColor: focusBorderColor,
value: query.value || "", value: query.value || "",
ref: searchQueryRef, ref: searchQueryRef,
minWidth: 0, minWidth: 0,
"data-test-id": "item-search-input", "data-test-id": "item-search-input",
onChange: (e, { newValue, method }) => { onChange: (e, { newValue, method }) => {
// The Autosuggest tries to change the _entire_ value of the element // The Autosuggest tries to change the _entire_ value of the element
// when navigating suggestions, which isn't actually what we want. // when navigating suggestions, which isn't actually what we want.
// Only accept value changes that are typed by the user! // Only accept value changes that are typed by the user!
if (method === "type") { if (method === "type") {
onChange({ ...query, value: newValue }); onChange({ ...query, value: newValue });
} }
}, },
onKeyDown: (e) => { onKeyDown: (e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
if (suggestions.length > 0) { if (suggestions.length > 0) {
setSuggestions([]); setSuggestions([]);
return; return;
} }
onChange(emptySearchQuery); onChange(emptySearchQuery);
e.target.blur(); e.target.blur();
} else if (e.key === "Enter") { } else if (e.key === "Enter") {
// Pressing Enter doesn't actually submit because it's all on // Pressing Enter doesn't actually submit because it's all on
// debounce, but it can be a declaration that the query is done, so // debounce, but it can be a declaration that the query is done, so
// filter suggestions should go away! // filter suggestions should go away!
if (suggestions.length > 0) { if (suggestions.length > 0) {
setSuggestions([]); setSuggestions([]);
return; return;
} }
} else if (e.key === "ArrowDown") { } else if (e.key === "ArrowDown") {
if (suggestions.length > 0) { if (suggestions.length > 0) {
return; return;
} }
onMoveFocusDownToResults(e); onMoveFocusDownToResults(e);
} else if (e.key === "Backspace" && e.target.selectionStart === 0) { } else if (e.key === "Backspace" && e.target.selectionStart === 0) {
onChange({ onChange({
...query, ...query,
filterToItemKind: null, filterToItemKind: null,
filterToZoneLabel: null, filterToZoneLabel: null,
filterToCurrentUserOwnsOrWants: null, filterToCurrentUserOwnsOrWants: null,
}); });
} }
}, },
}} }}
/> />
</Box> </Box>
); );
} }
function getSuggestions( function getSuggestions(
value, value,
query, query,
zoneLabels, zoneLabels,
isLoggedIn, isLoggedIn,
{ showAll = false } = {}, { showAll = false } = {},
) { ) {
if (!value && !showAll) { if (!value && !showAll) {
return []; return [];
} }
const words = (value || "").split(/\s+/); const words = (value || "").split(/\s+/);
const lastWord = words[words.length - 1]; const lastWord = words[words.length - 1];
if (lastWord.length < 2 && !showAll) { if (lastWord.length < 2 && !showAll) {
return []; return [];
} }
const suggestions = []; const suggestions = [];
if (query.filterToItemKind == null) { if (query.filterToItemKind == null) {
if ( if (
wordMatches("NC", lastWord) || wordMatches("NC", lastWord) ||
wordMatches("Neocash", lastWord) || wordMatches("Neocash", lastWord) ||
showAll showAll
) { ) {
suggestions.push({ itemKind: "NC", text: "Neocash items" }); suggestions.push({ itemKind: "NC", text: "Neocash items" });
} }
if ( if (
wordMatches("NP", lastWord) || wordMatches("NP", lastWord) ||
wordMatches("Neopoints", lastWord) || wordMatches("Neopoints", lastWord) ||
showAll showAll
) { ) {
suggestions.push({ itemKind: "NP", text: "Neopoint items" }); suggestions.push({ itemKind: "NP", text: "Neopoint items" });
} }
if ( if (
wordMatches("PB", lastWord) || wordMatches("PB", lastWord) ||
wordMatches("Paintbrush", lastWord) || wordMatches("Paintbrush", lastWord) ||
showAll showAll
) { ) {
suggestions.push({ itemKind: "PB", text: "Paintbrush items" }); suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
} }
} }
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) { if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
if (wordMatches("Items you own", lastWord) || showAll) { if (wordMatches("Items you own", lastWord) || showAll) {
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" }); suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
} }
if (wordMatches("Items you want", lastWord) || showAll) { if (wordMatches("Items you want", lastWord) || showAll) {
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" }); suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
} }
} }
if (query.filterToZoneLabel == null) { if (query.filterToZoneLabel == null) {
for (const zoneLabel of zoneLabels) { for (const zoneLabel of zoneLabels) {
if (wordMatches(zoneLabel, lastWord) || showAll) { if (wordMatches(zoneLabel, lastWord) || showAll) {
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` }); suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
} }
} }
} }
return suggestions; return suggestions;
} }
function wordMatches(target, word) { function wordMatches(target, word) {
return target.toLowerCase().includes(word.toLowerCase()); return target.toLowerCase().includes(word.toLowerCase());
} }
function getQueryFilterText(query) { function getQueryFilterText(query) {
const textWords = []; const textWords = [];
if (query.filterToItemKind) { if (query.filterToItemKind) {
textWords.push(query.filterToItemKind); textWords.push(query.filterToItemKind);
} }
if (query.filterToZoneLabel) { if (query.filterToZoneLabel) {
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel)); textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
} }
if (query.filterToCurrentUserOwnsOrWants === "OWNS") { if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
if (!query.filterToItemKind && !query.filterToZoneLabel) { if (!query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("Items"); textWords.push("Items");
} else if (query.filterToItemKind && !query.filterToZoneLabel) { } else if (query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("items"); textWords.push("items");
} }
textWords.push("you own"); textWords.push("you own");
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") { } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
if (!query.filterToItemKind && !query.filterToZoneLabel) { if (!query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("Items"); textWords.push("Items");
} else if (query.filterToItemKind && !query.filterToZoneLabel) { } else if (query.filterToItemKind && !query.filterToZoneLabel) {
textWords.push("items"); textWords.push("items");
} }
textWords.push("you want"); 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! ¯\_ ()_/¯ * manually creating the plural for each zone. But, ehh! ¯\_ ()_/¯
*/ */
function pluralizeZoneLabel(zoneLabel) { function pluralizeZoneLabel(zoneLabel) {
if (zoneLabel.endsWith("ss")) { if (zoneLabel.endsWith("ss")) {
return zoneLabel + "es"; return zoneLabel + "es";
} else if (zoneLabel.endsWith("s")) { } else if (zoneLabel.endsWith("s")) {
return zoneLabel; return zoneLabel;
} else { } else {
return zoneLabel + "s"; return zoneLabel + "s";
} }
} }
/** /**
@ -460,22 +460,22 @@ function pluralizeZoneLabel(zoneLabel) {
* preceding space removed. * preceding space removed.
*/ */
function removeLastWord(text) { function removeLastWord(text) {
// This regex matches the full text, and assigns the last word and any // 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 // 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 // there's no last word, we'll still match, and the full string will be in
// subgroup 1, including any space - no changes made! // subgroup 1, including any space - no changes made!
const match = text.match(/^(.*?)(\s*\S+)?$/); const match = text.match(/^(.*?)(\s*\S+)?$/);
if (!match) { if (!match) {
logAndCapture( logAndCapture(
new Error( new Error(
`Assertion failure: pattern should match any input text, ` + `Assertion failure: pattern should match any input text, ` +
`but failed to match ${JSON.stringify(text)}`, `but failed to match ${JSON.stringify(text)}`,
), ),
); );
return text; return text;
} }
return match[1]; return match[1];
} }
export default SearchToolbar; export default SearchToolbar;

View file

@ -3,65 +3,65 @@ import { Box, Grid, useColorModeValue, useToken } from "@chakra-ui/react";
import { useCommonStyles } from "../util"; import { useCommonStyles } from "../util";
function WardrobePageLayout({ function WardrobePageLayout({
previewAndControls = null, previewAndControls = null,
itemsAndMaybeSearchPanel = null, itemsAndMaybeSearchPanel = null,
searchFooter = null, searchFooter = null,
}) { }) {
const itemsAndSearchBackground = useColorModeValue("white", "gray.900"); const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
const searchBackground = useCommonStyles().bodyBackground; const searchBackground = useCommonStyles().bodyBackground;
const searchShadowColorValue = useToken("colors", "gray.400"); const searchShadowColorValue = useToken("colors", "gray.400");
return ( return (
<Box <Box
position="absolute" position="absolute"
top="0" top="0"
bottom="0" bottom="0"
left="0" left="0"
right="0" right="0"
// Create a stacking context, so that our drawers and modals don't fight // Create a stacking context, so that our drawers and modals don't fight
// with the z-indexes in here! // with the z-indexes in here!
zIndex="0" zIndex="0"
> >
<Grid <Grid
templateAreas={{ templateAreas={{
base: `"previewAndControls" base: `"previewAndControls"
"itemsAndMaybeSearchPanel"`, "itemsAndMaybeSearchPanel"`,
md: `"previewAndControls itemsAndMaybeSearchPanel" md: `"previewAndControls itemsAndMaybeSearchPanel"
"searchFooter searchFooter"`, "searchFooter searchFooter"`,
}} }}
templateRows={{ templateRows={{
base: "minmax(100px, 45%) minmax(300px, 55%)", base: "minmax(100px, 45%) minmax(300px, 55%)",
md: "minmax(300px, 1fr) auto", md: "minmax(300px, 1fr) auto",
}} }}
templateColumns={{ templateColumns={{
base: "100%", base: "100%",
md: "50% 50%", md: "50% 50%",
}} }}
height="100%" height="100%"
width="100%" width="100%"
> >
<Box <Box
gridArea="previewAndControls" gridArea="previewAndControls"
bg="gray.900" bg="gray.900"
color="gray.50" color="gray.50"
position="relative" position="relative"
> >
{previewAndControls} {previewAndControls}
</Box> </Box>
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}> <Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
{itemsAndMaybeSearchPanel} {itemsAndMaybeSearchPanel}
</Box> </Box>
<Box <Box
gridArea="searchFooter" gridArea="searchFooter"
bg={searchBackground} bg={searchBackground}
boxShadow={`0 0 8px ${searchShadowColorValue}`} boxShadow={`0 0 8px ${searchShadowColorValue}`}
display={{ base: "none", md: "block" }} display={{ base: "none", md: "block" }}
> >
{searchFooter} {searchFooter}
</Box> </Box>
</Grid> </Grid>
</Box> </Box>
); );
} }
export default WardrobePageLayout; export default WardrobePageLayout;

View file

@ -11,43 +11,43 @@ import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
const OutfitControls = loadable(() => import("./OutfitControls")); const OutfitControls = loadable(() => import("./OutfitControls"));
function WardrobePreviewAndControls({ function WardrobePreviewAndControls({
isLoading, isLoading,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
}) { }) {
// Whether the current outfit preview has animations. Determines whether we // Whether the current outfit preview has animations. Determines whether we
// show the play/pause button. // show the play/pause button.
const [hasAnimations, setHasAnimations] = React.useState(false); const [hasAnimations, setHasAnimations] = React.useState(false);
const { appearance, preview } = useOutfitPreview({ const { appearance, preview } = useOutfitPreview({
isLoading: isLoading, isLoading: isLoading,
speciesId: outfitState.speciesId, speciesId: outfitState.speciesId,
colorId: outfitState.colorId, colorId: outfitState.colorId,
pose: outfitState.pose, pose: outfitState.pose,
altStyleId: outfitState.altStyleId, altStyleId: outfitState.altStyleId,
appearanceId: outfitState.appearanceId, appearanceId: outfitState.appearanceId,
wornItemIds: outfitState.wornItemIds, wornItemIds: outfitState.wornItemIds,
onChangeHasAnimations: setHasAnimations, onChangeHasAnimations: setHasAnimations,
placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />, placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
"data-test-id": "wardrobe-outfit-preview", "data-test-id": "wardrobe-outfit-preview",
}); });
return ( return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}> <Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender /> <TestErrorSender />
<Center position="absolute" top="0" bottom="0" left="0" right="0"> <Center position="absolute" top="0" bottom="0" left="0" right="0">
<DarkMode>{preview}</DarkMode> <DarkMode>{preview}</DarkMode>
</Center> </Center>
<Box position="absolute" top="0" bottom="0" left="0" right="0"> <Box position="absolute" top="0" bottom="0" left="0" right="0">
<OutfitControls <OutfitControls
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
showAnimationControls={hasAnimations} showAnimationControls={hasAnimations}
appearance={appearance} appearance={appearance}
/> />
</Box> </Box>
</Sentry.ErrorBoundary> </Sentry.ErrorBoundary>
); );
} }
/** /**
@ -61,40 +61,40 @@ function WardrobePreviewAndControls({
* like usual! * like usual!
*/ */
function OutfitThumbnailIfCached({ outfitId }) { function OutfitThumbnailIfCached({ outfitId }) {
const { data } = useQuery( const { data } = useQuery(
gql` gql`
query OutfitThumbnailIfCached($outfitId: ID!) { query OutfitThumbnailIfCached($outfitId: ID!) {
outfit(id: $outfitId) { outfit(id: $outfitId) {
id id
updatedAt updatedAt
} }
} }
`, `,
{ {
variables: { variables: {
outfitId, outfitId,
}, },
skip: outfitId == null, skip: outfitId == null,
fetchPolicy: "cache-only", fetchPolicy: "cache-only",
onError: (e) => console.error(e), onError: (e) => console.error(e),
}, },
); );
if (!data?.outfit) { if (!data?.outfit) {
return null; return null;
} }
return ( return (
<OutfitThumbnail <OutfitThumbnail
outfitId={data.outfit.id} outfitId={data.outfit.id}
updatedAt={data.outfit.updatedAt} updatedAt={data.outfit.updatedAt}
alt="" alt=""
objectFit="contain" objectFit="contain"
width="100%" width="100%"
height="100%" height="100%"
filter="blur(2px)" filter="blur(2px)"
/> />
); );
} }
export default WardrobePreviewAndControls; export default WardrobePreviewAndControls;

View file

@ -21,93 +21,93 @@ import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
* page layout. * page layout.
*/ */
function WardrobePage() { function WardrobePage() {
const toast = useToast(); const toast = useToast();
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState(); 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 // 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 // "Saving" indicators appear. That way, auto-saving still happens even when
// the indicator isn't on the page, e.g. when searching. // the indicator isn't on the page, e.g. when searching.
// NOTE: This only applies to navigations leaving the wardrobe-2020 app, not // NOTE: This only applies to navigations leaving the wardrobe-2020 app, not
// within! // within!
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit); const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
// TODO: I haven't found a great place for this error UI yet, and this case // 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! // isn't very common, so this lil toast notification seems good enough!
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
console.error(error); console.error(error);
toast({ toast({
title: "We couldn't load this outfit 😖", title: "We couldn't load this outfit 😖",
description: "Please reload the page to try again. Sorry!", description: "Please reload the page to try again. Sorry!",
status: "error", status: "error",
isClosable: true, isClosable: true,
duration: 999999999, duration: 999999999,
}); });
} }
}, [error, toast]); }, [error, toast]);
// For new outfits, we only block navigation while saving. For existing // For new outfits, we only block navigation while saving. For existing
// outfits, we block navigation while there are any unsaved changes. // outfits, we block navigation while there are any unsaved changes.
const shouldBlockNavigation = const shouldBlockNavigation =
outfitSaving.canSaveOutfit && outfitSaving.canSaveOutfit &&
((outfitSaving.isNewOutfit && outfitSaving.isSaving) || ((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved)); (!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
// In addition to a <Prompt /> for client-side nav, we need to block full nav! // In addition to a <Prompt /> for client-side nav, we need to block full nav!
React.useEffect(() => { React.useEffect(() => {
if (shouldBlockNavigation) { if (shouldBlockNavigation) {
const onBeforeUnload = (e) => { const onBeforeUnload = (e) => {
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example // https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
e.preventDefault(); e.preventDefault();
e.returnValue = ""; e.returnValue = "";
}; };
window.addEventListener("beforeunload", onBeforeUnload); window.addEventListener("beforeunload", onBeforeUnload);
return () => window.removeEventListener("beforeunload", onBeforeUnload); return () => window.removeEventListener("beforeunload", onBeforeUnload);
} }
}, [shouldBlockNavigation]); }, [shouldBlockNavigation]);
const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`; const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
React.useEffect(() => { React.useEffect(() => {
document.title = title; document.title = title;
}, [title]); }, [title]);
// NOTE: Most components pass around outfitState directly, to make the data // NOTE: Most components pass around outfitState directly, to make the data
// relationships more explicit... but there are some deep components // relationships more explicit... but there are some deep components
// that need it, where it's more useful and more performant to access // that need it, where it's more useful and more performant to access
// via context. // via context.
return ( return (
<OutfitStateContext.Provider value={outfitState}> <OutfitStateContext.Provider value={outfitState}>
<WardrobePageLayout <WardrobePageLayout
previewAndControls={ previewAndControls={
<WardrobePreviewAndControls <WardrobePreviewAndControls
isLoading={loading} isLoading={loading}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
} }
itemsAndMaybeSearchPanel={ itemsAndMaybeSearchPanel={
<ItemsAndSearchPanels <ItemsAndSearchPanels
loading={loading} loading={loading}
searchQuery={searchQuery} searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery} onChangeSearchQuery={setSearchQuery}
outfitState={outfitState} outfitState={outfitState}
outfitSaving={outfitSaving} outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
} }
searchFooter={ searchFooter={
<SearchFooter <SearchFooter
searchQuery={searchQuery} searchQuery={searchQuery}
onChangeSearchQuery={setSearchQuery} onChangeSearchQuery={setSearchQuery}
outfitState={outfitState} outfitState={outfitState}
/> />
} }
/> />
</OutfitStateContext.Provider> </OutfitStateContext.Provider>
); );
} }
export default WardrobePage; export default WardrobePage;

View file

@ -6,94 +6,94 @@ import AppearanceLayerSupportModal from "./AppearanceLayerSupportModal";
import { OutfitLayers } from "../../components/OutfitPreview"; import { OutfitLayers } from "../../components/OutfitPreview";
function ItemSupportAppearanceLayer({ function ItemSupportAppearanceLayer({
item, item,
itemLayer, itemLayer,
biologyLayers, biologyLayers,
outfitState, outfitState,
}) { }) {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const iconButtonBgColor = useColorModeValue("green.100", "green.300"); const iconButtonBgColor = useColorModeValue("green.100", "green.300");
const iconButtonColor = useColorModeValue("green.800", "gray.900"); const iconButtonColor = useColorModeValue("green.800", "gray.900");
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <Box
as="button" as="button"
width="150px" width="150px"
textAlign="center" textAlign="center"
fontSize="xs" fontSize="xs"
onClick={onOpen} onClick={onOpen}
> >
<Box <Box
width="150px" width="150px"
height="150px" height="150px"
marginBottom="1" marginBottom="1"
boxShadow="md" boxShadow="md"
borderRadius="md" borderRadius="md"
position="relative" position="relative"
> >
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} /> <OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
<Box <Box
className={css` className={css`
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
button:hover &, button:hover &,
button:focus & { button:focus & {
opacity: 1; 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 * an interactable object! (Whereas I expect other devices to
* discover things by exploratory hover or focus!) */ * discover things by exploratory hover or focus!) */
@media (hover: none) { @media (hover: none) {
opacity: 1; opacity: 1;
} }
`} `}
background={iconButtonBgColor} background={iconButtonBgColor}
color={iconButtonColor} color={iconButtonColor}
borderRadius="full" borderRadius="full"
boxShadow="sm" boxShadow="sm"
position="absolute" position="absolute"
bottom="2" bottom="2"
right="2" right="2"
padding="2" padding="2"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
width="32px" width="32px"
height="32px" height="32px"
> >
<EditIcon <EditIcon
boxSize="16px" boxSize="16px"
position="relative" position="relative"
top="-2px" top="-2px"
right="-1px" right="-1px"
/> />
</Box> </Box>
</Box> </Box>
<Box> <Box>
<Box as="span" fontWeight="700"> <Box as="span" fontWeight="700">
{itemLayer.zone.label} {itemLayer.zone.label}
</Box>{" "} </Box>{" "}
<Box as="span" fontWeight="600"> <Box as="span" fontWeight="600">
(Zone {itemLayer.zone.id}) (Zone {itemLayer.zone.id})
</Box> </Box>
</Box> </Box>
<Box>Neopets ID: {itemLayer.remoteId}</Box> <Box>Neopets ID: {itemLayer.remoteId}</Box>
<Box>DTI ID: {itemLayer.id}</Box> <Box>DTI ID: {itemLayer.id}</Box>
<AppearanceLayerSupportModal <AppearanceLayerSupportModal
item={item} item={item}
layer={itemLayer} layer={itemLayer}
outfitState={outfitState} outfitState={outfitState}
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
/> />
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
export default ItemSupportAppearanceLayer; export default ItemSupportAppearanceLayer;

View file

@ -2,34 +2,34 @@ import * as React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery, useMutation } from "@apollo/client"; import { useQuery, useMutation } from "@apollo/client";
import { import {
Badge, Badge,
Box, Box,
Button, Button,
Drawer, Drawer,
DrawerBody, DrawerBody,
DrawerCloseButton, DrawerCloseButton,
DrawerContent, DrawerContent,
DrawerHeader, DrawerHeader,
DrawerOverlay, DrawerOverlay,
Flex, Flex,
FormControl, FormControl,
FormErrorMessage, FormErrorMessage,
FormHelperText, FormHelperText,
FormLabel, FormLabel,
HStack, HStack,
Link, Link,
Select, Select,
Spinner, Spinner,
Stack, Stack,
Text, Text,
useBreakpointValue, useBreakpointValue,
useColorModeValue, useColorModeValue,
useDisclosure, useDisclosure,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckCircleIcon, CheckCircleIcon,
ChevronRightIcon, ChevronRightIcon,
ExternalLinkIcon, ExternalLinkIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import AllItemLayersSupportModal from "./AllItemLayersSupportModal"; import AllItemLayersSupportModal from "./AllItemLayersSupportModal";
@ -46,362 +46,362 @@ import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
* from another lazy-loaded component! * from another lazy-loaded component!
*/ */
function ItemSupportDrawer({ item, isOpen, onClose }) { function ItemSupportDrawer({ item, isOpen, onClose }) {
const placement = useBreakpointValue({ base: "bottom", lg: "right" }); const placement = useBreakpointValue({ base: "bottom", lg: "right" });
return ( return (
<Drawer <Drawer
placement={placement} placement={placement}
size="md" size="md"
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
// blockScrollOnMount doesn't matter on our fullscreen UI, but the // blockScrollOnMount doesn't matter on our fullscreen UI, but the
// default implementation breaks out layout somehow 🤔 idk, let's not! // default implementation breaks out layout somehow 🤔 idk, let's not!
blockScrollOnMount={false} blockScrollOnMount={false}
> >
<DrawerOverlay> <DrawerOverlay>
<DrawerContent <DrawerContent
maxHeight={placement === "bottom" ? "90vh" : undefined} maxHeight={placement === "bottom" ? "90vh" : undefined}
overflow="auto" overflow="auto"
> >
<DrawerCloseButton /> <DrawerCloseButton />
<DrawerHeader> <DrawerHeader>
{item.name} {item.name}
<Badge colorScheme="pink" marginLeft="3"> <Badge colorScheme="pink" marginLeft="3">
Support <span aria-hidden="true">💖</span> Support <span aria-hidden="true">💖</span>
</Badge> </Badge>
</DrawerHeader> </DrawerHeader>
<DrawerBody paddingBottom="5"> <DrawerBody paddingBottom="5">
<Metadata> <Metadata>
<MetadataLabel>Item ID:</MetadataLabel> <MetadataLabel>Item ID:</MetadataLabel>
<MetadataValue>{item.id}</MetadataValue> <MetadataValue>{item.id}</MetadataValue>
<MetadataLabel>Restricted zones:</MetadataLabel> <MetadataLabel>Restricted zones:</MetadataLabel>
<MetadataValue> <MetadataValue>
<ItemSupportRestrictedZones item={item} /> <ItemSupportRestrictedZones item={item} />
</MetadataValue> </MetadataValue>
</Metadata> </Metadata>
<Stack spacing="8" marginTop="6"> <Stack spacing="8" marginTop="6">
<ItemSupportFields item={item} /> <ItemSupportFields item={item} />
<ItemSupportAppearanceLayers item={item} /> <ItemSupportAppearanceLayers item={item} />
</Stack> </Stack>
</DrawerBody> </DrawerBody>
</DrawerContent> </DrawerContent>
</DrawerOverlay> </DrawerOverlay>
</Drawer> </Drawer>
); );
} }
function ItemSupportRestrictedZones({ item }) { 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 // 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 // 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! // the appearance, so query them that way to be instant in practice!
const { loading, error, data } = useQuery( const { loading, error, data } = useQuery(
gql` gql`
query ItemSupportRestrictedZones( query ItemSupportRestrictedZones(
$itemId: ID! $itemId: ID!
$speciesId: ID! $speciesId: ID!
$colorId: ID! $colorId: ID!
) { ) {
item(id: $itemId) { item(id: $itemId) {
id id
appearanceOn(speciesId: $speciesId, colorId: $colorId) { appearanceOn(speciesId: $speciesId, colorId: $colorId) {
restrictedZones { restrictedZones {
id id
label label
} }
} }
} }
} }
`, `,
{ variables: { itemId: item.id, speciesId, colorId } }, { variables: { itemId: item.id, speciesId, colorId } },
); );
if (loading) { if (loading) {
return <Spinner size="xs" />; return <Spinner size="xs" />;
} }
if (error) { if (error) {
return <Text color="red.400">{error.message}</Text>; return <Text color="red.400">{error.message}</Text>;
} }
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || []; const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
if (restrictedZones.length === 0) { if (restrictedZones.length === 0) {
return "None"; return "None";
} }
return restrictedZones return restrictedZones
.map((z) => `${z.label} (${z.id})`) .map((z) => `${z.label} (${z.id})`)
.sort() .sort()
.join(", "); .join(", ");
} }
function ItemSupportFields({ item }) { function ItemSupportFields({ item }) {
const { loading, error, data } = useQuery( const { loading, error, data } = useQuery(
gql` gql`
query ItemSupportFields($itemId: ID!) { query ItemSupportFields($itemId: ID!) {
item(id: $itemId) { item(id: $itemId) {
id id
manualSpecialColor { manualSpecialColor {
id id
} }
explicitlyBodySpecific explicitlyBodySpecific
} }
} }
`, `,
{ {
variables: { itemId: item.id }, variables: { itemId: item.id },
// HACK: I think it's a bug in @apollo/client 3.1.1 that, if the // HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
// optimistic response sets `manualSpecialColor` to null, the query // optimistic response sets `manualSpecialColor` to null, the query
// doesn't update, even though its cache has updated :/ // doesn't update, even though its cache has updated :/
// //
// This cheap trick of changing the display name every re-render // This cheap trick of changing the display name every re-render
// persuades Apollo that this is a different query, so it re-checks // persuades Apollo that this is a different query, so it re-checks
// its cache and finds the empty `manualSpecialColor`. Weird! // its cache and finds the empty `manualSpecialColor`. Weird!
displayName: `ItemSupportFields-${new Date()}`, displayName: `ItemSupportFields-${new Date()}`,
}, },
); );
const errorColor = useColorModeValue("red.500", "red.300"); const errorColor = useColorModeValue("red.500", "red.300");
return ( return (
<> <>
{error && <Box color={errorColor}>{error.message}</Box>} {error && <Box color={errorColor}>{error.message}</Box>}
<ItemSupportSpecialColorFields <ItemSupportSpecialColorFields
loading={loading} loading={loading}
error={error} error={error}
item={item} item={item}
manualSpecialColor={data?.item?.manualSpecialColor?.id} manualSpecialColor={data?.item?.manualSpecialColor?.id}
/> />
<ItemSupportPetCompatibilityRuleFields <ItemSupportPetCompatibilityRuleFields
loading={loading} loading={loading}
error={error} error={error}
item={item} item={item}
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific} explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
/> />
</> </>
); );
} }
function ItemSupportSpecialColorFields({ function ItemSupportSpecialColorFields({
loading, loading,
error, error,
item, item,
manualSpecialColor, manualSpecialColor,
}) { }) {
const { supportSecret } = useSupport(); const { supportSecret } = useSupport();
const { const {
loading: colorsLoading, loading: colorsLoading,
error: colorsError, error: colorsError,
data: colorsData, data: colorsData,
} = useQuery(gql` } = useQuery(gql`
query ItemSupportDrawerAllColors { query ItemSupportDrawerAllColors {
allColors { allColors {
id id
name name
isStandard isStandard
} }
} }
`); `);
const [ const [
mutate, mutate,
{ loading: mutationLoading, error: mutationError, data: mutationData }, { loading: mutationLoading, error: mutationError, data: mutationData },
] = useMutation(gql` ] = useMutation(gql`
mutation ItemSupportDrawerSetManualSpecialColor( mutation ItemSupportDrawerSetManualSpecialColor(
$itemId: ID! $itemId: ID!
$colorId: ID $colorId: ID
$supportSecret: String! $supportSecret: String!
) { ) {
setManualSpecialColor( setManualSpecialColor(
itemId: $itemId itemId: $itemId
colorId: $colorId colorId: $colorId
supportSecret: $supportSecret supportSecret: $supportSecret
) { ) {
id id
manualSpecialColor { manualSpecialColor {
id id
} }
} }
} }
`); `);
const onChange = React.useCallback( const onChange = React.useCallback(
(e) => { (e) => {
const colorId = e.target.value || null; const colorId = e.target.value || null;
const color = const color =
colorId != null ? { __typename: "Color", id: colorId } : null; colorId != null ? { __typename: "Color", id: colorId } : null;
mutate({ mutate({
variables: { variables: {
itemId: item.id, itemId: item.id,
colorId, colorId,
supportSecret, supportSecret,
}, },
optimisticResponse: { optimisticResponse: {
__typename: "Mutation", __typename: "Mutation",
setManualSpecialColor: { setManualSpecialColor: {
__typename: "Item", __typename: "Item",
id: item.id, id: item.id,
manualSpecialColor: color, manualSpecialColor: color,
}, },
}, },
}).catch((e) => { }).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render! // Ignore errors from the promise, because we'll handle them on render!
}); });
}, },
[item.id, mutate, supportSecret], [item.id, mutate, supportSecret],
); );
const nonStandardColors = const nonStandardColors =
colorsData?.allColors?.filter((c) => !c.isStandard) || []; colorsData?.allColors?.filter((c) => !c.isStandard) || [];
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name)); nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
const linkColor = useColorModeValue("green.500", "green.300"); const linkColor = useColorModeValue("green.500", "green.300");
return ( return (
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}> <FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
<FormLabel>Special color</FormLabel> <FormLabel>Special color</FormLabel>
<Select <Select
placeholder={ placeholder={
loading || colorsLoading loading || colorsLoading
? "Loading…" ? "Loading…"
: "Default: Auto-detect from item description" : "Default: Auto-detect from item description"
} }
value={manualSpecialColor?.id} value={manualSpecialColor?.id}
isDisabled={mutationLoading} isDisabled={mutationLoading}
icon={ icon={
loading || colorsLoading || mutationLoading ? ( loading || colorsLoading || mutationLoading ? (
<Spinner /> <Spinner />
) : mutationData ? ( ) : mutationData ? (
<CheckCircleIcon /> <CheckCircleIcon />
) : undefined ) : undefined
} }
onChange={onChange} onChange={onChange}
> >
{nonStandardColors.map((color) => ( {nonStandardColors.map((color) => (
<option key={color.id} value={color.id}> <option key={color.id} value={color.id}>
{color.name} {color.name}
</option> </option>
))} ))}
</Select> </Select>
{colorsError && ( {colorsError && (
<FormErrorMessage>{colorsError.message}</FormErrorMessage> <FormErrorMessage>{colorsError.message}</FormErrorMessage>
)} )}
{mutationError && ( {mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage> <FormErrorMessage>{mutationError.message}</FormErrorMessage>
)} )}
{!colorsError && !mutationError && ( {!colorsError && !mutationError && (
<FormHelperText> <FormHelperText>
This controls which previews we show on the{" "} This controls which previews we show on the{" "}
<Link <Link
href={`https://impress.openneo.net/items/${ href={`https://impress.openneo.net/items/${
item.id item.id
}-${item.name.replace(/ /g, "-")}`} }-${item.name.replace(/ /g, "-")}`}
color={linkColor} color={linkColor}
isExternal isExternal
> >
classic item page <ExternalLinkIcon /> classic item page <ExternalLinkIcon />
</Link> </Link>
. .
</FormHelperText> </FormHelperText>
)} )}
</FormControl> </FormControl>
); );
} }
function ItemSupportPetCompatibilityRuleFields({ function ItemSupportPetCompatibilityRuleFields({
loading, loading,
error, error,
item, item,
explicitlyBodySpecific, explicitlyBodySpecific,
}) { }) {
const { supportSecret } = useSupport(); const { supportSecret } = useSupport();
const [ const [
mutate, mutate,
{ loading: mutationLoading, error: mutationError, data: mutationData }, { loading: mutationLoading, error: mutationError, data: mutationData },
] = useMutation(gql` ] = useMutation(gql`
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific( mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
$itemId: ID! $itemId: ID!
$explicitlyBodySpecific: Boolean! $explicitlyBodySpecific: Boolean!
$supportSecret: String! $supportSecret: String!
) { ) {
setItemExplicitlyBodySpecific( setItemExplicitlyBodySpecific(
itemId: $itemId itemId: $itemId
explicitlyBodySpecific: $explicitlyBodySpecific explicitlyBodySpecific: $explicitlyBodySpecific
supportSecret: $supportSecret supportSecret: $supportSecret
) { ) {
id id
explicitlyBodySpecific explicitlyBodySpecific
} }
} }
`); `);
const onChange = React.useCallback( const onChange = React.useCallback(
(e) => { (e) => {
const explicitlyBodySpecific = e.target.value === "true"; const explicitlyBodySpecific = e.target.value === "true";
mutate({ mutate({
variables: { variables: {
itemId: item.id, itemId: item.id,
explicitlyBodySpecific, explicitlyBodySpecific,
supportSecret, supportSecret,
}, },
optimisticResponse: { optimisticResponse: {
__typename: "Mutation", __typename: "Mutation",
setItemExplicitlyBodySpecific: { setItemExplicitlyBodySpecific: {
__typename: "Item", __typename: "Item",
id: item.id, id: item.id,
explicitlyBodySpecific, explicitlyBodySpecific,
}, },
}, },
}).catch((e) => { }).catch((e) => {
// Ignore errors from the promise, because we'll handle them on render! // Ignore errors from the promise, because we'll handle them on render!
}); });
}, },
[item.id, mutate, supportSecret], [item.id, mutate, supportSecret],
); );
return ( return (
<FormControl isInvalid={Boolean(error || mutationError)}> <FormControl isInvalid={Boolean(error || mutationError)}>
<FormLabel>Pet compatibility rule</FormLabel> <FormLabel>Pet compatibility rule</FormLabel>
<Select <Select
value={explicitlyBodySpecific ? "true" : "false"} value={explicitlyBodySpecific ? "true" : "false"}
isDisabled={mutationLoading} isDisabled={mutationLoading}
icon={ icon={
loading || mutationLoading ? ( loading || mutationLoading ? (
<Spinner /> <Spinner />
) : mutationData ? ( ) : mutationData ? (
<CheckCircleIcon /> <CheckCircleIcon />
) : undefined ) : undefined
} }
onChange={onChange} onChange={onChange}
> >
{loading ? ( {loading ? (
<option>Loading</option> <option>Loading</option>
) : ( ) : (
<> <>
<option value="false"> <option value="false">
Default: Auto-detect whether this fits all pets Default: Auto-detect whether this fits all pets
</option> </option>
<option value="true"> <option value="true">
Body specific: Always different for each pet body Body specific: Always different for each pet body
</option> </option>
</> </>
)} )}
</Select> </Select>
{mutationError && ( {mutationError && (
<FormErrorMessage>{mutationError.message}</FormErrorMessage> <FormErrorMessage>{mutationError.message}</FormErrorMessage>
)} )}
{!mutationError && ( {!mutationError && (
<FormHelperText> <FormHelperText>
By default, we assume Background-y zones fit all pets the same. When By default, we assume Background-y zones fit all pets the same. When
items don't follow that rule, we can override it. items don't follow that rule, we can override it.
</FormHelperText> </FormHelperText>
)} )}
</FormControl> </FormControl>
); );
} }
/** /**
@ -412,51 +412,51 @@ function ItemSupportPetCompatibilityRuleFields({
* it here, only when the drawer is open! * it here, only when the drawer is open!
*/ */
function ItemSupportAppearanceLayers({ item }) { function ItemSupportAppearanceLayers({ item }) {
const outfitState = React.useContext(OutfitStateContext); const outfitState = React.useContext(OutfitStateContext);
const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState; const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
const { error, visibleLayers } = useOutfitAppearance({ const { error, visibleLayers } = useOutfitAppearance({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId, altStyleId,
appearanceId, appearanceId,
wornItemIds: [item.id], wornItemIds: [item.id],
}); });
const biologyLayers = visibleLayers.filter((l) => l.source === "pet"); const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
const itemLayers = visibleLayers.filter((l) => l.source === "item"); const itemLayers = visibleLayers.filter((l) => l.source === "item");
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth); itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
const modalState = useDisclosure(); const modalState = useDisclosure();
return ( return (
<FormControl> <FormControl>
<Flex align="center"> <Flex align="center">
<FormLabel>Appearance layers</FormLabel> <FormLabel>Appearance layers</FormLabel>
<Box width="4" flex="1 0 auto" /> <Box width="4" flex="1 0 auto" />
<Button size="xs" onClick={modalState.onOpen}> <Button size="xs" onClick={modalState.onOpen}>
View on all pets <ChevronRightIcon /> View on all pets <ChevronRightIcon />
</Button> </Button>
<AllItemLayersSupportModal <AllItemLayersSupportModal
item={item} item={item}
isOpen={modalState.isOpen} isOpen={modalState.isOpen}
onClose={modalState.onClose} onClose={modalState.onClose}
/> />
</Flex> </Flex>
<HStack spacing="4" overflow="auto" paddingX="1"> <HStack spacing="4" overflow="auto" paddingX="1">
{itemLayers.map((itemLayer) => ( {itemLayers.map((itemLayer) => (
<ItemSupportAppearanceLayer <ItemSupportAppearanceLayer
key={itemLayer.id} key={itemLayer.id}
item={item} item={item}
itemLayer={itemLayer} itemLayer={itemLayer}
biologyLayers={biologyLayers} biologyLayers={biologyLayers}
outfitState={outfitState} outfitState={outfitState}
/> />
))} ))}
</HStack> </HStack>
{error && <FormErrorMessage>{error.message}</FormErrorMessage>} {error && <FormErrorMessage>{error.message}</FormErrorMessage>}
</FormControl> </FormControl>
); );
} }
export default ItemSupportDrawer; export default ItemSupportDrawer;

View file

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

View file

@ -12,8 +12,8 @@ import useSupport from "./useSupport";
* the server checks the provided secret for each Support request. * the server checks the provided secret for each Support request.
*/ */
function SupportOnly({ children }) { function SupportOnly({ children }) {
const { isSupportUser } = useSupport(); const { isSupportUser } = useSupport();
return isSupportUser ? children : null; return isSupportUser ? children : null;
} }
export default SupportOnly; 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. * the server checks the provided secret for each Support request.
*/ */
function useSupport() { 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; export default useSupport;

View file

@ -7,166 +7,166 @@ import { outfitStatesAreEqual } from "./useOutfitState";
import { useSaveOutfitMutation } from "../loaders/outfits"; import { useSaveOutfitMutation } from "../loaders/outfits";
function useOutfitSaving(outfitState, dispatchToOutfit) { function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser(); const { isLoggedIn, id: currentUserId } = useCurrentUser();
const { pathname } = useLocation(); const { pathname } = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved // Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
// to the server. // to the server.
const isNewOutfit = outfitState.id == null; const isNewOutfit = outfitState.id == null;
// Whether this outfit's latest local changes have been saved to the server. // Whether this outfit's latest local changes have been saved to the server.
// And log it to the console! // And log it to the console!
const latestVersionIsSaved = const latestVersionIsSaved =
outfitState.savedOutfitState && outfitState.savedOutfitState &&
outfitStatesAreEqual( outfitStatesAreEqual(
outfitState.outfitStateWithoutExtras, outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState, outfitState.savedOutfitState,
); );
React.useEffect(() => { React.useEffect(() => {
console.debug( console.debug(
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o", "[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
latestVersionIsSaved, latestVersionIsSaved,
outfitState.outfitStateWithoutExtras, outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState, outfitState.savedOutfitState,
); );
}, [ }, [
latestVersionIsSaved, latestVersionIsSaved,
outfitState.outfitStateWithoutExtras, outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState, outfitState.savedOutfitState,
]); ]);
// Only logged-in users can save outfits - and they can only save new outfits, // Only logged-in users can save outfits - and they can only save new outfits,
// or outfits they created. // or outfits they created.
const canSaveOutfit = const canSaveOutfit =
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId); isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
// Users can delete their own outfits too. The logic is slightly different // 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 // than for saving, because you can save an outfit that hasn't been saved
// yet, but you can't delete it. // yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit; const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
const saveOutfitMutation = useSaveOutfitMutation({ const saveOutfitMutation = useSaveOutfitMutation({
onSuccess: (outfit) => { onSuccess: (outfit) => {
dispatchToOutfit({ dispatchToOutfit({
type: "handleOutfitSaveResponse", type: "handleOutfitSaveResponse",
outfitData: outfit, outfitData: outfit,
}); });
}, },
}); });
const isSaving = saveOutfitMutation.isPending; const isSaving = saveOutfitMutation.isPending;
const saveError = saveOutfitMutation.error; const saveError = saveOutfitMutation.error;
const saveOutfitFromProvidedState = React.useCallback( const saveOutfitFromProvidedState = React.useCallback(
(outfitState) => { (outfitState) => {
saveOutfitMutation saveOutfitMutation
.mutateAsync({ .mutateAsync({
id: outfitState.id, id: outfitState.id,
name: outfitState.name, name: outfitState.name,
speciesId: outfitState.speciesId, speciesId: outfitState.speciesId,
colorId: outfitState.colorId, colorId: outfitState.colorId,
pose: outfitState.pose, pose: outfitState.pose,
appearanceId: outfitState.appearanceId, appearanceId: outfitState.appearanceId,
altStyleId: outfitState.altStyleId, altStyleId: outfitState.altStyleId,
wornItemIds: [...outfitState.wornItemIds], wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: [...outfitState.closetedItemIds], closetedItemIds: [...outfitState.closetedItemIds],
}) })
.then((outfit) => { .then((outfit) => {
// Navigate to the new saved outfit URL. Our Apollo cache should pick // Navigate to the new saved outfit URL. Our Apollo cache should pick
// up the data from this mutation response, and combine it with the // up the data from this mutation response, and combine it with the
// existing cached data, to make this smooth without any loading UI. // existing cached data, to make this smooth without any loading UI.
if (pathname !== `/outfits/[outfitId]`) { if (pathname !== `/outfits/[outfitId]`) {
navigate(`/outfits/${outfit.id}`); navigate(`/outfits/${outfit.id}`);
} }
}) })
.catch((e) => { .catch((e) => {
console.error(e); console.error(e);
toast({ toast({
status: "error", status: "error",
title: "Sorry, there was an error saving this outfit!", title: "Sorry, there was an error saving this outfit!",
description: "Maybe check your connection and try again.", description: "Maybe check your connection and try again.",
}); });
}); });
}, },
// It's important that this callback _doesn't_ change when the outfit // It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the // changes, so that the auto-save effect is only responding to the
// debounced state! // debounced state!
[saveOutfitMutation, pathname, navigate, toast], [saveOutfitMutation, pathname, navigate, toast],
); );
const saveOutfit = React.useCallback( const saveOutfit = React.useCallback(
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras), () => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras], [saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras],
); );
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`, // Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
// which only contains the basic fields, and will keep a stable object // which only contains the basic fields, and will keep a stable object
// identity until actual changes occur. Then, save the outfit after the user // 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 // has left it alone for long enough, so long as it's actually different
// than the saved state. // than the saved state.
const debouncedOutfitState = useDebounce( const debouncedOutfitState = useDebounce(
outfitState.outfitStateWithoutExtras, outfitState.outfitStateWithoutExtras,
2000, 2000,
{ {
// When the outfit ID changes, update the debounced state immediately! // When the outfit ID changes, update the debounced state immediately!
forceReset: (debouncedOutfitState, newOutfitState) => forceReset: (debouncedOutfitState, newOutfitState) =>
debouncedOutfitState.id !== newOutfitState.id, debouncedOutfitState.id !== newOutfitState.id,
}, },
); );
// HACK: This prevents us from auto-saving the outfit state that's still // HACK: This prevents us from auto-saving the outfit state that's still
// loading. I worry that this might not catch other loading scenarios // 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 // 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! // items are still loading in... not sure where this would happen tho!
const debouncedOutfitStateIsSaveable = const debouncedOutfitStateIsSaveable =
debouncedOutfitState.speciesId && debouncedOutfitState.speciesId &&
debouncedOutfitState.colorId && debouncedOutfitState.colorId &&
debouncedOutfitState.pose; debouncedOutfitState.pose;
React.useEffect(() => { React.useEffect(() => {
if ( if (
!isNewOutfit && !isNewOutfit &&
canSaveOutfit && canSaveOutfit &&
!isSaving && !isSaving &&
!saveError && !saveError &&
debouncedOutfitStateIsSaveable && debouncedOutfitStateIsSaveable &&
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState) !outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
) { ) {
console.info( console.info(
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o", "[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
outfitState.savedOutfitState, outfitState.savedOutfitState,
debouncedOutfitState, debouncedOutfitState,
); );
saveOutfitFromProvidedState(debouncedOutfitState); saveOutfitFromProvidedState(debouncedOutfitState);
} }
}, [ }, [
isNewOutfit, isNewOutfit,
canSaveOutfit, canSaveOutfit,
isSaving, isSaving,
saveError, saveError,
debouncedOutfitState, debouncedOutfitState,
debouncedOutfitStateIsSaveable, debouncedOutfitStateIsSaveable,
outfitState.savedOutfitState, outfitState.savedOutfitState,
saveOutfitFromProvidedState, saveOutfitFromProvidedState,
]); ]);
// When the outfit changes, clear out the error state from previous saves. // 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 // We'll send the mutation again after the debounce, and we don't want to
// show the error UI in the meantime! // show the error UI in the meantime!
const resetMutation = saveOutfitMutation.reset; const resetMutation = saveOutfitMutation.reset;
React.useEffect( React.useEffect(
() => resetMutation(), () => resetMutation(),
[outfitState.outfitStateWithoutExtras, resetMutation], [outfitState.outfitStateWithoutExtras, resetMutation],
); );
return { return {
canSaveOutfit, canSaveOutfit,
canDeleteOutfit, canDeleteOutfit,
isNewOutfit, isNewOutfit,
isSaving, isSaving,
latestVersionIsSaved, latestVersionIsSaved,
saveError, saveError,
saveOutfit, saveOutfit,
}; };
} }
export default useOutfitSaving; 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! * useSearchResults manages the actual querying and state management of search!
*/ */
export function useSearchResults( export function useSearchResults(
query, query,
outfitState, outfitState,
currentPageNumber, currentPageNumber,
{ skip = false } = {}, { 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 // We debounce the search query, so that we don't resend a new query whenever
// the user types anything. // the user types anything.
const debouncedQuery = useDebounce(query, 300, { const debouncedQuery = useDebounce(query, 300, {
waitForFirstPause: true, waitForFirstPause: true,
initialValue: emptySearchQuery, initialValue: emptySearchQuery,
}); });
const { isLoading, error, data } = useItemSearch( const { isLoading, error, data } = useItemSearch(
{ {
filters: buildSearchFilters(debouncedQuery, outfitState), filters: buildSearchFilters(debouncedQuery, outfitState),
withAppearancesFor: { speciesId, colorId, altStyleId }, withAppearancesFor: { speciesId, colorId, altStyleId },
page: currentPageNumber, page: currentPageNumber,
perPage: SEARCH_PER_PAGE, perPage: SEARCH_PER_PAGE,
}, },
{ {
enabled: !skip && !searchQueryIsEmpty(debouncedQuery), enabled: !skip && !searchQueryIsEmpty(debouncedQuery),
}, },
); );
const loading = debouncedQuery !== query || isLoading; const loading = debouncedQuery !== query || isLoading;
const items = data?.items ?? []; const items = data?.items ?? [];
const numTotalPages = data?.numTotalPages ?? 0; const numTotalPages = data?.numTotalPages ?? 0;
return { loading, error, items, numTotalPages }; return { loading, error, items, numTotalPages };
} }
function buildSearchFilters(query, { speciesId, colorId, altStyleId }) { function buildSearchFilters(query, { speciesId, colorId, altStyleId }) {
const filters = []; const filters = [];
// TODO: We're missing quote support, like `background "Dyeworks White"`. // 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 // 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 // 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. // the server merge them? That'd support text-based `is:nc` etc too.
const words = query.value.split(/\s+/); const words = query.value.split(/\s+/);
for (const word of words) { for (const word of words) {
filters.push({ key: "name", value: word }); filters.push({ key: "name", value: word });
} }
if (query.filterToItemKind === "NC") { if (query.filterToItemKind === "NC") {
filters.push({ key: "is_nc" }); filters.push({ key: "is_nc" });
} else if (query.filterToItemKind === "PB") { } else if (query.filterToItemKind === "PB") {
filters.push({ key: "is_pb" }); filters.push({ key: "is_pb" });
} else if (query.filterToItemKind === "NP") { } else if (query.filterToItemKind === "NP") {
filters.push({ key: "is_np" }); filters.push({ key: "is_np" });
} }
if (query.filterToZoneLabel != null) { if (query.filterToZoneLabel != null) {
filters.push({ filters.push({
key: "occupied_zone_set_name", key: "occupied_zone_set_name",
value: query.filterToZoneLabel, value: query.filterToZoneLabel,
}); });
} }
if (query.filterToCurrentUserOwnsOrWants === "OWNS") { if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
filters.push({ key: "user_closet_hanger_ownership", value: "true" }); filters.push({ key: "user_closet_hanger_ownership", value: "true" });
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") { } else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
filters.push({ key: "user_closet_hanger_ownership", value: "false" }); filters.push({ key: "user_closet_hanger_ownership", value: "false" });
} }
filters.push({ filters.push({
key: "fits", key: "fits",
value: { speciesId, colorId, altStyleId }, 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. // Use Apollo's error messages in development.
if (process.env["NODE_ENV"] === "development") { if (process.env["NODE_ENV"] === "development") {
loadErrorMessages(); loadErrorMessages();
loadDevMessages(); loadDevMessages();
} }
// Teach Apollo to load certain fields from the cache, to avoid extra network // 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 // requests. This happens a lot - e.g. reusing data from item search on the
// outfit immediately! // outfit immediately!
const typePolicies = { const typePolicies = {
Query: { Query: {
fields: { fields: {
closetList: (_, { args, toReference }) => { closetList: (_, { args, toReference }) => {
return toReference({ __typename: "ClosetList", id: args.id }, true); return toReference({ __typename: "ClosetList", id: args.id }, true);
}, },
items: (_, { args, toReference }) => { items: (_, { args, toReference }) => {
return args.ids.map((id) => return args.ids.map((id) =>
toReference({ __typename: "Item", id }, true), toReference({ __typename: "Item", id }, true),
); );
}, },
item: (_, { args, toReference }) => { item: (_, { args, toReference }) => {
return toReference({ __typename: "Item", id: args.id }, true); return toReference({ __typename: "Item", id: args.id }, true);
}, },
petAppearanceById: (_, { args, toReference }) => { petAppearanceById: (_, { args, toReference }) => {
return toReference({ __typename: "PetAppearance", id: args.id }, true); return toReference({ __typename: "PetAppearance", id: args.id }, true);
}, },
species: (_, { args, toReference }) => { species: (_, { args, toReference }) => {
return toReference({ __typename: "Species", id: args.id }, true); return toReference({ __typename: "Species", id: args.id }, true);
}, },
color: (_, { args, toReference }) => { color: (_, { args, toReference }) => {
return toReference({ __typename: "Color", id: args.id }, true); return toReference({ __typename: "Color", id: args.id }, true);
}, },
outfit: (_, { args, toReference }) => { outfit: (_, { args, toReference }) => {
return toReference({ __typename: "Outfit", id: args.id }, true); return toReference({ __typename: "Outfit", id: args.id }, true);
}, },
user: (_, { args, toReference }) => { user: (_, { args, toReference }) => {
return toReference({ __typename: "User", id: args.id }, true); return toReference({ __typename: "User", id: args.id }, true);
}, },
}, },
}, },
Item: { Item: {
fields: { fields: {
appearanceOn: (appearance, { args, readField, toReference }) => { appearanceOn: (appearance, { args, readField, toReference }) => {
// If we already have this exact appearance in the cache, serve it! // If we already have this exact appearance in the cache, serve it!
if (appearance) { if (appearance) {
return appearance; return appearance;
} }
const { speciesId, colorId, altStyleId } = args; const { speciesId, colorId, altStyleId } = args;
console.debug( console.debug(
"[appearanceOn] seeking cached appearance", "[appearanceOn] seeking cached appearance",
speciesId, speciesId,
colorId, colorId,
altStyleId, altStyleId,
readField("id"), readField("id"),
); );
// If this is an alt style, don't try to mess with clever caching. // 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 // (Note that, if it's already in the cache, the first condition will
// catch that! This won't *always* force a fresh load!) // catch that! This won't *always* force a fresh load!)
if (altStyleId != null) { if (altStyleId != null) {
return undefined; return undefined;
} }
// Otherwise, we're going to see if this is a standard color, in which // 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 // case we can reuse the standard color appearance if we already have
// it! This helps for fast loading when switching between standard // it! This helps for fast loading when switching between standard
// colors. // colors.
const speciesStandardBodyId = readField( const speciesStandardBodyId = readField(
"standardBodyId", "standardBodyId",
toReference({ __typename: "Species", id: speciesId }), toReference({ __typename: "Species", id: speciesId }),
); );
const colorIsStandard = readField( const colorIsStandard = readField(
"isStandard", "isStandard",
toReference({ __typename: "Color", id: colorId }), toReference({ __typename: "Color", id: colorId }),
); );
if (speciesStandardBodyId == null || colorIsStandard == null) { if (speciesStandardBodyId == null || colorIsStandard == null) {
// We haven't loaded all the species/colors into cache yet. We might // We haven't loaded all the species/colors into cache yet. We might
// be loading them, depending on the page? Either way, return // be loading them, depending on the page? Either way, return
// `undefined`, meaning we don't know how to serve this from cache. // `undefined`, meaning we don't know how to serve this from cache.
// This will cause us to start loading it from the server. // This will cause us to start loading it from the server.
console.debug("[appearanceOn] species/colors not loaded yet"); console.debug("[appearanceOn] species/colors not loaded yet");
return undefined; return undefined;
} }
if (colorIsStandard) { if (colorIsStandard) {
const itemId = readField("id"); const itemId = readField("id");
console.debug( console.debug(
"[appearanceOn] standard color, will read:", "[appearanceOn] standard color, will read:",
`item-${itemId}-body-${speciesStandardBodyId}`, `item-${itemId}-body-${speciesStandardBodyId}`,
); );
return toReference({ return toReference({
__typename: "ItemAppearance", __typename: "ItemAppearance",
id: `item-${itemId}-body-${speciesStandardBodyId}`, id: `item-${itemId}-body-${speciesStandardBodyId}`,
}); });
} else { } else {
console.debug("[appearanceOn] non-standard color, failure"); console.debug("[appearanceOn] non-standard color, failure");
// This isn't a standard color, so we don't support special // This isn't a standard color, so we don't support special
// cross-color caching for it. Return `undefined`, meaning we don't // cross-color caching for it. Return `undefined`, meaning we don't
// know how to serve this from cache. This will cause us to start // know how to serve this from cache. This will cause us to start
// loading it from the server. // loading it from the server.
return undefined; return undefined;
} }
}, },
currentUserOwnsThis: (cachedValue, { readField }) => { currentUserOwnsThis: (cachedValue, { readField }) => {
if (cachedValue != null) { if (cachedValue != null) {
return cachedValue; return cachedValue;
} }
// Do we know what items this user owns? If so, scan for this item. // Do we know what items this user owns? If so, scan for this item.
const currentUserRef = readField("currentUser", { const currentUserRef = readField("currentUser", {
__ref: "ROOT_QUERY", __ref: "ROOT_QUERY",
}); });
if (!currentUserRef) { if (!currentUserRef) {
return undefined; return undefined;
} }
const thisItemId = readField("id"); const thisItemId = readField("id");
const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef); const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef);
if (!itemsTheyOwn) { if (!itemsTheyOwn) {
return undefined; return undefined;
} }
const theyOwnThisItem = itemsTheyOwn.some( const theyOwnThisItem = itemsTheyOwn.some(
(itemRef) => readField("id", itemRef) === thisItemId, (itemRef) => readField("id", itemRef) === thisItemId,
); );
return theyOwnThisItem; return theyOwnThisItem;
}, },
currentUserWantsThis: (cachedValue, { readField }) => { currentUserWantsThis: (cachedValue, { readField }) => {
if (cachedValue != null) { if (cachedValue != null) {
return cachedValue; return cachedValue;
} }
// Do we know what items this user owns? If so, scan for this item. // Do we know what items this user owns? If so, scan for this item.
const currentUserRef = readField("currentUser", { const currentUserRef = readField("currentUser", {
__ref: "ROOT_QUERY", __ref: "ROOT_QUERY",
}); });
if (!currentUserRef) { if (!currentUserRef) {
return undefined; return undefined;
} }
const thisItemId = readField("id"); const thisItemId = readField("id");
const itemsTheyWant = readField("itemsTheyWant", currentUserRef); const itemsTheyWant = readField("itemsTheyWant", currentUserRef);
if (!itemsTheyWant) { if (!itemsTheyWant) {
return undefined; return undefined;
} }
const theyWantThisItem = itemsTheyWant.some( const theyWantThisItem = itemsTheyWant.some(
(itemRef) => readField("id", itemRef) === thisItemId, (itemRef) => readField("id", itemRef) === thisItemId,
); );
return theyWantThisItem; return theyWantThisItem;
}, },
}, },
}, },
ClosetList: { ClosetList: {
fields: { fields: {
// When loading the updated contents of a list, replace it entirely. // When loading the updated contents of a list, replace it entirely.
items: { merge: false }, items: { merge: false },
}, },
}, },
}; };
const cache = new InMemoryCache({ typePolicies }); const cache = new InMemoryCache({ typePolicies });
const httpLink = createHttpLink({ const httpLink = createHttpLink({
uri: buildImpress2020Url("/api/graphql"), uri: buildImpress2020Url("/api/graphql"),
}); });
const link = createPersistedQueryLink({ const link = createPersistedQueryLink({
useGETForHashedQueries: true, useGETForHashedQueries: true,
}).concat(httpLink); }).concat(httpLink);
/** /**
@ -182,9 +182,9 @@ const link = createPersistedQueryLink({
* queries. This is how we communicate with the server! * queries. This is how we communicate with the server!
*/ */
const apolloClient = new ApolloClient({ const apolloClient = new ApolloClient({
link, link,
cache, cache,
connectToDevTools: true, connectToDevTools: true,
}); });
export default apolloClient; 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"; import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons";
function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) { function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) {
// `delayedUsesHTML5` stores the last known value of `usesHTML5`, when // `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
// `isLoading` was `false`. This enables us to keep showing the badge, even // `isLoading` was `false`. This enables us to keep showing the badge, even
// when loading a new appearance - because it's unlikely the badge will // when loading a new appearance - because it's unlikely the badge will
// change between different appearances for the same item, and the flicker is // change between different appearances for the same item, and the flicker is
// annoying! // annoying!
const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null); const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
React.useEffect(() => { React.useEffect(() => {
if (!isLoading) { if (!isLoading) {
setDelayedUsesHTML5(usesHTML5); setDelayedUsesHTML5(usesHTML5);
} }
}, [usesHTML5, isLoading]); }, [usesHTML5, isLoading]);
if (delayedUsesHTML5 === true) { if (delayedUsesHTML5 === true) {
return ( return (
<GlitchBadgeLayout <GlitchBadgeLayout
hasGlitches={false} hasGlitches={false}
aria-label="HTML5 supported!" aria-label="HTML5 supported!"
tooltipLabel={ tooltipLabel={
tooltipLabel || tooltipLabel ||
"This item is converted to HTML5, and ready to use on Neopets.com!" "This item is converted to HTML5, and ready to use on Neopets.com!"
} }
> >
<CheckCircleIcon fontSize="xs" /> <CheckCircleIcon fontSize="xs" />
<Icon <Icon
viewBox="0 0 36 36" viewBox="0 0 36 36"
fontSize="xl" fontSize="xl"
// Visual re-balancing, there's too much visual right-padding here! // Visual re-balancing, there's too much visual right-padding here!
marginRight="-1" marginRight="-1"
> >
{/* From Twemoji Keycap 5 */} {/* From Twemoji Keycap 5 */}
<path <path
fill="currentColor" 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" 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> </Icon>
</GlitchBadgeLayout> </GlitchBadgeLayout>
); );
} else if (delayedUsesHTML5 === false) { } else if (delayedUsesHTML5 === false) {
return ( return (
<GlitchBadgeLayout <GlitchBadgeLayout
hasGlitches={true} hasGlitches={true}
aria-label="HTML5 not supported" aria-label="HTML5 not supported"
tooltipLabel={ tooltipLabel={
tooltipLabel || ( tooltipLabel || (
<> <>
This item isn't converted to HTML5 yet, so it might not appear in 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 Neopets.com customization yet. Once it's ready, it could look a
bit different than our temporary preview here. It might even be bit different than our temporary preview here. It might even be
animated! animated!
</> </>
) )
} }
> >
<WarningTwoIcon fontSize="xs" marginRight="1" /> <WarningTwoIcon fontSize="xs" marginRight="1" />
<Icon viewBox="0 0 36 36" fontSize="xl"> <Icon viewBox="0 0 36 36" fontSize="xl">
{/* From Twemoji Keycap 5 */} {/* From Twemoji Keycap 5 */}
<path <path
fill="currentColor" 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" 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 */} {/* From Twemoji Not Allowed */}
<path <path
fill="#DD2E44" fill="#DD2E44"
opacity="0.75" 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" 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> </Icon>
</GlitchBadgeLayout> </GlitchBadgeLayout>
); );
} else { } else {
// If no `usesHTML5` value has been provided yet, we're empty for now! // If no `usesHTML5` value has been provided yet, we're empty for now!
return null; return null;
} }
} }
export function GlitchBadgeLayout({ export function GlitchBadgeLayout({
hasGlitches = true, hasGlitches = true,
children, children,
tooltipLabel, tooltipLabel,
...props ...props
}) { }) {
const [isHovered, setIsHovered] = React.useState(false); const [isHovered, setIsHovered] = React.useState(false);
const [isFocused, setIsFocused] = React.useState(false); const [isFocused, setIsFocused] = React.useState(false);
const greenBackground = useColorModeValue("green.100", "green.900"); const greenBackground = useColorModeValue("green.100", "green.900");
const greenBorderColor = useColorModeValue("green.600", "green.500"); const greenBorderColor = useColorModeValue("green.600", "green.500");
const greenTextColor = useColorModeValue("green.700", "white"); const greenTextColor = useColorModeValue("green.700", "white");
const yellowBackground = useColorModeValue("yellow.100", "yellow.900"); const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500"); const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
const yellowTextColor = useColorModeValue("yellow.700", "white"); const yellowTextColor = useColorModeValue("yellow.700", "white");
return ( return (
<Tooltip <Tooltip
textAlign="center" textAlign="center"
fontSize="xs" fontSize="xs"
placement="bottom" placement="bottom"
label={tooltipLabel} label={tooltipLabel}
// HACK: Chakra tooltips seem inconsistent about staying open when focus // HACK: Chakra tooltips seem inconsistent about staying open when focus
// comes from touch events. But I really want this one to work on // comes from touch events. But I really want this one to work on
// mobile! // mobile!
isOpen={isHovered || isFocused} isOpen={isHovered || isFocused}
> >
<Flex <Flex
align="center" align="center"
backgroundColor={hasGlitches ? yellowBackground : greenBackground} backgroundColor={hasGlitches ? yellowBackground : greenBackground}
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor} borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
color={hasGlitches ? yellowTextColor : greenTextColor} color={hasGlitches ? yellowTextColor : greenTextColor}
border="1px solid" border="1px solid"
borderRadius="md" borderRadius="md"
boxShadow="md" boxShadow="md"
paddingX="2" paddingX="2"
paddingY="1" paddingY="1"
transition="all 0.2s" transition="all 0.2s"
tabIndex="0" tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }} _focus={{ outline: "none", boxShadow: "outline" }}
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge // For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
minHeight="30px" minHeight="30px"
onMouseEnter={() => setIsHovered(true)} onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)} onMouseLeave={() => setIsHovered(false)}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
{...props} {...props}
> >
{children} {children}
</Flex> </Flex>
</Tooltip> </Tooltip>
); );
} }
export function layerUsesHTML5(layer) { export function layerUsesHTML5(layer) {
return Boolean( return Boolean(
layer.svgUrl || layer.svgUrl ||
layer.canvasMovieLibraryUrl || layer.canvasMovieLibraryUrl ||
// If this glitch is applied, then `svgUrl` will be null, but there's still // If this glitch is applied, then `svgUrl` will be null, but there's still
// an HTML5 manifest that the official player can render. // an HTML5 manifest that the official player can render.
(layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"), (layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
); );
} }
export default HTML5Badge; export default HTML5Badge;

View file

@ -4,94 +4,94 @@ import { Box, useColorModeValue } from "@chakra-ui/react";
import { createIcon } from "@chakra-ui/icons"; import { createIcon } from "@chakra-ui/icons";
const HangerIcon = createIcon({ const HangerIcon = createIcon({
displayName: "HangerIcon", displayName: "HangerIcon",
// https://www.svgrepo.com/svg/108090/clothes-hanger // https://www.svgrepo.com/svg/108090/clothes-hanger
viewBox: "0 0 473 473", viewBox: "0 0 473 473",
path: ( path: (
<path <path
fill="currentColor" 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" 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 }) { function HangerSpinner({ size = "md", ...props }) {
const boxSize = { sm: "32px", md: "48px" }[size]; const boxSize = { sm: "32px", md: "48px" }[size];
const color = useColorModeValue("green.500", "green.300"); const color = useColorModeValue("green.500", "green.300");
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <Box
className={css` className={css`
/* /*
Adapted from animate.css "swing". We spend 75% of the time swinging, Adapted from animate.css "swing". We spend 75% of the time swinging,
then 25% of the time pausing before the next loop. then 25% of the time pausing before the next loop.
We use this animation for folks who are okay with dizzy-ish motion. We use this animation for folks who are okay with dizzy-ish motion.
For reduced motion, we use a pulse-fade instead. For reduced motion, we use a pulse-fade instead.
*/ */
@keyframes swing { @keyframes swing {
15% { 15% {
transform: rotate3d(0, 0, 1, 15deg); transform: rotate3d(0, 0, 1, 15deg);
} }
30% { 30% {
transform: rotate3d(0, 0, 1, -10deg); transform: rotate3d(0, 0, 1, -10deg);
} }
45% { 45% {
transform: rotate3d(0, 0, 1, 5deg); transform: rotate3d(0, 0, 1, 5deg);
} }
60% { 60% {
transform: rotate3d(0, 0, 1, -5deg); transform: rotate3d(0, 0, 1, -5deg);
} }
75% { 75% {
transform: rotate3d(0, 0, 1, 0deg); transform: rotate3d(0, 0, 1, 0deg);
} }
100% { 100% {
transform: rotate3d(0, 0, 1, 0deg); transform: rotate3d(0, 0, 1, 0deg);
} }
} }
/* /*
A homebrew fade-pulse animation. We use this for folks who don't A homebrew fade-pulse animation. We use this for folks who don't
like motion. It's an important accessibility thing! like motion. It's an important accessibility thing!
*/ */
@keyframes fade-pulse { @keyframes fade-pulse {
0% { 0% {
opacity: 0.2; opacity: 0.2;
} }
50% { 50% {
opacity: 1; opacity: 1;
} }
100% { 100% {
opacity: 0.2; opacity: 0.2;
} }
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite swing; animation: 1.2s infinite swing;
transform-origin: top center; transform-origin: top center;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite fade-pulse; animation: 1.6s infinite fade-pulse;
} }
`} `}
{...props} {...props}
> >
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" /> <HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
export default HangerSpinner; export default HangerSpinner;

View file

@ -1,20 +1,20 @@
import React from "react"; import React from "react";
import { ClassNames } from "@emotion/react"; import { ClassNames } from "@emotion/react";
import { import {
Badge, Badge,
Box, Box,
SimpleGrid, SimpleGrid,
Tooltip, Tooltip,
Wrap, Wrap,
WrapItem, WrapItem,
useColorModeValue, useColorModeValue,
useTheme, useTheme,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { import {
CheckIcon, CheckIcon,
EditIcon, EditIcon,
NotAllowedIcon, NotAllowedIcon,
StarIcon, StarIcon,
} from "@chakra-ui/icons"; } from "@chakra-ui/icons";
import { HiSparkles } from "react-icons/hi"; import { HiSparkles } from "react-icons/hi";
@ -23,73 +23,73 @@ import { safeImageUrl, useCommonStyles } from "../util";
import usePreferArchive from "./usePreferArchive"; import usePreferArchive from "./usePreferArchive";
function ItemCard({ item, badges, variant = "list", ...props }) { function ItemCard({ item, badges, variant = "list", ...props }) {
const { brightBackground } = useCommonStyles(); const { brightBackground } = useCommonStyles();
switch (variant) { switch (variant) {
case "grid": case "grid":
return <SquareItemCard item={item} {...props} />; return <SquareItemCard item={item} {...props} />;
case "list": case "list":
return ( return (
<Box <Box
as="a" as="a"
href={`/items/${item.id}`} href={`/items/${item.id}`}
display="block" display="block"
p="2" p="2"
boxShadow="lg" boxShadow="lg"
borderRadius="lg" borderRadius="lg"
background={brightBackground} background={brightBackground}
transition="all 0.2s" transition="all 0.2s"
className="item-card" className="item-card"
width="100%" width="100%"
minWidth="0" minWidth="0"
{...props} {...props}
> >
<ItemCardContent <ItemCardContent
item={item} item={item}
badges={badges} badges={badges}
focusSelector=".item-card:hover &, .item-card:focus &" focusSelector=".item-card:hover &, .item-card:focus &"
/> />
</Box> </Box>
); );
default: default:
throw new Error(`Unexpected ItemCard variant: ${variant}`); throw new Error(`Unexpected ItemCard variant: ${variant}`);
} }
} }
export function ItemCardContent({ export function ItemCardContent({
item, item,
badges, badges,
isWorn, isWorn,
isDisabled, isDisabled,
itemNameId, itemNameId,
focusSelector, focusSelector,
}) { }) {
return ( return (
<Box display="flex"> <Box display="flex">
<Box> <Box>
<Box flex="0 0 auto" marginRight="3"> <Box flex="0 0 auto" marginRight="3">
<ItemThumbnail <ItemThumbnail
item={item} item={item}
isActive={isWorn} isActive={isWorn}
isDisabled={isDisabled} isDisabled={isDisabled}
focusSelector={focusSelector} focusSelector={focusSelector}
/> />
</Box> </Box>
</Box> </Box>
<Box flex="1 1 0" minWidth="0" marginTop="1px"> <Box flex="1 1 0" minWidth="0" marginTop="1px">
<ItemName <ItemName
id={itemNameId} id={itemNameId}
isWorn={isWorn} isWorn={isWorn}
isDisabled={isDisabled} isDisabled={isDisabled}
focusSelector={focusSelector} focusSelector={focusSelector}
> >
{item.name} {item.name}
</ItemName> </ItemName>
{badges} {badges}
</Box> </Box>
</Box> </Box>
); );
} }
/** /**
@ -97,88 +97,88 @@ export function ItemCardContent({
* hover/focus and worn/unworn states. * hover/focus and worn/unworn states.
*/ */
export function ItemThumbnail({ export function ItemThumbnail({
item, item,
size = "md", size = "md",
isActive, isActive,
isDisabled, isDisabled,
focusSelector, focusSelector,
...props ...props
}) { }) {
const [preferArchive] = usePreferArchive(); const [preferArchive] = usePreferArchive();
const theme = useTheme(); const theme = useTheme();
const borderColor = useColorModeValue( const borderColor = useColorModeValue(
theme.colors.green["700"], theme.colors.green["700"],
"transparent", "transparent",
); );
const focusBorderColor = useColorModeValue( const focusBorderColor = useColorModeValue(
theme.colors.green["600"], theme.colors.green["600"],
"transparent", "transparent",
); );
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <Box
width={size === "lg" ? "80px" : "50px"} width={size === "lg" ? "80px" : "50px"}
height={size === "lg" ? "80px" : "50px"} height={size === "lg" ? "80px" : "50px"}
transition="all 0.15s" transition="all 0.15s"
transformOrigin="center" transformOrigin="center"
position="relative" position="relative"
className={css([ className={css([
{ {
transform: "scale(0.8)", transform: "scale(0.8)",
}, },
!isDisabled && !isDisabled &&
!isActive && { !isActive && {
[focusSelector]: { [focusSelector]: {
opacity: "0.9", opacity: "0.9",
transform: "scale(0.9)", transform: "scale(0.9)",
}, },
}, },
!isDisabled && !isDisabled &&
isActive && { isActive && {
opacity: 1, opacity: 1,
transform: "none", transform: "none",
}, },
])} ])}
{...props} {...props}
> >
<Box <Box
borderRadius="lg" borderRadius="lg"
boxShadow="md" boxShadow="md"
border="1px" border="1px"
overflow="hidden" overflow="hidden"
width="100%" width="100%"
height="100%" height="100%"
className={css([ className={css([
{ {
borderColor: `${borderColor} !important`, borderColor: `${borderColor} !important`,
}, },
!isDisabled && !isDisabled &&
!isActive && { !isActive && {
[focusSelector]: { [focusSelector]: {
borderColor: `${focusBorderColor} !important`, borderColor: `${focusBorderColor} !important`,
}, },
}, },
])} ])}
> >
{/* If the item is still loading, wait with an empty box. */} {/* If the item is still loading, wait with an empty box. */}
{item && ( {item && (
<Box <Box
as="img" as="img"
width="100%" width="100%"
height="100%" height="100%"
src={safeImageUrl(item.thumbnailUrl, { preferArchive })} src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`} alt={`Thumbnail art for ${item.name}`}
/> />
)} )}
</Box> </Box>
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
/** /**
@ -186,245 +186,245 @@ export function ItemThumbnail({
* states. * states.
*/ */
function ItemName({ children, isDisabled, focusSelector, ...props }) { function ItemName({ children, isDisabled, focusSelector, ...props }) {
const theme = useTheme(); const theme = useTheme();
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <Box
fontSize="md" fontSize="md"
transition="all 0.15s" transition="all 0.15s"
overflow="hidden" overflow="hidden"
whiteSpace="nowrap" whiteSpace="nowrap"
textOverflow="ellipsis" textOverflow="ellipsis"
className={ className={
!isDisabled && !isDisabled &&
css` css`
${focusSelector} { ${focusSelector} {
opacity: 0.9; opacity: 0.9;
font-weight: ${theme.fontWeights.medium}; font-weight: ${theme.fontWeights.medium};
} }
input:checked + .item-container & { input:checked + .item-container & {
opacity: 1; opacity: 1;
font-weight: ${theme.fontWeights.bold}; font-weight: ${theme.fontWeights.bold};
} }
` `
} }
{...props} {...props}
> >
{children} {children}
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
export function ItemCardList({ children }) { export function ItemCardList({ children }) {
return ( return (
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6"> <SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
{children} {children}
</SimpleGrid> </SimpleGrid>
); );
} }
export function ItemBadgeList({ children, ...props }) { export function ItemBadgeList({ children, ...props }) {
return ( return (
<Wrap spacing="2" opacity="0.7" {...props}> <Wrap spacing="2" opacity="0.7" {...props}>
{React.Children.map( {React.Children.map(
children, children,
(badge) => badge && <WrapItem>{badge}</WrapItem>, (badge) => badge && <WrapItem>{badge}</WrapItem>,
)} )}
</Wrap> </Wrap>
); );
} }
export function ItemBadgeTooltip({ label, children }) { export function ItemBadgeTooltip({ label, children }) {
return ( return (
<Tooltip <Tooltip
label={<Box textAlign="center">{label}</Box>} label={<Box textAlign="center">{label}</Box>}
placement="top" placement="top"
openDelay={400} openDelay={400}
> >
{children} {children}
</Tooltip> </Tooltip>
); );
} }
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return ( return (
<ItemBadgeTooltip label="Neocash"> <ItemBadgeTooltip label="Neocash">
<Badge <Badge
ref={ref} ref={ref}
as={isEditButton ? "button" : "span"} as={isEditButton ? "button" : "span"}
colorScheme="purple" colorScheme="purple"
display="flex" display="flex"
alignItems="center" alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }} _focus={{ outline: "none", boxShadow: "outline" }}
{...props} {...props}
> >
NC NC
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />} {isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge> </Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
}); });
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return ( return (
<ItemBadgeTooltip label="Neopoints"> <ItemBadgeTooltip label="Neopoints">
<Badge <Badge
ref={ref} ref={ref}
as={isEditButton ? "button" : "span"} as={isEditButton ? "button" : "span"}
display="flex" display="flex"
alignItems="center" alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }} _focus={{ outline: "none", boxShadow: "outline" }}
{...props} {...props}
> >
NP NP
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />} {isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge> </Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
}); });
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => { export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
return ( return (
<ItemBadgeTooltip label="This item is only obtainable via paintbrush"> <ItemBadgeTooltip label="This item is only obtainable via paintbrush">
<Badge <Badge
ref={ref} ref={ref}
as={isEditButton ? "button" : "span"} as={isEditButton ? "button" : "span"}
colorScheme="orange" colorScheme="orange"
display="flex" display="flex"
alignItems="center" alignItems="center"
_focus={{ outline: "none", boxShadow: "outline" }} _focus={{ outline: "none", boxShadow: "outline" }}
{...props} {...props}
> >
PB PB
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />} {isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
</Badge> </Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
}); });
export const ItemKindBadge = React.forwardRef( export const ItemKindBadge = React.forwardRef(
({ isNc, isPb, isEditButton, ...props }, ref) => { ({ isNc, isPb, isEditButton, ...props }, ref) => {
if (isNc) { if (isNc) {
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />; return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else if (isPb) { } else if (isPb) {
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />; return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
} else { } else {
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />; return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
} }
}, },
); );
export function YouOwnThisBadge({ variant = "long" }) { export function YouOwnThisBadge({ variant = "long" }) {
let badge = ( let badge = (
<Badge <Badge
colorScheme="green" colorScheme="green"
display="flex" display="flex"
alignItems="center" alignItems="center"
minHeight="1.5em" minHeight="1.5em"
> >
<CheckIcon aria-label="Check" /> <CheckIcon aria-label="Check" />
{variant === "medium" && <Box marginLeft="1">Own</Box>} {variant === "medium" && <Box marginLeft="1">Own</Box>}
{variant === "long" && <Box marginLeft="1">You own this!</Box>} {variant === "long" && <Box marginLeft="1">You own this!</Box>}
</Badge> </Badge>
); );
if (variant === "short" || variant === "medium") { if (variant === "short" || variant === "medium") {
badge = ( badge = (
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip> <ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
); );
} }
return badge; return badge;
} }
export function YouWantThisBadge({ variant = "long" }) { export function YouWantThisBadge({ variant = "long" }) {
let badge = ( let badge = (
<Badge <Badge
colorScheme="blue" colorScheme="blue"
display="flex" display="flex"
alignItems="center" alignItems="center"
minHeight="1.5em" minHeight="1.5em"
> >
<StarIcon aria-label="Star" /> <StarIcon aria-label="Star" />
{variant === "medium" && <Box marginLeft="1">Want</Box>} {variant === "medium" && <Box marginLeft="1">Want</Box>}
{variant === "long" && <Box marginLeft="1">You want this!</Box>} {variant === "long" && <Box marginLeft="1">You want this!</Box>}
</Badge> </Badge>
); );
if (variant === "short" || variant === "medium") { if (variant === "short" || variant === "medium") {
badge = ( badge = (
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip> <ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
); );
} }
return badge; return badge;
} }
function ZoneBadge({ variant, zoneLabel }) { function ZoneBadge({ variant, zoneLabel }) {
// Shorten the label when necessary, to make the badges less bulky // Shorten the label when necessary, to make the badges less bulky
const shorthand = zoneLabel const shorthand = zoneLabel
.replace("Background Item", "BG Item") .replace("Background Item", "BG Item")
.replace("Foreground Item", "FG Item") .replace("Foreground Item", "FG Item")
.replace("Lower-body", "Lower") .replace("Lower-body", "Lower")
.replace("Upper-body", "Upper") .replace("Upper-body", "Upper")
.replace("Transient", "Trans") .replace("Transient", "Trans")
.replace("Biology", "Bio"); .replace("Biology", "Bio");
if (variant === "restricts") { if (variant === "restricts") {
return ( return (
<ItemBadgeTooltip <ItemBadgeTooltip
label={`Restricted: This item can't be worn with ${zoneLabel} items`} label={`Restricted: This item can't be worn with ${zoneLabel} items`}
> >
<Badge> <Badge>
<Box display="flex" alignItems="center"> <Box display="flex" alignItems="center">
{shorthand} <NotAllowedIcon marginLeft="1" /> {shorthand} <NotAllowedIcon marginLeft="1" />
</Box> </Box>
</Badge> </Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
} }
if (shorthand !== zoneLabel) { if (shorthand !== zoneLabel) {
return ( return (
<ItemBadgeTooltip label={zoneLabel}> <ItemBadgeTooltip label={zoneLabel}>
<Badge>{shorthand}</Badge> <Badge>{shorthand}</Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
} }
return <Badge>{shorthand}</Badge>; return <Badge>{shorthand}</Badge>;
} }
export function getZoneBadges(zones, propsForAllBadges) { export function getZoneBadges(zones, propsForAllBadges) {
// Get the sorted zone labels. Sometimes an item occupies multiple zones of // Get the sorted zone labels. Sometimes an item occupies multiple zones of
// the same name, so it's important to de-duplicate them! // the same name, so it's important to de-duplicate them!
let labels = zones.map((z) => z.label); let labels = zones.map((z) => z.label);
labels = new Set(labels); labels = new Set(labels);
labels = [...labels].sort(); labels = [...labels].sort();
return labels.map((label) => ( return labels.map((label) => (
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} /> <ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
)); ));
} }
export function MaybeAnimatedBadge() { export function MaybeAnimatedBadge() {
return ( return (
<ItemBadgeTooltip label="Maybe animated? (Support only)"> <ItemBadgeTooltip label="Maybe animated? (Support only)">
<Badge <Badge
colorScheme="orange" colorScheme="orange"
display="flex" display="flex"
alignItems="center" alignItems="center"
minHeight="1.5em" minHeight="1.5em"
> >
<Box as={HiSparkles} aria-label="Sparkles" /> <Box as={HiSparkles} aria-label="Sparkles" />
</Badge> </Badge>
</ItemBadgeTooltip> </ItemBadgeTooltip>
); );
} }
export default ItemCard; export default ItemCard;

View file

@ -17,471 +17,471 @@ new Function(easelSource).call(window);
new Function(tweenSource).call(window); new Function(tweenSource).call(window);
function OutfitMovieLayer({ function OutfitMovieLayer({
libraryUrl, libraryUrl,
width, width,
height, height,
placeholderImageUrl = null, placeholderImageUrl = null,
isPaused = false, isPaused = false,
onLoad = null, onLoad = null,
onError = null, onError = null,
onLowFps = null, onLowFps = null,
canvasProps = {}, canvasProps = {},
}) { }) {
const [preferArchive] = usePreferArchive(); const [preferArchive] = usePreferArchive();
const [stage, setStage] = React.useState(null); const [stage, setStage] = React.useState(null);
const [library, setLibrary] = React.useState(null); const [library, setLibrary] = React.useState(null);
const [movieClip, setMovieClip] = React.useState(null); const [movieClip, setMovieClip] = React.useState(null);
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false); const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false); const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
const canvasRef = React.useRef(null); const canvasRef = React.useRef(null);
const hasShownErrorMessageRef = React.useRef(false); const hasShownErrorMessageRef = React.useRef(false);
const toast = useToast(); const toast = useToast();
// Set the canvas's internal dimensions to be higher, if the device has high // 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! // DPI like retina. But we'll keep the layout width/height as expected!
const internalWidth = width * window.devicePixelRatio; const internalWidth = width * window.devicePixelRatio;
const internalHeight = height * window.devicePixelRatio; const internalHeight = height * window.devicePixelRatio;
const callOnLoadIfNotYetCalled = React.useCallback(() => { const callOnLoadIfNotYetCalled = React.useCallback(() => {
setHasCalledOnLoad((alreadyHasCalledOnLoad) => { setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
if (!alreadyHasCalledOnLoad && onLoad) { if (!alreadyHasCalledOnLoad && onLoad) {
onLoad(); onLoad();
} }
return true; return true;
}); });
}, [onLoad]); }, [onLoad]);
const updateStage = React.useCallback(() => { const updateStage = React.useCallback(() => {
if (!stage) { if (!stage) {
return; return;
} }
try { try {
stage.update(); stage.update();
} catch (e) { } catch (e) {
// If rendering the frame fails, log it and proceed. If it's an // 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, // 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 // just as an FYI. (This is pretty uncommon, so I'm not worried about
// being noisy!) // being noisy!)
if (!hasShownErrorMessageRef.current) { if (!hasShownErrorMessageRef.current) {
console.error(`Error rendering movie clip ${libraryUrl}`); console.error(`Error rendering movie clip ${libraryUrl}`);
logAndCapture(e); logAndCapture(e);
toast({ toast({
status: "warning", status: "warning",
title: title:
"Hmm, we're maybe having trouble playing one of these animations.", "Hmm, we're maybe having trouble playing one of these animations.",
description: description:
"If it looks wrong, try pausing and playing, or reloading the " + "If it looks wrong, try pausing and playing, or reloading the " +
"page. Sorry!", "page. Sorry!",
duration: 10000, duration: 10000,
isClosable: true, isClosable: true,
}); });
// We do this via a ref, not state, because I want to guarantee that // 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 // future calls see the new value. With state, React's effects might
// not happen in the right order for it to work! // not happen in the right order for it to work!
hasShownErrorMessageRef.current = true; hasShownErrorMessageRef.current = true;
} }
} }
}, [stage, toast, libraryUrl]); }, [stage, toast, libraryUrl]);
// This effect gives us a `stage` corresponding to the canvas element. // This effect gives us a `stage` corresponding to the canvas element.
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
if (!canvas) { if (!canvas) {
return; return;
} }
if (canvas.getContext("2d") == null) { if (canvas.getContext("2d") == null) {
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`); console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
toast({ toast({
status: "warning", status: "warning",
title: "Oops, too many animations!", title: "Oops, too many animations!",
description: description:
`Your device is out of memory, so we can't show any more ` + `Your device is out of memory, so we can't show any more ` +
`animations. Try removing some items, or using another device.`, `animations. Try removing some items, or using another device.`,
duration: null, duration: null,
isClosable: true, isClosable: true,
}); });
return; return;
} }
setStage((stage) => { setStage((stage) => {
if (stage && stage.canvas === canvas) { if (stage && stage.canvas === canvas) {
return stage; return stage;
} }
return new window.createjs.Stage(canvas); return new window.createjs.Stage(canvas);
}); });
return () => { return () => {
setStage(null); setStage(null);
if (canvas) { if (canvas) {
// There's a Safari bug where it doesn't reliably garbage-collect // There's a Safari bug where it doesn't reliably garbage-collect
// canvas data. Clean it up ourselves, rather than leaking memory over // canvas data. Clean it up ourselves, rather than leaking memory over
// time! https://stackoverflow.com/a/52586606/107415 // time! https://stackoverflow.com/a/52586606/107415
// https://bugs.webkit.org/show_bug.cgi?id=195325 // https://bugs.webkit.org/show_bug.cgi?id=195325
canvas.width = 0; canvas.width = 0;
canvas.height = 0; canvas.height = 0;
} }
}; };
}, [libraryUrl, toast]); }, [libraryUrl, toast]);
// This effect gives us the `library` and `movieClip`, based on the incoming // This effect gives us the `library` and `movieClip`, based on the incoming
// `libraryUrl`. // `libraryUrl`.
React.useEffect(() => { React.useEffect(() => {
let canceled = false; let canceled = false;
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive }); const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
movieLibraryPromise movieLibraryPromise
.then((library) => { .then((library) => {
if (canceled) { if (canceled) {
return; return;
} }
setLibrary(library); setLibrary(library);
const movieClip = buildMovieClip(library, libraryUrl); const movieClip = buildMovieClip(library, libraryUrl);
setMovieClip(movieClip); setMovieClip(movieClip);
}) })
.catch((e) => { .catch((e) => {
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e); console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
if (onError) { if (onError) {
onError(e); onError(e);
} }
}); });
return () => { return () => {
canceled = true; canceled = true;
movieLibraryPromise.cancel(); movieLibraryPromise.cancel();
setLibrary(null); setLibrary(null);
setMovieClip(null); setMovieClip(null);
}; };
}, [libraryUrl, preferArchive, onError]); }, [libraryUrl, preferArchive, onError]);
// This effect puts the `movieClip` on the `stage`, when both are ready. // This effect puts the `movieClip` on the `stage`, when both are ready.
React.useEffect(() => { React.useEffect(() => {
if (!stage || !movieClip) { if (!stage || !movieClip) {
return; return;
} }
stage.addChild(movieClip); stage.addChild(movieClip);
// Render the movie's first frame. If it's animated and we're not paused, // Render the movie's first frame. If it's animated and we're not paused,
// then another effect will perform subsequent updates. // then another effect will perform subsequent updates.
updateStage(); updateStage();
// This is when we trigger `onLoad`: once we're actually showing it! // This is when we trigger `onLoad`: once we're actually showing it!
callOnLoadIfNotYetCalled(); callOnLoadIfNotYetCalled();
setMovieIsLoaded(true); setMovieIsLoaded(true);
return () => stage.removeChild(movieClip); return () => stage.removeChild(movieClip);
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]); }, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
// This effect updates the `stage` according to the `library`'s framerate, // 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 // but only if there's actual animation to do - i.e., there's more than one
// frame to show, and we're not paused. // frame to show, and we're not paused.
React.useEffect(() => { React.useEffect(() => {
if (!stage || !movieClip || !library) { if (!stage || !movieClip || !library) {
return; return;
} }
if (isPaused || !hasAnimations(movieClip)) { if (isPaused || !hasAnimations(movieClip)) {
return; return;
} }
const targetFps = library.properties.fps; const targetFps = library.properties.fps;
let lastFpsLoggedAtInMs = performance.now(); let lastFpsLoggedAtInMs = performance.now();
let numFramesSinceLastLogged = 0; let numFramesSinceLastLogged = 0;
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
const now = performance.now(); const now = performance.now();
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs; const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000; const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec; const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
const roundedFps = Math.round(fps * 100) / 100; const roundedFps = Math.round(fps * 100) / 100;
// If the page is visible, render the next frame, and track that we did. // 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, // 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 // compute and log the FPS during those two seconds. (Checking the page
// visibility is both an optimization to avoid rendering the movie, but // visibility is both an optimization to avoid rendering the movie, but
// also makes "low FPS" tracking more accurate: browsers already throttle // also makes "low FPS" tracking more accurate: browsers already throttle
// intervals when the page is hidden, so a low FPS is *expected*, and // 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.) // wouldn't indicate a performance problem like a low FPS normally would.)
if (!document.hidden) { if (!document.hidden) {
updateStage(); updateStage();
numFramesSinceLastLogged++; numFramesSinceLastLogged++;
if (timeSinceLastFpsLoggedAtInSec > 2) { if (timeSinceLastFpsLoggedAtInSec > 2) {
console.debug( console.debug(
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`, `[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
); );
if (onLowFps && fps < 2) { if (onLowFps && fps < 2) {
onLowFps(fps); onLowFps(fps);
} }
lastFpsLoggedAtInMs = now; lastFpsLoggedAtInMs = now;
numFramesSinceLastLogged = 0; numFramesSinceLastLogged = 0;
} }
} }
}, 1000 / targetFps); }, 1000 / targetFps);
const onVisibilityChange = () => { const onVisibilityChange = () => {
// When the page switches from hidden to visible, reset the FPS counter // When the page switches from hidden to visible, reset the FPS counter
// state, to start counting from When Visibility Came Back, rather than // state, to start counting from When Visibility Came Back, rather than
// from when we last counted, which could be a long time ago. // from when we last counted, which could be a long time ago.
if (!document.hidden) { if (!document.hidden) {
lastFpsLoggedAtInMs = performance.now(); lastFpsLoggedAtInMs = performance.now();
numFramesSinceLastLogged = 0; numFramesSinceLastLogged = 0;
console.debug( console.debug(
`[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`, `[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
); );
} else { } else {
console.debug( console.debug(
`[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`, `[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
); );
} }
}; };
document.addEventListener("visibilitychange", onVisibilityChange); document.addEventListener("visibilitychange", onVisibilityChange);
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
document.removeEventListener("visibilitychange", onVisibilityChange); document.removeEventListener("visibilitychange", onVisibilityChange);
}; };
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]); }, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
// This effect keeps the `movieClip` scaled correctly, based on the canvas // This effect keeps the `movieClip` scaled correctly, based on the canvas
// size and the `library`'s natural size declaration. (If the canvas size // 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 // changes on window resize, then this will keep us responsive, so long as
// the parent updates our width/height props on window resize!) // the parent updates our width/height props on window resize!)
React.useEffect(() => { React.useEffect(() => {
if (!stage || !movieClip || !library) { if (!stage || !movieClip || !library) {
return; return;
} }
movieClip.scaleX = internalWidth / library.properties.width; movieClip.scaleX = internalWidth / library.properties.width;
movieClip.scaleY = internalHeight / library.properties.height; movieClip.scaleY = internalHeight / library.properties.height;
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set // 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 // 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 // really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing. // we're playing.
stage.tickOnUpdate = false; stage.tickOnUpdate = false;
updateStage(); updateStage();
stage.tickOnUpdate = true; stage.tickOnUpdate = true;
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]); }, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
return ( return (
<Grid templateAreas="single-shared-area"> <Grid templateAreas="single-shared-area">
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={internalWidth} width={internalWidth}
height={internalHeight} height={internalHeight}
style={{ style={{
width: width, width: width,
height: height, height: height,
gridArea: "single-shared-area", gridArea: "single-shared-area",
}} }}
data-is-loaded={movieIsLoaded} data-is-loaded={movieIsLoaded}
{...canvasProps} {...canvasProps}
/> />
{/* While the movie is loading, we show our image version as a {/* While the movie is loading, we show our image version as a
* placeholder, because it generally loads much faster. * placeholder, because it generally loads much faster.
* TODO: Show a loading indicator for this partially-loaded state? */} * TODO: Show a loading indicator for this partially-loaded state? */}
{placeholderImageUrl && ( {placeholderImageUrl && (
<Box <Box
as="img" as="img"
src={safeImageUrl(placeholderImageUrl)} src={safeImageUrl(placeholderImageUrl)}
width={width} width={width}
height={height} height={height}
gridArea="single-shared-area" gridArea="single-shared-area"
opacity={movieIsLoaded ? 0 : 1} opacity={movieIsLoaded ? 0 : 1}
transition="opacity 0.2s" transition="opacity 0.2s"
onLoad={callOnLoadIfNotYetCalled} onLoad={callOnLoadIfNotYetCalled}
/> />
)} )}
</Grid> </Grid>
); );
} }
function loadScriptTag(src) { function loadScriptTag(src) {
let script; let script;
let canceled = false; let canceled = false;
let resolved = false; let resolved = false;
const scriptTagPromise = new Promise((resolve, reject) => { const scriptTagPromise = new Promise((resolve, reject) => {
script = document.createElement("script"); script = document.createElement("script");
script.onload = () => { script.onload = () => {
if (canceled) return; if (canceled) return;
resolved = true; resolved = true;
resolve(script); resolve(script);
}; };
script.onerror = (e) => { script.onerror = (e) => {
if (canceled) return; if (canceled) return;
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`)); reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
}; };
script.src = src; script.src = src;
document.body.appendChild(script); document.body.appendChild(script);
}); });
scriptTagPromise.cancel = () => { scriptTagPromise.cancel = () => {
if (resolved) return; if (resolved) return;
script.src = ""; script.src = "";
canceled = true; canceled = true;
}; };
return scriptTagPromise; return scriptTagPromise;
} }
const MOVIE_LIBRARY_CACHE = new LRU(10); const MOVIE_LIBRARY_CACHE = new LRU(10);
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) { export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
const cancelableResourcePromises = []; const cancelableResourcePromises = [];
const cancelAllResources = () => const cancelAllResources = () =>
cancelableResourcePromises.forEach((p) => p.cancel()); cancelableResourcePromises.forEach((p) => p.cancel());
// Most of the logic for `loadMovieLibrary` is inside this async function. // 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 // 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 // we declare this async function separately, then call it, then edit the
// returned promise! // returned promise!
const createMovieLibraryPromise = async () => { const createMovieLibraryPromise = async () => {
// First, check the LRU cache. This will enable us to quickly return movie // First, check the LRU cache. This will enable us to quickly return movie
// libraries, without re-loading and re-parsing and re-executing. // libraries, without re-loading and re-parsing and re-executing.
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc); const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
if (cachedLibrary) { if (cachedLibrary) {
return cachedLibrary; return cachedLibrary;
} }
// Then, load the script tag. (Make sure we set it up to be cancelable!) // Then, load the script tag. (Make sure we set it up to be cancelable!)
const scriptPromise = loadScriptTag( const scriptPromise = loadScriptTag(
safeImageUrl(librarySrc, { preferArchive }), safeImageUrl(librarySrc, { preferArchive }),
); );
cancelableResourcePromises.push(scriptPromise); cancelableResourcePromises.push(scriptPromise);
await scriptPromise; await scriptPromise;
// These library JS files are interesting in their operation. It seems like // 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 // 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 // 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 // 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! // try to grab it once it arrives!
// //
// I'm not _sure_ this method is reliable, but it seems to be stable so far // 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: // in Firefox for me. The things I think I'm observing are:
// - Script execution order should match insert order, // - Script execution order should match insert order,
// - Onload execution order should match insert order, // - Onload execution order should match insert order,
// - BUT, script executions might be batched before onloads. // - BUT, script executions might be batched before onloads.
// - So, each script grabs the _first_ composition from the list, and // - So, each script grabs the _first_ composition from the list, and
// deletes it after grabbing. That way, it serves as a FIFO queue! // 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 // I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
// the race anymore? But fingers crossed! // the race anymore? But fingers crossed!
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) { if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
throw new Error( throw new Error(
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`, `Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
); );
} }
const [compositionId, composition] = Object.entries( const [compositionId, composition] = Object.entries(
window.AdobeAn.compositions, window.AdobeAn.compositions,
)[0]; )[0];
if (Object.keys(window.AdobeAn.compositions).length > 1) { if (Object.keys(window.AdobeAn.compositions).length > 1) {
console.warn( console.warn(
`Grabbing composition ${compositionId}, but there are >1 here: `, `Grabbing composition ${compositionId}, but there are >1 here: `,
Object.keys(window.AdobeAn.compositions).length, Object.keys(window.AdobeAn.compositions).length,
); );
} }
delete window.AdobeAn.compositions[compositionId]; delete window.AdobeAn.compositions[compositionId];
const library = composition.getLibrary(); const library = composition.getLibrary();
// One more loading step as part of loading this library is loading the // One more loading step as part of loading this library is loading the
// images it uses for sprites. // images it uses for sprites.
// //
// TODO: I guess the manifest has these too, so if we could use our DB cache // 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 // 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 // on the critical path by preloading these images before the JS file
// even gets to us? // even gets to us?
const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/"); const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
const manifestImages = new Map( const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [ library.properties.manifest.map(({ id, src }) => [
id, id,
loadImage(librarySrcDir + "/" + src, { loadImage(librarySrcDir + "/" + src, {
crossOrigin: "anonymous", crossOrigin: "anonymous",
preferArchive, preferArchive,
}), }),
]), ]),
); );
// Wait for the images, and make sure they're cancelable while we do. // Wait for the images, and make sure they're cancelable while we do.
const manifestImagePromises = manifestImages.values(); const manifestImagePromises = manifestImages.values();
cancelableResourcePromises.push(...manifestImagePromises); cancelableResourcePromises.push(...manifestImagePromises);
await Promise.all(manifestImagePromises); await Promise.all(manifestImagePromises);
// Finally, once we have the images loaded, the library object expects us to // 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 // 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 // the loaded images. That's how the MovieClip's internal JS objects will
// access the loaded data! // access the loaded data!
const images = composition.getImages(); const images = composition.getImages();
for (const [id, image] of manifestImages.entries()) { for (const [id, image] of manifestImages.entries()) {
images[id] = await image; images[id] = await image;
} }
const spriteSheets = composition.getSpriteSheet(); const spriteSheets = composition.getSpriteSheet();
for (const { name, frames } of library.ssMetadata) { for (const { name, frames } of library.ssMetadata) {
const image = await manifestImages.get(name); const image = await manifestImages.get(name);
spriteSheets[name] = new window.createjs.SpriteSheet({ spriteSheets[name] = new window.createjs.SpriteSheet({
images: [image], images: [image],
frames, frames,
}); });
} }
MOVIE_LIBRARY_CACHE.set(librarySrc, library); MOVIE_LIBRARY_CACHE.set(librarySrc, library);
return library; return library;
}; };
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => { const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
// When any part of the movie library fails, we also cancel the other // When any part of the movie library fails, we also cancel the other
// resources ourselves, to avoid stray throws for resources that fail after // resources ourselves, to avoid stray throws for resources that fail after
// the parent catches the initial failure. We re-throw the initial failure // the parent catches the initial failure. We re-throw the initial failure
// for the parent to handle, though! // for the parent to handle, though!
cancelAllResources(); cancelAllResources();
throw e; throw e;
}); });
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we // To cancel a `loadMovieLibrary`, cancel all of the resource promises we
// load as part of it. That should effectively halt the async function above // load as part of it. That should effectively halt the async function above
// (anything not yet loaded will stop loading), and ensure that stray // (anything not yet loaded will stop loading), and ensure that stray
// failures don't trigger uncaught promise rejection warnings. // failures don't trigger uncaught promise rejection warnings.
movieLibraryPromise.cancel = cancelAllResources; movieLibraryPromise.cancel = cancelAllResources;
return movieLibraryPromise; return movieLibraryPromise;
} }
export function buildMovieClip(library, libraryUrl) { export function buildMovieClip(library, libraryUrl) {
let constructorName; let constructorName;
try { try {
const fileName = decodeURI(libraryUrl).split("/").pop(); const fileName = decodeURI(libraryUrl).split("/").pop();
const fileNameWithoutExtension = fileName.split(".")[0]; const fileNameWithoutExtension = fileName.split(".")[0];
constructorName = fileNameWithoutExtension.replace(/[ -]/g, ""); constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
if (constructorName.match(/^[0-9]/)) { if (constructorName.match(/^[0-9]/)) {
constructorName = "_" + constructorName; constructorName = "_" + constructorName;
} }
} catch (e) { } catch (e) {
throw new Error( throw new Error(
`Movie libraryUrl ${JSON.stringify( `Movie libraryUrl ${JSON.stringify(
libraryUrl, libraryUrl,
)} did not match expected format: ${e.message}`, )} did not match expected format: ${e.message}`,
); );
} }
const LibraryMovieClipConstructor = library[constructorName]; const LibraryMovieClipConstructor = library[constructorName];
if (!LibraryMovieClipConstructor) { if (!LibraryMovieClipConstructor) {
throw new Error( throw new Error(
`Expected JS movie library ${libraryUrl} to contain a constructor ` + `Expected JS movie library ${libraryUrl} to contain a constructor ` +
`named ${constructorName}, but it did not: ${Object.keys(library)}`, `named ${constructorName}, but it did not: ${Object.keys(library)}`,
); );
} }
const movieClip = new LibraryMovieClipConstructor(); const movieClip = new LibraryMovieClipConstructor();
return movieClip; return movieClip;
} }
/** /**
@ -489,15 +489,15 @@ export function buildMovieClip(library, libraryUrl) {
* there are any animated areas. * there are any animated areas.
*/ */
export function hasAnimations(createjsNode) { export function hasAnimations(createjsNode) {
return ( return (
// Some nodes have simple animation frames. // Some nodes have simple animation frames.
createjsNode.totalFrames > 1 || createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames. // Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly. // They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 || createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated. // And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations) (createjsNode.children || []).some(hasAnimations)
); );
} }
export default OutfitMovieLayer; export default OutfitMovieLayer;

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { import {
Box, Box,
DarkMode, DarkMode,
Flex, Flex,
Text, Text,
useColorModeValue, useColorModeValue,
useToast, useToast,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import LRU from "lru-cache"; import LRU from "lru-cache";
import { WarningIcon } from "@chakra-ui/icons"; import { WarningIcon } from "@chakra-ui/icons";
@ -13,9 +13,9 @@ import { ClassNames } from "@emotion/react";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import OutfitMovieLayer, { import OutfitMovieLayer, {
buildMovieClip, buildMovieClip,
hasAnimations, hasAnimations,
loadMovieLibrary, loadMovieLibrary,
} from "./OutfitMovieLayer"; } from "./OutfitMovieLayer";
import HangerSpinner from "./HangerSpinner"; import HangerSpinner from "./HangerSpinner";
import { loadImage, safeImageUrl, useLocalStorage } from "../util"; import { loadImage, safeImageUrl, useLocalStorage } from "../util";
@ -37,8 +37,8 @@ import usePreferArchive from "./usePreferArchive";
* useOutfitState both getting appearance data on first load... * useOutfitState both getting appearance data on first load...
*/ */
function OutfitPreview(props) { function OutfitPreview(props) {
const { preview } = useOutfitPreview(props); const { preview } = useOutfitPreview(props);
return preview; return preview;
} }
/** /**
@ -49,110 +49,110 @@ function OutfitPreview(props) {
* want to show some additional UI that uses the appearance data we loaded! * want to show some additional UI that uses the appearance data we loaded!
*/ */
export function useOutfitPreview({ export function useOutfitPreview({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId, altStyleId,
wornItemIds, wornItemIds,
appearanceId = null, appearanceId = null,
isLoading = false, isLoading = false,
placeholder = null, placeholder = null,
loadingDelayMs, loadingDelayMs,
spinnerVariant, spinnerVariant,
onChangeHasAnimations = null, onChangeHasAnimations = null,
...props ...props
}) { }) {
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true); const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
const toast = useToast(); const toast = useToast();
const appearance = useOutfitAppearance({ const appearance = useOutfitAppearance({
speciesId, speciesId,
colorId, colorId,
pose, pose,
altStyleId, altStyleId,
appearanceId, appearanceId,
wornItemIds, wornItemIds,
}); });
const { loading, error, visibleLayers } = appearance; const { loading, error, visibleLayers } = appearance;
const { const {
loading: loading2, loading: loading2,
error: error2, error: error2,
loadedLayers, loadedLayers,
layersHaveAnimations, layersHaveAnimations,
} = usePreloadLayers(visibleLayers); } = usePreloadLayers(visibleLayers);
const onMovieError = React.useCallback(() => { const onMovieError = React.useCallback(() => {
if (!toast.isActive("outfit-preview-on-movie-error")) { if (!toast.isActive("outfit-preview-on-movie-error")) {
toast({ toast({
id: "outfit-preview-on-movie-error", id: "outfit-preview-on-movie-error",
status: "warning", status: "warning",
title: "Oops, we couldn't load one of these animations.", title: "Oops, we couldn't load one of these animations.",
description: "We'll show a static image version instead.", description: "We'll show a static image version instead.",
duration: null, duration: null,
isClosable: true, isClosable: true,
}); });
} }
}, [toast]); }, [toast]);
const onLowFps = React.useCallback( const onLowFps = React.useCallback(
(fps) => { (fps) => {
setIsPaused(true); setIsPaused(true);
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`); console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
if (!toast.isActive("outfit-preview-on-low-fps")) { if (!toast.isActive("outfit-preview-on-low-fps")) {
toast({ toast({
id: "outfit-preview-on-low-fps", id: "outfit-preview-on-low-fps",
status: "warning", status: "warning",
title: "Sorry, the animation was lagging, so we paused it! 😖", title: "Sorry, the animation was lagging, so we paused it! 😖",
description: description:
"We do this to help make sure your machine doesn't lag too much! " + "We do this to help make sure your machine doesn't lag too much! " +
"You can unpause the preview to try again.", "You can unpause the preview to try again.",
duration: null, duration: null,
isClosable: true, isClosable: true,
}); });
} }
}, },
[setIsPaused, toast], [setIsPaused, toast],
); );
React.useEffect(() => { React.useEffect(() => {
if (onChangeHasAnimations) { if (onChangeHasAnimations) {
onChangeHasAnimations(layersHaveAnimations); onChangeHasAnimations(layersHaveAnimations);
} }
}, [layersHaveAnimations, onChangeHasAnimations]); }, [layersHaveAnimations, onChangeHasAnimations]);
const textColor = useColorModeValue("green.700", "white"); const textColor = useColorModeValue("green.700", "white");
let preview; let preview;
if (error || error2) { if (error || error2) {
preview = ( preview = (
<FullScreenCenter> <FullScreenCenter>
<Text color={textColor} d="flex" alignItems="center"> <Text color={textColor} d="flex" alignItems="center">
<WarningIcon /> <WarningIcon />
<Box width={2} /> <Box width={2} />
Could not load preview. Try again? Could not load preview. Try again?
</Text> </Text>
</FullScreenCenter> </FullScreenCenter>
); );
} else { } else {
preview = ( preview = (
<OutfitLayers <OutfitLayers
loading={isLoading || loading || loading2} loading={isLoading || loading || loading2}
visibleLayers={loadedLayers} visibleLayers={loadedLayers}
placeholder={placeholder} placeholder={placeholder}
loadingDelayMs={loadingDelayMs} loadingDelayMs={loadingDelayMs}
spinnerVariant={spinnerVariant} spinnerVariant={spinnerVariant}
onMovieError={onMovieError} onMovieError={onMovieError}
onLowFps={onLowFps} onLowFps={onLowFps}
doTransitions doTransitions
isPaused={isPaused} isPaused={isPaused}
{...props} {...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! * used both in the main outfit preview, and in other minor UIs!
*/ */
export function OutfitLayers({ export function OutfitLayers({
loading, loading,
visibleLayers, visibleLayers,
placeholder = null, placeholder = null,
loadingDelayMs = 500, loadingDelayMs = 500,
spinnerVariant = "overlay", spinnerVariant = "overlay",
doTransitions = false, doTransitions = false,
isPaused = true, isPaused = true,
onMovieError = null, onMovieError = null,
onLowFps = null, onLowFps = null,
...props ...props
}) { }) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false); const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive(); const [preferArchive] = usePreferArchive();
const containerRef = React.useRef(null); const containerRef = React.useRef(null);
const [canvasSize, setCanvasSize] = React.useState(0); const [canvasSize, setCanvasSize] = React.useState(0);
const [loadingDelayHasPassed, setLoadingDelayHasPassed] = const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
React.useState(false); React.useState(false);
// When we start in a loading state, or re-enter a loading state, start the // When we start in a loading state, or re-enter a loading state, start the
// loading delay timer. // loading delay timer.
React.useEffect(() => { React.useEffect(() => {
if (loading) { if (loading) {
setLoadingDelayHasPassed(false); setLoadingDelayHasPassed(false);
const t = setTimeout( const t = setTimeout(
() => setLoadingDelayHasPassed(true), () => setLoadingDelayHasPassed(true),
loadingDelayMs, loadingDelayMs,
); );
return () => clearTimeout(t); return () => clearTimeout(t);
} }
}, [loadingDelayMs, loading]); }, [loadingDelayMs, loading]);
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
function computeAndSaveCanvasSize() { function computeAndSaveCanvasSize() {
setCanvasSize( setCanvasSize(
// Follow an algorithm similar to the <img> sizing: a square that // Follow an algorithm similar to the <img> sizing: a square that
// covers the available space, without exceeding the natural image size // covers the available space, without exceeding the natural image size
// (which is 600px). // (which is 600px).
// //
// TODO: Once we're entirely off PNGs, we could drop the 600 // TODO: Once we're entirely off PNGs, we could drop the 600
// requirement, and let SVGs and movies scale up as far as they // requirement, and let SVGs and movies scale up as far as they
// want... // want...
Math.min( Math.min(
containerRef.current.offsetWidth, containerRef.current.offsetWidth,
containerRef.current.offsetHeight, containerRef.current.offsetHeight,
600, 600,
), ),
); );
} }
computeAndSaveCanvasSize(); computeAndSaveCanvasSize();
window.addEventListener("resize", computeAndSaveCanvasSize); window.addEventListener("resize", computeAndSaveCanvasSize);
return () => window.removeEventListener("resize", computeAndSaveCanvasSize); return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
}, [setCanvasSize]); }, [setCanvasSize]);
const layersWithAssets = visibleLayers.filter((l) => const layersWithAssets = visibleLayers.filter((l) =>
layerHasUsableAssets(l, { hiResMode }), layerHasUsableAssets(l, { hiResMode }),
); );
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <Box
pos="relative" pos="relative"
height="100%" height="100%"
width="100%" width="100%"
maxWidth="600px" maxWidth="600px"
maxHeight="600px" maxHeight="600px"
// Create a stacking context, so the z-indexed layers don't escape! // Create a stacking context, so the z-indexed layers don't escape!
zIndex="0" zIndex="0"
ref={containerRef} ref={containerRef}
data-loading={loading ? true : undefined} data-loading={loading ? true : undefined}
{...props} {...props}
> >
{placeholder && ( {placeholder && (
<FullScreenCenter> <FullScreenCenter>
<Box <Box
// We show the placeholder until there are visible layers, at which // We show the placeholder until there are visible layers, at which
// point we fade it out. // point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0} opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s" transition="opacity 0.2s"
width="100%" width="100%"
height="100%" height="100%"
maxWidth="600px" maxWidth="600px"
maxHeight="600px" maxHeight="600px"
> >
{placeholder} {placeholder}
</Box> </Box>
</FullScreenCenter> </FullScreenCenter>
)} )}
<TransitionGroup enter={false} exit={doTransitions}> <TransitionGroup enter={false} exit={doTransitions}>
{layersWithAssets.map((layer) => ( {layersWithAssets.map((layer) => (
<CSSTransition <CSSTransition
// We manage the fade-in and fade-out separately! The fade-out // We manage the fade-in and fade-out separately! The fade-out
// happens here, when the layer exits the DOM. // happens here, when the layer exits the DOM.
key={layer.id} key={layer.id}
timeout={200} timeout={200}
> >
<FadeInOnLoad <FadeInOnLoad
as={FullScreenCenter} as={FullScreenCenter}
zIndex={layer.zone.depth} zIndex={layer.zone.depth}
className={css` className={css`
&.exit { &.exit {
opacity: 1; opacity: 1;
} }
&.exit-active { &.exit-active {
opacity: 0; opacity: 0;
transition: opacity 0.2s; transition: opacity 0.2s;
} }
`} `}
> >
{layer.canvasMovieLibraryUrl ? ( {layer.canvasMovieLibraryUrl ? (
<OutfitMovieLayer <OutfitMovieLayer
libraryUrl={layer.canvasMovieLibraryUrl} libraryUrl={layer.canvasMovieLibraryUrl}
placeholderImageUrl={getBestImageUrlForLayer(layer, { placeholderImageUrl={getBestImageUrlForLayer(layer, {
hiResMode, hiResMode,
})} })}
width={canvasSize} width={canvasSize}
height={canvasSize} height={canvasSize}
isPaused={isPaused} isPaused={isPaused}
onError={onMovieError} onError={onMovieError}
onLowFps={onLowFps} onLowFps={onLowFps}
/> />
) : ( ) : (
<Box <Box
as="img" as="img"
src={safeImageUrl( src={safeImageUrl(
getBestImageUrlForLayer(layer, { hiResMode }), getBestImageUrlForLayer(layer, { hiResMode }),
{ preferArchive }, { preferArchive },
)} )}
alt="" alt=""
objectFit="contain" objectFit="contain"
maxWidth="100%" maxWidth="100%"
maxHeight="100%" maxHeight="100%"
/> />
)} )}
</FadeInOnLoad> </FadeInOnLoad>
</CSSTransition> </CSSTransition>
))} ))}
</TransitionGroup> </TransitionGroup>
<FullScreenCenter <FullScreenCenter
zIndex="9000" zIndex="9000"
// This is similar to our Delay util component, but Delay disappears // This is similar to our Delay util component, but Delay disappears
// immediately on load, whereas we want this to fade out smoothly. We // 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 // 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 // fade-out at all. (The timeout was an awkward choice, it was hard to
// find a good CSS way to specify this delay well!) // find a good CSS way to specify this delay well!)
opacity={loading && loadingDelayHasPassed ? 1 : 0} opacity={loading && loadingDelayHasPassed ? 1 : 0}
transition="opacity 0.2s" transition="opacity 0.2s"
> >
{spinnerVariant === "overlay" && ( {spinnerVariant === "overlay" && (
<> <>
<Box <Box
position="absolute" position="absolute"
top="0" top="0"
left="0" left="0"
right="0" right="0"
bottom="0" bottom="0"
backgroundColor="gray.900" backgroundColor="gray.900"
opacity="0.7" opacity="0.7"
/> />
{/* Against the dark overlay, use the Dark Mode spinner. */} {/* Against the dark overlay, use the Dark Mode spinner. */}
<DarkMode> <DarkMode>
<HangerSpinner /> <HangerSpinner />
</DarkMode> </DarkMode>
</> </>
)} )}
{spinnerVariant === "corner" && ( {spinnerVariant === "corner" && (
<HangerSpinner <HangerSpinner
size="sm" size="sm"
position="absolute" position="absolute"
bottom="2" bottom="2"
right="2" right="2"
/> />
)} )}
</FullScreenCenter> </FullScreenCenter>
</Box> </Box>
)} )}
</ClassNames> </ClassNames>
); );
} }
export function FullScreenCenter({ children, ...otherProps }) { export function FullScreenCenter({ children, ...otherProps }) {
return ( return (
<Flex <Flex
pos="absolute" pos="absolute"
top="0" top="0"
right="0" right="0"
bottom="0" bottom="0"
left="0" left="0"
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
{...otherProps} {...otherProps}
> >
{children} {children}
</Flex> </Flex>
); );
} }
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) { export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
if (hiResMode && layer.svgUrl) { if (hiResMode && layer.svgUrl) {
return layer.svgUrl; return layer.svgUrl;
} else if (layer.imageUrl) { } else if (layer.imageUrl) {
return layer.imageUrl; return layer.imageUrl;
} else { } else {
return null; return null;
} }
} }
function layerHasUsableAssets(layer, options = {}) { 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! * all the new layers are ready, then show them all at once!
*/ */
export function usePreloadLayers(layers) { export function usePreloadLayers(layers) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false); const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const [preferArchive] = usePreferArchive(); const [preferArchive] = usePreferArchive();
const [error, setError] = React.useState(null); const [error, setError] = React.useState(null);
const [loadedLayers, setLoadedLayers] = React.useState([]); const [loadedLayers, setLoadedLayers] = React.useState([]);
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false); const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
// NOTE: This condition would need to change if we started loading one at a // 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! // time, or if the error case would need to show a partial state!
const loading = layers.length > 0 && loadedLayers !== layers; const loading = layers.length > 0 && loadedLayers !== layers;
React.useEffect(() => { React.useEffect(() => {
// HACK: Don't clear the preview when we have zero layers, because it // 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 // usually means the parent is still loading data. I feel like this isn't
// the right abstraction, though... // the right abstraction, though...
if (layers.length === 0) { if (layers.length === 0) {
return; return;
} }
let canceled = false; let canceled = false;
setError(null); setError(null);
setLayersHaveAnimations(false); setLayersHaveAnimations(false);
const minimalAssetPromises = []; const minimalAssetPromises = [];
const imageAssetPromises = []; const imageAssetPromises = [];
const movieAssetPromises = []; const movieAssetPromises = [];
for (const layer of layers) { for (const layer of layers) {
const imageUrl = getBestImageUrlForLayer(layer, { hiResMode }); const imageUrl = getBestImageUrlForLayer(layer, { hiResMode });
const imageAssetPromise = const imageAssetPromise =
imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null; imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null;
if (imageAssetPromise != null) { if (imageAssetPromise != null) {
imageAssetPromises.push(imageAssetPromise); imageAssetPromises.push(imageAssetPromise);
} }
if (layer.canvasMovieLibraryUrl) { if (layer.canvasMovieLibraryUrl) {
// Start preloading the movie. But we won't block on it! The blocking // 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 // request will still be the image, which we'll show as a
// placeholder, which should usually be noticeably faster! // placeholder, which should usually be noticeably faster!
const movieLibraryPromise = loadMovieLibrary( const movieLibraryPromise = loadMovieLibrary(
layer.canvasMovieLibraryUrl, layer.canvasMovieLibraryUrl,
{ preferArchive }, { preferArchive },
); );
const movieAssetPromise = movieLibraryPromise.then((library) => ({ const movieAssetPromise = movieLibraryPromise.then((library) => ({
library, library,
libraryUrl: layer.canvasMovieLibraryUrl, libraryUrl: layer.canvasMovieLibraryUrl,
})); }));
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl; movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
movieAssetPromise.cancel = () => movieLibraryPromise.cancel(); movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
movieAssetPromises.push(movieAssetPromise); movieAssetPromises.push(movieAssetPromise);
// The minimal asset for the movie case is *either* the image *or* // The minimal asset for the movie case is *either* the image *or*
// the movie, because we can start rendering when either is ready. // the movie, because we can start rendering when either is ready.
minimalAssetPromises.push( minimalAssetPromises.push(
Promise.any([imageAssetPromise, movieAssetPromise]), Promise.any([imageAssetPromise, movieAssetPromise]),
); );
} else if (imageAssetPromise != null) { } else if (imageAssetPromise != null) {
minimalAssetPromises.push(imageAssetPromise); minimalAssetPromises.push(imageAssetPromise);
} else { } else {
console.warn( console.warn(
`Skipping preloading layer ${layer.id}: no asset URLs found`, `Skipping preloading layer ${layer.id}: no asset URLs found`,
); );
} }
} }
// When the minimal assets have loaded, we can say the layers have // When the minimal assets have loaded, we can say the layers have
// loaded, and allow the UI to start showing them! // loaded, and allow the UI to start showing them!
Promise.all(minimalAssetPromises) Promise.all(minimalAssetPromises)
.then(() => { .then(() => {
if (canceled) return; if (canceled) return;
setLoadedLayers(layers); setLoadedLayers(layers);
}) })
.catch((e) => { .catch((e) => {
if (canceled) return; if (canceled) return;
console.error("Error preloading outfit layers", e); console.error("Error preloading outfit layers", e);
setError(e); setError(e);
// Cancel any remaining promises, if cancelable. // Cancel any remaining promises, if cancelable.
imageAssetPromises.forEach((p) => p.cancel && p.cancel()); imageAssetPromises.forEach((p) => p.cancel && p.cancel());
movieAssetPromises.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 // As the movie assets come in, check them for animations, to decide
// whether to show the Play/Pause button. // whether to show the Play/Pause button.
const checkHasAnimations = (asset) => { const checkHasAnimations = (asset) => {
if (canceled) return; if (canceled) return;
let assetHasAnimations; let assetHasAnimations;
try { try {
assetHasAnimations = getHasAnimationsForMovieAsset(asset); assetHasAnimations = getHasAnimationsForMovieAsset(asset);
} catch (e) { } catch (e) {
console.error("Error testing layers for animations", e); console.error("Error testing layers for animations", e);
setError(e); setError(e);
return; return;
} }
setLayersHaveAnimations( setLayersHaveAnimations(
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations, (alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
); );
}; };
movieAssetPromises.forEach((p) => movieAssetPromises.forEach((p) =>
p.then(checkHasAnimations).catch((e) => { p.then(checkHasAnimations).catch((e) => {
console.error(`Error preloading movie library ${p.libraryUrl}:`, e); console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
}), }),
); );
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [layers, hiResMode, preferArchive]); }, [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 // 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); const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
function getHasAnimationsForMovieAsset({ library, libraryUrl }) { function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
// This operation can be pretty expensive! We store a cache to only do it // 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. // once per layer per session ish, instead of on each outfit change.
const cachedHasAnimations = const cachedHasAnimations =
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl); HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
if (cachedHasAnimations) { if (cachedHasAnimations) {
return 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 // 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 // before the children mount onto the stage. If we detect animations
// without doing this, we'll incorrectly say no, because we see no children! // 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 // Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
movieClip.advance(); movieClip.advance();
const movieClipHasAnimations = hasAnimations(movieClip); const movieClipHasAnimations = hasAnimations(movieClip);
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations); HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
return movieClipHasAnimations; return movieClipHasAnimations;
} }
/** /**
@ -524,18 +524,18 @@ function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
* the container element once it triggers. * the container element once it triggers.
*/ */
function FadeInOnLoad({ children, ...props }) { 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 child = React.Children.only(children);
const wrappedChild = React.cloneElement(child, { onLoad }); const wrappedChild = React.cloneElement(child, { onLoad });
return ( return (
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}> <Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
{wrappedChild} {wrappedChild}
</Box> </Box>
); );
} }
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any // 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 // 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) // surprisingly high. And the polyfill is small, so let's do it! (11/2021)
Promise.any = Promise.any =
Promise.any || Promise.any ||
function ($) { function ($) {
return new Promise(function (D, E, A, L) { return new Promise(function (D, E, A, L) {
A = []; A = [];
L = $.map(function ($, i) { L = $.map(function ($, i) {
return Promise.resolve($).then(D, function (O) { return Promise.resolve($).then(D, function (O) {
return ((A[i] = O), --L) || E({ errors: A }); return ((A[i] = O), --L) || E({ errors: A });
}); });
}).length; }).length;
}); });
}; };
export default OutfitPreview; export default OutfitPreview;

View file

@ -2,21 +2,21 @@ import React from "react";
import { Box } from "@chakra-ui/react"; import { Box } from "@chakra-ui/react";
function OutfitThumbnail({ outfitId, updatedAt, ...props }) { 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 // 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. // 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 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`; const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
return ( return (
<Box <Box
as="img" as="img"
src={thumbnailUrl150} src={thumbnailUrl150}
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`} srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
{...props} {...props}
/> />
); );
} }
export default OutfitThumbnail; export default OutfitThumbnail;

View file

@ -2,111 +2,111 @@ import React from "react";
import { Box, Button, Flex, Select } from "@chakra-ui/react"; import { Box, Button, Flex, Select } from "@chakra-ui/react";
function PaginationToolbar({ function PaginationToolbar({
isLoading, isLoading,
numTotalPages, numTotalPages,
currentPageNumber, currentPageNumber,
goToPageNumber, goToPageNumber,
buildPageUrl, buildPageUrl,
size = "md", size = "md",
...props ...props
}) { }) {
const pagesAreLoaded = currentPageNumber != null && numTotalPages != null; const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
const hasPrevPage = pagesAreLoaded && currentPageNumber > 1; const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages; const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null; const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null; const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
return ( return (
<Flex align="center" justify="space-between" {...props}> <Flex align="center" justify="space-between" {...props}>
<LinkOrButton <LinkOrButton
href={prevPageUrl} href={prevPageUrl}
onClick={ onClick={
prevPageUrl == null prevPageUrl == null
? () => goToPageNumber(currentPageNumber - 1) ? () => goToPageNumber(currentPageNumber - 1)
: undefined : undefined
} }
_disabled={{ _disabled={{
cursor: isLoading ? "wait" : "not-allowed", cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4, opacity: 0.4,
}} }}
isDisabled={!hasPrevPage} isDisabled={!hasPrevPage}
size={size} size={size}
> >
Prev Prev
</LinkOrButton> </LinkOrButton>
{numTotalPages > 0 && ( {numTotalPages > 0 && (
<Flex align="center" paddingX="4" fontSize={size}> <Flex align="center" paddingX="4" fontSize={size}>
<Box flex="0 0 auto">Page</Box> <Box flex="0 0 auto">Page</Box>
<Box width="1" /> <Box width="1" />
<PageNumberSelect <PageNumberSelect
currentPageNumber={currentPageNumber} currentPageNumber={currentPageNumber}
numTotalPages={numTotalPages} numTotalPages={numTotalPages}
onChange={goToPageNumber} onChange={goToPageNumber}
marginBottom="-2px" marginBottom="-2px"
size={size} size={size}
/> />
<Box width="1" /> <Box width="1" />
<Box flex="0 0 auto">of {numTotalPages}</Box> <Box flex="0 0 auto">of {numTotalPages}</Box>
</Flex> </Flex>
)} )}
<LinkOrButton <LinkOrButton
href={nextPageUrl} href={nextPageUrl}
onClick={ onClick={
nextPageUrl == null nextPageUrl == null
? () => goToPageNumber(currentPageNumber + 1) ? () => goToPageNumber(currentPageNumber + 1)
: undefined : undefined
} }
_disabled={{ _disabled={{
cursor: isLoading ? "wait" : "not-allowed", cursor: isLoading ? "wait" : "not-allowed",
opacity: 0.4, opacity: 0.4,
}} }}
isDisabled={!hasNextPage} isDisabled={!hasNextPage}
size={size} size={size}
> >
Next Next
</LinkOrButton> </LinkOrButton>
</Flex> </Flex>
); );
} }
function LinkOrButton({ href, ...props }) { function LinkOrButton({ href, ...props }) {
if (href != null) { if (href != null) {
return <Button as="a" href={href} {...props} />; return <Button as="a" href={href} {...props} />;
} else { } else {
return <Button {...props} />; return <Button {...props} />;
} }
} }
function PageNumberSelect({ function PageNumberSelect({
currentPageNumber, currentPageNumber,
numTotalPages, numTotalPages,
onChange, onChange,
...props ...props
}) { }) {
const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1); const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
const handleChange = React.useCallback( const handleChange = React.useCallback(
(e) => onChange(Number(e.target.value)), (e) => onChange(Number(e.target.value)),
[onChange], [onChange],
); );
return ( return (
<Select <Select
value={currentPageNumber} value={currentPageNumber}
onChange={handleChange} onChange={handleChange}
width="7ch" width="7ch"
variant="flushed" variant="flushed"
textAlign="center" textAlign="center"
{...props} {...props}
> >
{allPageNumbers.map((pageNumber) => ( {allPageNumbers.map((pageNumber) => (
<option key={pageNumber} value={pageNumber}> <option key={pageNumber} value={pageNumber}>
{pageNumber} {pageNumber}
</option> </option>
))} ))}
</Select> </Select>
); );
} }
export default PaginationToolbar; export default PaginationToolbar;

View file

@ -18,320 +18,320 @@ import { Delay, logAndCapture, useFetch } from "../util";
* devices. * devices.
*/ */
function SpeciesColorPicker({ function SpeciesColorPicker({
speciesId, speciesId,
colorId, colorId,
idealPose, idealPose,
showPlaceholders = false, showPlaceholders = false,
colorPlaceholderText = "", colorPlaceholderText = "",
speciesPlaceholderText = "", speciesPlaceholderText = "",
stateMustAlwaysBeValid = false, stateMustAlwaysBeValid = false,
isDisabled = false, isDisabled = false,
speciesIsDisabled = false, speciesIsDisabled = false,
size = "md", size = "md",
speciesTestId = null, speciesTestId = null,
colorTestId = null, colorTestId = null,
onChange, onChange,
}) { }) {
const { const {
loading: loadingMeta, loading: loadingMeta,
error: errorMeta, error: errorMeta,
data: meta, data: meta,
} = useQuery(gql` } = useQuery(gql`
query SpeciesColorPicker { query SpeciesColorPicker {
allSpecies { allSpecies {
id id
name name
standardBodyId # Used for keeping items on during standard color changes standardBodyId # Used for keeping items on during standard color changes
} }
allColors { allColors {
id id
name name
isStandard # Used for keeping items on during standard color changes isStandard # Used for keeping items on during standard color changes
} }
} }
`); `);
const { const {
loading: loadingValids, loading: loadingValids,
error: errorValids, error: errorValids,
valids, valids,
} = useAllValidPetPoses(); } = useAllValidPetPoses();
const allColors = (meta && [...meta.allColors]) || []; const allColors = (meta && [...meta.allColors]) || [];
allColors.sort((a, b) => a.name.localeCompare(b.name)); allColors.sort((a, b) => a.name.localeCompare(b.name));
const allSpecies = (meta && [...meta.allSpecies]) || []; const allSpecies = (meta && [...meta.allSpecies]) || [];
allSpecies.sort((a, b) => a.name.localeCompare(b.name)); 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) { if ((loadingMeta || loadingValids) && !showPlaceholders) {
return ( return (
<Delay ms={5000}> <Delay ms={5000}>
<Text color={textColor} textShadow="md"> <Text color={textColor} textShadow="md">
Loading species/color data Loading species/color data
</Text> </Text>
</Delay> </Delay>
); );
} }
if (errorMeta || errorValids) { if (errorMeta || errorValids) {
return ( return (
<Text color={textColor} textShadow="md"> <Text color={textColor} textShadow="md">
Error loading species/color data. Error loading species/color data.
</Text> </Text>
); );
} }
// When the color changes, check if the new pair is valid, and update the // When the color changes, check if the new pair is valid, and update the
// outfit if so! // outfit if so!
const onChangeColor = (e) => { const onChangeColor = (e) => {
const newColorId = e.target.value; const newColorId = e.target.value;
console.debug(`SpeciesColorPicker.onChangeColor`, { console.debug(`SpeciesColorPicker.onChangeColor`, {
// for IMPRESS-2020-1H // for IMPRESS-2020-1H
speciesId, speciesId,
colorId, colorId,
newColorId, newColorId,
}); });
// Ignore switching to the placeholder option. It shouldn't generally be // Ignore switching to the placeholder option. It shouldn't generally be
// doable once real options exist, and it doesn't represent a valid or // doable once real options exist, and it doesn't represent a valid or
// meaningful transition in the case where it could happen. // meaningful transition in the case where it could happen.
if (newColorId === "SpeciesColorPicker-color-loading-placeholder") { if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
return; return;
} }
const species = allSpecies.find((s) => s.id === speciesId); const species = allSpecies.find((s) => s.id === speciesId);
const newColor = allColors.find((c) => c.id === newColorId); const newColor = allColors.find((c) => c.id === newColorId);
const validPoses = getValidPoses(valids, speciesId, newColorId); const validPoses = getValidPoses(valids, speciesId, newColorId);
const isValid = validPoses.size > 0; const isValid = validPoses.size > 0;
if (stateMustAlwaysBeValid && !isValid) { if (stateMustAlwaysBeValid && !isValid) {
// NOTE: This shouldn't happen, because we should hide invalid colors. // NOTE: This shouldn't happen, because we should hide invalid colors.
logAndCapture( logAndCapture(
new Error( new Error(
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` + `Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` + `with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
`colorId=${newColorId}.`, `colorId=${newColorId}.`,
), ),
); );
return; return;
} }
const closestPose = getClosestPose(validPoses, idealPose); const closestPose = getClosestPose(validPoses, idealPose);
onChange(species, newColor, isValid, closestPose); onChange(species, newColor, isValid, closestPose);
}; };
// When the species changes, check if the new pair is valid, and update the // When the species changes, check if the new pair is valid, and update the
// outfit if so! // outfit if so!
const onChangeSpecies = (e) => { const onChangeSpecies = (e) => {
const newSpeciesId = e.target.value; const newSpeciesId = e.target.value;
console.debug(`SpeciesColorPicker.onChangeSpecies`, { console.debug(`SpeciesColorPicker.onChangeSpecies`, {
// for IMPRESS-2020-1H // for IMPRESS-2020-1H
speciesId, speciesId,
newSpeciesId, newSpeciesId,
colorId, colorId,
}); });
// Ignore switching to the placeholder option. It shouldn't generally be // Ignore switching to the placeholder option. It shouldn't generally be
// doable once real options exist, and it doesn't represent a valid or // doable once real options exist, and it doesn't represent a valid or
// meaningful transition in the case where it could happen. // meaningful transition in the case where it could happen.
if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") { if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
return; return;
} }
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId); const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
if (!newSpecies) { if (!newSpecies) {
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species // Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
// ends up coming out of `onChange`! // ends up coming out of `onChange`!
console.debug({ allSpecies, loadingMeta, errorMeta, meta }); console.debug({ allSpecies, loadingMeta, errorMeta, meta });
logAndCapture( logAndCapture(
new Error( new Error(
`Assertion error in SpeciesColorPicker: species not found. ` + `Assertion error in SpeciesColorPicker: species not found. ` +
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` + `speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
`colorId=${colorId}.`, `colorId=${colorId}.`,
), ),
); );
return; return;
} }
let color = allColors.find((c) => c.id === colorId); let color = allColors.find((c) => c.id === colorId);
let validPoses = getValidPoses(valids, newSpeciesId, colorId); let validPoses = getValidPoses(valids, newSpeciesId, colorId);
let isValid = validPoses.size > 0; let isValid = validPoses.size > 0;
if (stateMustAlwaysBeValid && !isValid) { if (stateMustAlwaysBeValid && !isValid) {
// If `stateMustAlwaysBeValid`, but the user switches to a species that // If `stateMustAlwaysBeValid`, but the user switches to a species that
// doesn't support this color, that's okay and normal! We'll just switch // doesn't support this color, that's okay and normal! We'll just switch
// to one of the four basic colors instead. // to one of the four basic colors instead.
const basicColorId = ["8", "34", "61", "84"][ const basicColorId = ["8", "34", "61", "84"][
Math.floor(Math.random() * 4) Math.floor(Math.random() * 4)
]; ];
const basicColor = allColors.find((c) => c.id === basicColorId); const basicColor = allColors.find((c) => c.id === basicColorId);
color = basicColor; color = basicColor;
validPoses = getValidPoses(valids, newSpeciesId, color.id); validPoses = getValidPoses(valids, newSpeciesId, color.id);
isValid = true; isValid = true;
} }
const closestPose = getClosestPose(validPoses, idealPose); const closestPose = getClosestPose(validPoses, idealPose);
onChange(newSpecies, color, isValid, closestPose); onChange(newSpecies, color, isValid, closestPose);
}; };
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this // In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
// species, so the user can't switch. (We handle species differently: if you // 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 // 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 // think this matches users' mental hierarchy of species -> color: showing
// supported colors for a species makes sense, but the other way around feels // supported colors for a species makes sense, but the other way around feels
// confusing and restrictive.) // confusing and restrictive.)
// //
// Also, if a color is provided that wouldn't normally be visible, we still // 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 // 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 // the first time - the boxes will still be red as if it were invalid, but
// this still smooths out the experience a lot. // this still smooths out the experience a lot.
let visibleColors = allColors; let visibleColors = allColors;
if (stateMustAlwaysBeValid && valids && speciesId) { if (stateMustAlwaysBeValid && valids && speciesId) {
visibleColors = visibleColors.filter( visibleColors = visibleColors.filter(
(c) => (c) =>
getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId, getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId,
); );
} }
return ( return (
<Flex direction="row"> <Flex direction="row">
<SpeciesColorSelect <SpeciesColorSelect
aria-label="Pet color" aria-label="Pet color"
value={colorId || "SpeciesColorPicker-color-loading-placeholder"} value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't // 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 // trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading. // hasn't provided species and color yet, assume it's still loading.
isLoading={ isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId allColors.length === 0 || loadingValids || !speciesId || !colorId
} }
isDisabled={isDisabled} isDisabled={isDisabled}
onChange={onChangeColor} onChange={onChangeColor}
size={size} size={size}
valids={valids} valids={valids}
speciesId={speciesId} speciesId={speciesId}
colorId={colorId} colorId={colorId}
data-test-id={colorTestId} data-test-id={colorTestId}
> >
{ {
// If the selected color isn't in the set we have here, show the // 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 // placeholder. (Can happen during loading, or if an invalid color ID
// like null is intentionally provided while the real value loads.) // like null is intentionally provided while the real value loads.)
!visibleColors.some((c) => c.id === colorId) && ( !visibleColors.some((c) => c.id === colorId) && (
<option value="SpeciesColorPicker-color-loading-placeholder"> <option value="SpeciesColorPicker-color-loading-placeholder">
{colorPlaceholderText} {colorPlaceholderText}
</option> </option>
) )
} }
{ {
// A long name for sizing! Should appear below the placeholder, out // A long name for sizing! Should appear below the placeholder, out
// of view. // of view.
visibleColors.length === 0 && <option>Dimensional</option> visibleColors.length === 0 && <option>Dimensional</option>
} }
{visibleColors.map((color) => ( {visibleColors.map((color) => (
<option key={color.id} value={color.id}> <option key={color.id} value={color.id}>
{color.name} {color.name}
</option> </option>
))} ))}
</SpeciesColorSelect> </SpeciesColorSelect>
<Box width={size === "sm" ? 2 : 4} /> <Box width={size === "sm" ? 2 : 4} />
<SpeciesColorSelect <SpeciesColorSelect
aria-label="Pet species" aria-label="Pet species"
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"} value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
// We also wait for the valid pairs before enabling, so users can't // 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 // trigger change events we're not ready for. Also, if the caller
// hasn't provided species and color yet, assume it's still loading. // hasn't provided species and color yet, assume it's still loading.
isLoading={ isLoading={
allColors.length === 0 || loadingValids || !speciesId || !colorId allColors.length === 0 || loadingValids || !speciesId || !colorId
} }
isDisabled={isDisabled || speciesIsDisabled} isDisabled={isDisabled || speciesIsDisabled}
// Don't fade out in the speciesIsDisabled case; it's more like a // Don't fade out in the speciesIsDisabled case; it's more like a
// read-only state. // read-only state.
_disabled={ _disabled={
speciesIsDisabled speciesIsDisabled
? { opacity: "1", cursor: "not-allowed" } ? { opacity: "1", cursor: "not-allowed" }
: undefined : undefined
} }
onChange={onChangeSpecies} onChange={onChangeSpecies}
size={size} size={size}
valids={valids} valids={valids}
speciesId={speciesId} speciesId={speciesId}
colorId={colorId} colorId={colorId}
data-test-id={speciesTestId} data-test-id={speciesTestId}
> >
{ {
// If the selected species isn't in the set we have here, show the // If the selected species isn't in the set we have here, show the
// placeholder. (Can happen during loading, or if an invalid species // placeholder. (Can happen during loading, or if an invalid species
// ID like null is intentionally provided while the real value // ID like null is intentionally provided while the real value
// loads.) // loads.)
!allSpecies.some((s) => s.id === speciesId) && ( !allSpecies.some((s) => s.id === speciesId) && (
<option value="SpeciesColorPicker-species-loading-placeholder"> <option value="SpeciesColorPicker-species-loading-placeholder">
{speciesPlaceholderText} {speciesPlaceholderText}
</option> </option>
) )
} }
{ {
// A long name for sizing! Should appear below the placeholder, out // A long name for sizing! Should appear below the placeholder, out
// of view. // of view.
allSpecies.length === 0 && <option>Tuskaninny</option> allSpecies.length === 0 && <option>Tuskaninny</option>
} }
{allSpecies.map((species) => ( {allSpecies.map((species) => (
<option key={species.id} value={species.id}> <option key={species.id} value={species.id}>
{species.name} {species.name}
</option> </option>
))} ))}
</SpeciesColorSelect> </SpeciesColorSelect>
</Flex> </Flex>
); );
} }
const SpeciesColorSelect = ({ const SpeciesColorSelect = ({
size, size,
valids, valids,
speciesId, speciesId,
colorId, colorId,
isDisabled, isDisabled,
isLoading, isLoading,
...props ...props
}) => { }) => {
const backgroundColor = useColorModeValue("white", "gray.600"); const backgroundColor = useColorModeValue("white", "gray.600");
const borderColor = useColorModeValue("green.600", "transparent"); const borderColor = useColorModeValue("green.600", "transparent");
const textColor = useColorModeValue("inherit", "green.50"); const textColor = useColorModeValue("inherit", "green.50");
const loadingProps = isLoading const loadingProps = isLoading
? { ? {
// Visually the disabled state is the same as the normal state, but // 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 // with a wait cursor. We don't expect this to take long, and the flash
// of content is rough! // of content is rough!
opacity: "1 !important", opacity: "1 !important",
cursor: "wait !important", cursor: "wait !important",
} }
: {}; : {};
return ( return (
<Select <Select
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
color={textColor} color={textColor}
size={size} size={size}
border="1px" border="1px"
borderColor={borderColor} borderColor={borderColor}
boxShadow="md" boxShadow="md"
width="auto" width="auto"
transition="all 0.25s" transition="all 0.25s"
_hover={{ _hover={{
borderColor: "green.400", borderColor: "green.400",
}} }}
isInvalid={ isInvalid={
valids && valids &&
speciesId && speciesId &&
colorId && colorId &&
!pairIsValid(valids, speciesId, colorId) !pairIsValid(valids, speciesId, colorId)
} }
isDisabled={isDisabled || isLoading} isDisabled={isDisabled || isLoading}
errorBorderColor="red.300" errorBorderColor="red.300"
{...props} {...props}
{...loadingProps} {...loadingProps}
/> />
); );
}; };
let cachedResponseForAllValidPetPoses = null; let cachedResponseForAllValidPetPoses = null;
@ -346,79 +346,76 @@ let cachedResponseForAllValidPetPoses = null;
* data from GraphQL serves on the first render, without a loading state. * data from GraphQL serves on the first render, without a loading state.
*/ */
export function useAllValidPetPoses() { export function useAllValidPetPoses() {
const networkResponse = useFetch( const networkResponse = useFetch(buildImpress2020Url("/api/validPetPoses"), {
buildImpress2020Url("/api/validPetPoses"), responseType: "arrayBuffer",
{ // If we already have globally-cached valids, skip the request.
responseType: "arrayBuffer", skip: cachedResponseForAllValidPetPoses != null,
// 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 // Use the globally-cached response if we have one, or await the network
// response if not. // response if not.
const response = cachedResponseForAllValidPetPoses || networkResponse; const response = cachedResponseForAllValidPetPoses || networkResponse;
const { loading, error, data: validsBuffer } = response; const { loading, error, data: validsBuffer } = response;
const valids = React.useMemo( const valids = React.useMemo(
() => validsBuffer && new DataView(validsBuffer), () => validsBuffer && new DataView(validsBuffer),
[validsBuffer], [validsBuffer],
); );
// Once a network response comes in, save it as the globally-cached response. // Once a network response comes in, save it as the globally-cached response.
React.useEffect(() => { React.useEffect(() => {
if ( if (
networkResponse && networkResponse &&
!networkResponse.loading && !networkResponse.loading &&
!cachedResponseForAllValidPetPoses !cachedResponseForAllValidPetPoses
) { ) {
cachedResponseForAllValidPetPoses = networkResponse; cachedResponseForAllValidPetPoses = networkResponse;
} }
}, [networkResponse]); }, [networkResponse]);
return { loading, error, valids }; return { loading, error, valids };
} }
function getPairByte(valids, speciesId, colorId) { function getPairByte(valids, speciesId, colorId) {
// Reading a bit table, owo! // Reading a bit table, owo!
const speciesIndex = speciesId - 1; const speciesIndex = speciesId - 1;
const colorIndex = colorId - 1; const colorIndex = colorId - 1;
const numColors = valids.getUint8(1); const numColors = valids.getUint8(1);
const pairByteIndex = speciesIndex * numColors + colorIndex + 2; const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
try { try {
return valids.getUint8(pairByteIndex); return valids.getUint8(pairByteIndex);
} catch (e) { } catch (e) {
logAndCapture( logAndCapture(
new Error( new Error(
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`, `Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`,
), ),
); );
return 0; return 0;
} }
} }
function pairIsValid(valids, speciesId, colorId) { function pairIsValid(valids, speciesId, colorId) {
return getPairByte(valids, speciesId, colorId) !== 0; return getPairByte(valids, speciesId, colorId) !== 0;
} }
export function getValidPoses(valids, speciesId, colorId) { export function getValidPoses(valids, speciesId, colorId) {
const pairByte = getPairByte(valids, speciesId, colorId); const pairByte = getPairByte(valids, speciesId, colorId);
const validPoses = new Set(); const validPoses = new Set();
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC"); if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
if (pairByte & 0b00000010) validPoses.add("SAD_MASC"); if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
if (pairByte & 0b00000100) validPoses.add("SICK_MASC"); if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM"); if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
if (pairByte & 0b00010000) validPoses.add("SAD_FEM"); if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
if (pairByte & 0b00100000) validPoses.add("SICK_FEM"); if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED"); if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
if (pairByte & 0b10000000) validPoses.add("UNKNOWN"); if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
return validPoses; return validPoses;
} }
export function getClosestPose(validPoses, idealPose) { 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? // 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. // - Unconverted vs converted is the biggest possible difference.
// - Unknown is the pose of last resort - even coming from another unknown. // - Unknown is the pose of last resort - even coming from another unknown.
const closestPosesInOrder = { const closestPosesInOrder = {
HAPPY_MASC: [ HAPPY_MASC: [
"HAPPY_MASC", "HAPPY_MASC",
"HAPPY_FEM", "HAPPY_FEM",
"SAD_MASC", "SAD_MASC",
"SAD_FEM", "SAD_FEM",
"SICK_MASC", "SICK_MASC",
"SICK_FEM", "SICK_FEM",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
HAPPY_FEM: [ HAPPY_FEM: [
"HAPPY_FEM", "HAPPY_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"SAD_FEM", "SAD_FEM",
"SAD_MASC", "SAD_MASC",
"SICK_FEM", "SICK_FEM",
"SICK_MASC", "SICK_MASC",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
SAD_MASC: [ SAD_MASC: [
"SAD_MASC", "SAD_MASC",
"SAD_FEM", "SAD_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"HAPPY_FEM", "HAPPY_FEM",
"SICK_MASC", "SICK_MASC",
"SICK_FEM", "SICK_FEM",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
SAD_FEM: [ SAD_FEM: [
"SAD_FEM", "SAD_FEM",
"SAD_MASC", "SAD_MASC",
"HAPPY_FEM", "HAPPY_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"SICK_FEM", "SICK_FEM",
"SICK_MASC", "SICK_MASC",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
SICK_MASC: [ SICK_MASC: [
"SICK_MASC", "SICK_MASC",
"SICK_FEM", "SICK_FEM",
"SAD_MASC", "SAD_MASC",
"SAD_FEM", "SAD_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"HAPPY_FEM", "HAPPY_FEM",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
SICK_FEM: [ SICK_FEM: [
"SICK_FEM", "SICK_FEM",
"SICK_MASC", "SICK_MASC",
"SAD_FEM", "SAD_FEM",
"SAD_MASC", "SAD_MASC",
"HAPPY_FEM", "HAPPY_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
UNCONVERTED: [ UNCONVERTED: [
"UNCONVERTED", "UNCONVERTED",
"HAPPY_FEM", "HAPPY_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"SAD_FEM", "SAD_FEM",
"SAD_MASC", "SAD_MASC",
"SICK_FEM", "SICK_FEM",
"SICK_MASC", "SICK_MASC",
"UNKNOWN", "UNKNOWN",
], ],
UNKNOWN: [ UNKNOWN: [
"HAPPY_FEM", "HAPPY_FEM",
"HAPPY_MASC", "HAPPY_MASC",
"SAD_FEM", "SAD_FEM",
"SAD_MASC", "SAD_MASC",
"SICK_FEM", "SICK_FEM",
"SICK_MASC", "SICK_MASC",
"UNCONVERTED", "UNCONVERTED",
"UNKNOWN", "UNKNOWN",
], ],
}; };
export default React.memo(SpeciesColorPicker); export default React.memo(SpeciesColorPicker);

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { import {
Box, Box,
IconButton, IconButton,
Skeleton, Skeleton,
useColorModeValue, useColorModeValue,
useTheme, useTheme,
useToken, useToken,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { ClassNames } from "@emotion/react"; import { ClassNames } from "@emotion/react";
@ -14,440 +14,440 @@ import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
import usePreferArchive from "./usePreferArchive"; import usePreferArchive from "./usePreferArchive";
function SquareItemCard({ function SquareItemCard({
item, item,
showRemoveButton = false, showRemoveButton = false,
onRemove = () => {}, onRemove = () => {},
tradeMatchingMode = null, tradeMatchingMode = null,
footer = null, footer = null,
...props ...props
}) { }) {
const outlineShadowValue = useToken("shadows", "outline"); const outlineShadowValue = useToken("shadows", "outline");
const mdRadiusValue = useToken("radii", "md"); const mdRadiusValue = useToken("radii", "md");
const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200"); const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200"); const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] = const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] =
useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]); useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
// When this is a trade match, give it an extra colorful shadow highlight so // 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 // 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 // make it easier to scan a user's lists page, and to learn how the sorting
// works!) // works!)
let tradeMatchShadow; let tradeMatchShadow;
if (tradeMatchingMode === "offering" && item.currentUserWantsThis) { if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`; tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
} else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) { } else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`; tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
} else { } else {
tradeMatchShadow = null; tradeMatchShadow = null;
} }
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
// SquareItemCard renders in large lists of 1k+ items, so we get a big // 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 // perf win by using Emotion directly instead of Chakra's styled-system
// Box. // Box.
<div <div
className={css` className={css`
position: relative; position: relative;
display: flex; display: flex;
`} `}
role="group" role="group"
> >
<Box <Box
as="a" as="a"
href={`/items/${item.id}`} href={`/items/${item.id}`}
className={css` className={css`
border-radius: ${mdRadiusValue}; border-radius: ${mdRadiusValue};
transition: all 0.2s; transition: all 0.2s;
&:hover, &:hover,
&:focus { &:focus {
transform: scale(1.05); transform: scale(1.05);
} }
&:focus { &:focus {
box-shadow: ${outlineShadowValue}; box-shadow: ${outlineShadowValue};
outline: none; outline: none;
} }
`} `}
{...props} {...props}
> >
<SquareItemCardLayout <SquareItemCardLayout
name={item.name} name={item.name}
thumbnailImage={ thumbnailImage={
<ItemThumbnail <ItemThumbnail
item={item} item={item}
tradeMatchingMode={tradeMatchingMode} tradeMatchingMode={tradeMatchingMode}
/> />
} }
removeButton={ removeButton={
showRemoveButton ? ( showRemoveButton ? (
<SquareItemCardRemoveButton onClick={onRemove} /> <SquareItemCardRemoveButton onClick={onRemove} />
) : null ) : null
} }
boxShadow={tradeMatchShadow} boxShadow={tradeMatchShadow}
footer={footer} footer={footer}
/> />
</Box> </Box>
{showRemoveButton && ( {showRemoveButton && (
<div <div
className={css` className={css`
position: absolute; position: absolute;
right: 0; right: 0;
top: 0; top: 0;
transform: translate(50%, -50%); transform: translate(50%, -50%);
z-index: 1; 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! */ * don't click the link instead, or vice-versa! */
padding: 0.75em; padding: 0.75em;
opacity: 0; opacity: 0;
[role="group"]:hover &, [role="group"]:hover &,
[role="group"]:focus-within &, [role="group"]:focus-within &,
&:hover, &:hover,
&:focus-within { &:focus-within {
opacity: 1; opacity: 1;
} }
`} `}
> >
<SquareItemCardRemoveButton onClick={onRemove} /> <SquareItemCardRemoveButton onClick={onRemove} />
</div> </div>
)} )}
</div> </div>
)} )}
</ClassNames> </ClassNames>
); );
} }
function SquareItemCardLayout({ function SquareItemCardLayout({
name, name,
thumbnailImage, thumbnailImage,
footer, footer,
minHeightNumLines = 2, minHeightNumLines = 2,
boxShadow = null, boxShadow = null,
}) { }) {
const { brightBackground } = useCommonStyles(); const { brightBackground } = useCommonStyles();
const brightBackgroundValue = useToken("colors", brightBackground); const brightBackgroundValue = useToken("colors", brightBackground);
const theme = useTheme(); const theme = useTheme();
return ( return (
// SquareItemCard renders in large lists of 1k+ items, so we get a big perf // 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. // win by using Emotion directly instead of Chakra's styled-system Box.
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<div <div
className={css` className={css`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
text-align: center; text-align: center;
box-shadow: ${boxShadow || theme.shadows.md}; box-shadow: ${boxShadow || theme.shadows.md};
border-radius: ${theme.radii.md}; border-radius: ${theme.radii.md};
padding: ${theme.space["3"]}; padding: ${theme.space["3"]};
width: calc(80px + 2em); width: calc(80px + 2em);
background: ${brightBackgroundValue}; background: ${brightBackgroundValue};
`} `}
> >
{thumbnailImage} {thumbnailImage}
<div <div
className={css` className={css`
margin-top: ${theme.space["1"]}; margin-top: ${theme.space["1"]};
font-size: ${theme.fontSizes.sm}; font-size: ${theme.fontSizes.sm};
/* Set min height to match a 2-line item name, so the cards /* Set min height to match a 2-line item name, so the cards
* in a row aren't toooo differently sized... */ * in a row aren't toooo differently sized... */
min-height: ${minHeightNumLines * 1.5 + "em"}; min-height: ${minHeightNumLines * 1.5 + "em"};
-webkit-line-clamp: 3; -webkit-line-clamp: 3;
-webkit-box-orient: vertical; -webkit-box-orient: vertical;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
width: 100%; width: 100%;
`} `}
// HACK: Emotion turns this into -webkit-display: -webkit-box? // HACK: Emotion turns this into -webkit-display: -webkit-box?
style={{ display: "-webkit-box" }} style={{ display: "-webkit-box" }}
> >
{name} {name}
</div> </div>
{footer && ( {footer && (
<Box marginTop="2" width="100%"> <Box marginTop="2" width="100%">
{footer} {footer}
</Box> </Box>
)} )}
</div> </div>
)} )}
</ClassNames> </ClassNames>
); );
} }
function ItemThumbnail({ item, tradeMatchingMode }) { function ItemThumbnail({ item, tradeMatchingMode }) {
const [preferArchive] = usePreferArchive(); const [preferArchive] = usePreferArchive();
const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray"; const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
const thumbnailShadowColor = useColorModeValue( const thumbnailShadowColor = useColorModeValue(
`${kindColorScheme}.200`, `${kindColorScheme}.200`,
`${kindColorScheme}.600`, `${kindColorScheme}.600`,
); );
const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor); const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
const mdRadiusValue = useToken("radii", "md"); const mdRadiusValue = useToken("radii", "md");
// Normally, we just show the owns/wants badges depending on whether the // 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 // 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 // mode instead: only show the badge if it represents a viable trade, and add
// some extra flair to it, too! // some extra flair to it, too!
let showOwnsBadge; let showOwnsBadge;
let showWantsBadge; let showWantsBadge;
let showTradeMatchFlair; let showTradeMatchFlair;
if (tradeMatchingMode == null) { if (tradeMatchingMode == null) {
showOwnsBadge = item.currentUserOwnsThis; showOwnsBadge = item.currentUserOwnsThis;
showWantsBadge = item.currentUserWantsThis; showWantsBadge = item.currentUserWantsThis;
showTradeMatchFlair = false; showTradeMatchFlair = false;
} else if (tradeMatchingMode === "offering") { } else if (tradeMatchingMode === "offering") {
showOwnsBadge = false; showOwnsBadge = false;
showWantsBadge = item.currentUserWantsThis; showWantsBadge = item.currentUserWantsThis;
showTradeMatchFlair = true; showTradeMatchFlair = true;
} else if (tradeMatchingMode === "seeking") { } else if (tradeMatchingMode === "seeking") {
showOwnsBadge = item.currentUserOwnsThis; showOwnsBadge = item.currentUserOwnsThis;
showWantsBadge = false; showWantsBadge = false;
showTradeMatchFlair = true; showTradeMatchFlair = true;
} else if (tradeMatchingMode === "hide-all") { } else if (tradeMatchingMode === "hide-all") {
showOwnsBadge = false; showOwnsBadge = false;
showWantsBadge = false; showWantsBadge = false;
showTradeMatchFlair = false; showTradeMatchFlair = false;
} else { } else {
throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`); throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
} }
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<div <div
className={css` className={css`
position: relative; position: relative;
`} `}
> >
<img <img
src={safeImageUrl(item.thumbnailUrl, { preferArchive })} src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
alt={`Thumbnail art for ${item.name}`} alt={`Thumbnail art for ${item.name}`}
width={80} width={80}
height={80} height={80}
className={css` className={css`
border-radius: ${mdRadiusValue}; border-radius: ${mdRadiusValue};
box-shadow: 0 0 4px ${thumbnailShadowColorValue}; box-shadow: 0 0 4px ${thumbnailShadowColorValue};
/* Don't let alt text flash in while loading */ /* Don't let alt text flash in while loading */
&:-moz-loading { &:-moz-loading {
visibility: hidden; visibility: hidden;
} }
`} `}
loading="lazy" loading="lazy"
/> />
<div <div
className={css` className={css`
position: absolute; position: absolute;
top: -6px; top: -6px;
left: -6px; left: -6px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
`} `}
> >
{showOwnsBadge && ( {showOwnsBadge && (
<ItemOwnsWantsBadge <ItemOwnsWantsBadge
colorScheme="green" colorScheme="green"
label={ label={
showTradeMatchFlair showTradeMatchFlair
? "You own this, and they want it!" ? "You own this, and they want it!"
: "You own this" : "You own this"
} }
> >
<CheckIcon /> <CheckIcon />
{showTradeMatchFlair && ( {showTradeMatchFlair && (
<div <div
className={css` className={css`
margin-left: 0.25em; margin-left: 0.25em;
margin-right: 0.125rem; margin-right: 0.125rem;
`} `}
> >
Match Match
</div> </div>
)} )}
</ItemOwnsWantsBadge> </ItemOwnsWantsBadge>
)} )}
{showWantsBadge && ( {showWantsBadge && (
<ItemOwnsWantsBadge <ItemOwnsWantsBadge
colorScheme="blue" colorScheme="blue"
label={ label={
showTradeMatchFlair showTradeMatchFlair
? "You want this, and they own it!" ? "You want this, and they own it!"
: "You want this" : "You want this"
} }
> >
<StarIcon /> <StarIcon />
{showTradeMatchFlair && ( {showTradeMatchFlair && (
<div <div
className={css` className={css`
margin-left: 0.25em; margin-left: 0.25em;
margin-right: 0.125rem; margin-right: 0.125rem;
`} `}
> >
Match Match
</div> </div>
)} )}
</ItemOwnsWantsBadge> </ItemOwnsWantsBadge>
)} )}
</div> </div>
{item.isNc != null && ( {item.isNc != null && (
<div <div
className={css` className={css`
position: absolute; position: absolute;
bottom: -6px; bottom: -6px;
right: -3px; right: -3px;
`} `}
> >
<ItemThumbnailKindBadge colorScheme={kindColorScheme}> <ItemThumbnailKindBadge colorScheme={kindColorScheme}>
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"} {item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
</ItemThumbnailKindBadge> </ItemThumbnailKindBadge>
</div> </div>
)} )}
</div> </div>
)} )}
</ClassNames> </ClassNames>
); );
} }
function ItemOwnsWantsBadge({ colorScheme, children, label }) { function ItemOwnsWantsBadge({ colorScheme, children, label }) {
const badgeBackground = useColorModeValue( const badgeBackground = useColorModeValue(
`${colorScheme}.100`, `${colorScheme}.100`,
`${colorScheme}.500`, `${colorScheme}.500`,
); );
const badgeColor = useColorModeValue( const badgeColor = useColorModeValue(
`${colorScheme}.500`, `${colorScheme}.500`,
`${colorScheme}.100`, `${colorScheme}.100`,
); );
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [ const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
badgeBackground, badgeBackground,
badgeColor, badgeColor,
]); ]);
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<div <div
aria-label={label} aria-label={label}
title={label} title={label}
className={css` className={css`
border-radius: 999px; border-radius: 999px;
height: 16px; height: 16px;
min-width: 16px; min-width: 16px;
font-size: 14px; font-size: 14px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 2px ${badgeBackgroundValue}; box-shadow: 0 0 2px ${badgeBackgroundValue};
/* Decrease the padding: I don't want to hit the edges, but I want /* Decrease the padding: I don't want to hit the edges, but I want
* to be a circle when possible! */ * to be a circle when possible! */
padding-left: 0.125rem; padding-left: 0.125rem;
padding-right: 0.125rem; padding-right: 0.125rem;
/* Copied from Chakra <Badge> */ /* Copied from Chakra <Badge> */
white-space: nowrap; white-space: nowrap;
vertical-align: middle; vertical-align: middle;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.65rem; font-size: 0.65rem;
font-weight: 700; font-weight: 700;
background: ${badgeBackgroundValue}; background: ${badgeBackgroundValue};
color: ${badgeColorValue}; color: ${badgeColorValue};
`} `}
> >
{children} {children}
</div> </div>
)} )}
</ClassNames> </ClassNames>
); );
} }
function ItemThumbnailKindBadge({ colorScheme, children }) { function ItemThumbnailKindBadge({ colorScheme, children }) {
const badgeBackground = useColorModeValue( const badgeBackground = useColorModeValue(
`${colorScheme}.100`, `${colorScheme}.100`,
`${colorScheme}.500`, `${colorScheme}.500`,
); );
const badgeColor = useColorModeValue( const badgeColor = useColorModeValue(
`${colorScheme}.500`, `${colorScheme}.500`,
`${colorScheme}.100`, `${colorScheme}.100`,
); );
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [ const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
badgeBackground, badgeBackground,
badgeColor, badgeColor,
]); ]);
return ( return (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<div <div
className={css` className={css`
/* Copied from Chakra <Badge> */ /* Copied from Chakra <Badge> */
white-space: nowrap; white-space: nowrap;
vertical-align: middle; vertical-align: middle;
padding-left: 0.25rem; padding-left: 0.25rem;
padding-right: 0.25rem; padding-right: 0.25rem;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.65rem; font-size: 0.65rem;
border-radius: 0.125rem; border-radius: 0.125rem;
font-weight: 700; font-weight: 700;
background: ${badgeBackgroundValue}; background: ${badgeBackgroundValue};
color: ${badgeColorValue}; color: ${badgeColorValue};
`} `}
> >
{children} {children}
</div> </div>
)} )}
</ClassNames> </ClassNames>
); );
} }
function SquareItemCardRemoveButton({ onClick }) { function SquareItemCardRemoveButton({ onClick }) {
const backgroundColor = useColorModeValue("gray.200", "gray.500"); const backgroundColor = useColorModeValue("gray.200", "gray.500");
return ( return (
<IconButton <IconButton
aria-label="Remove" aria-label="Remove"
title="Remove" title="Remove"
icon={<CloseIcon />} icon={<CloseIcon />}
size="xs" size="xs"
borderRadius="full" borderRadius="full"
boxShadow="lg" boxShadow="lg"
backgroundColor={backgroundColor} backgroundColor={backgroundColor}
onClick={onClick} onClick={onClick}
_hover={{ _hover={{
// Override night mode's fade-out on hover // Override night mode's fade-out on hover
opacity: 1, opacity: 1,
transform: "scale(1.15, 1.15)", transform: "scale(1.15, 1.15)",
}} }}
_focus={{ _focus={{
transform: "scale(1.15, 1.15)", transform: "scale(1.15, 1.15)",
boxShadow: "outline", boxShadow: "outline",
}} }}
/> />
); );
} }
export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) { export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) {
return ( return (
<SquareItemCardLayout <SquareItemCardLayout
name={ name={
<> <>
<Skeleton width="100%" height="1em" marginTop="2" /> <Skeleton width="100%" height="1em" marginTop="2" />
{minHeightNumLines >= 3 && ( {minHeightNumLines >= 3 && (
<Skeleton width="100%" height="1em" marginTop="2" /> <Skeleton width="100%" height="1em" marginTop="2" />
)} )}
</> </>
} }
thumbnailImage={<Skeleton width="80px" height="80px" />} thumbnailImage={<Skeleton width="80px" height="80px" />}
minHeightNumLines={minHeightNumLines} minHeightNumLines={minHeightNumLines}
footer={footer} footer={footer}
/> />
); );
} }
export default SquareItemCard; export default SquareItemCard;

View file

@ -1,131 +1,131 @@
import gql from "graphql-tag"; import gql from "graphql-tag";
function getVisibleLayers(petAppearance, itemAppearances) { function getVisibleLayers(petAppearance, itemAppearances) {
if (!petAppearance) { if (!petAppearance) {
return []; 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 const itemLayers = validItemAppearances
.map((a) => a.layers) .map((a) => a.layers)
.flat() .flat()
.map((l) => ({ ...l, source: "item" })); .map((l) => ({ ...l, source: "item" }));
let allLayers = [...petLayers, ...itemLayers]; let allLayers = [...petLayers, ...itemLayers];
const itemRestrictedZoneIds = new Set( const itemRestrictedZoneIds = new Set(
validItemAppearances validItemAppearances
.map((a) => a.restrictedZones) .map((a) => a.restrictedZones)
.flat() .flat()
.map((z) => z.id), .map((z) => z.id),
); );
const petRestrictedZoneIds = new Set( const petRestrictedZoneIds = new Set(
petAppearance.restrictedZones.map((z) => z.id), petAppearance.restrictedZones.map((z) => z.id),
); );
const visibleLayers = allLayers.filter((layer) => { const visibleLayers = allLayers.filter((layer) => {
// When an item restricts a zone, it hides pet layers of the same zone. // 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. // 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 // NOTE: Items' restricted layers also affect what items you can wear at
// the same time. We don't enforce anything about that here, and // the same time. We don't enforce anything about that here, and
// instead assume that the input by this point is valid! // instead assume that the input by this point is valid!
if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) { if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
return false; return false;
} }
// When a pet appearance restricts a zone, or when the pet is Unconverted, // 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 // it makes body-specific items incompatible. We use this to disallow UCs
// from wearing certain body-specific Biology Effects, Statics, etc, while // from wearing certain body-specific Biology Effects, Statics, etc, while
// still allowing non-body-specific items in those zones! (I think this // still allowing non-body-specific items in those zones! (I think this
// happens for some Invisible pet stuff, too?) // happens for some Invisible pet stuff, too?)
// //
// TODO: We shouldn't be *hiding* these zones, like we do with items; we // 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 // should be doing this way earlier, to prevent the item from even
// showing up even in search results! // showing up even in search results!
// //
// NOTE: This can result in both pet layers and items occupying the same // 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 // 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 // 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 // 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 // stability, and *then* rely on the UI to respect that ordering when
// rendering them by depth. Not great! 😅) // rendering them by depth. Not great! 😅)
// //
// NOTE: We used to also include the pet appearance's *occupied* zones in // NOTE: We used to also include the pet appearance's *occupied* zones in
// this condition, not just the restricted zones, as a sensible // this condition, not just the restricted zones, as a sensible
// defensive default, even though we weren't aware of any relevant // defensive default, even though we weren't aware of any relevant
// items. But now we know that actually the "Bruce Brucey B Mouth" // items. But now we know that actually the "Bruce Brucey B Mouth"
// occupies the real Mouth zone, and still should be visible and // occupies the real Mouth zone, and still should be visible and
// above pet layers! So, we now only check *restricted* zones. // above pet layers! So, we now only check *restricted* zones.
// //
// NOTE: UCs used to implement their restrictions by listing specific // NOTE: UCs used to implement their restrictions by listing specific
// zones, but it seems that the logic has changed to just be about // zones, but it seems that the logic has changed to just be about
// UC-ness and body-specific-ness, and not necessarily involve the // UC-ness and body-specific-ness, and not necessarily involve the
// set of restricted zones at all. (This matters because e.g. UCs // 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 // 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 // don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
// zone restriction case running too, because I don't think it // zone restriction case running too, because I don't think it
// _hurts_ anything, and I'm not confident enough in this conclusion. // _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 // TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
// use zone restrictions? // use zone restrictions?
if ( if (
layer.source === "item" && layer.source === "item" &&
layer.bodyId !== "0" && layer.bodyId !== "0" &&
(petAppearance.pose === "UNCONVERTED" || (petAppearance.pose === "UNCONVERTED" ||
petRestrictedZoneIds.has(layer.zone.id)) petRestrictedZoneIds.has(layer.zone.id))
) { ) {
return false; return false;
} }
// A pet appearance can also restrict its own zones. The Wraith Uni is an // 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! // interesting example: it has a horn, but its zone restrictions hide it!
if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) { if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
return false; return false;
} }
return true; return true;
}); });
visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth); visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
return visibleLayers; return visibleLayers;
} }
export const itemAppearanceFragmentForGetVisibleLayers = gql` export const itemAppearanceFragmentForGetVisibleLayers = gql`
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance { fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
id id
layers { layers {
id id
bodyId bodyId
zone { zone {
id id
depth depth
} }
} }
restrictedZones { restrictedZones {
id id
} }
} }
`; `;
export const petAppearanceFragmentForGetVisibleLayers = gql` export const petAppearanceFragmentForGetVisibleLayers = gql`
fragment PetAppearanceForGetVisibleLayers on PetAppearance { fragment PetAppearanceForGetVisibleLayers on PetAppearance {
id id
pose pose
layers { layers {
id id
zone { zone {
id id
depth depth
} }
} }
restrictedZones { restrictedZones {
id id
} }
} }
`; `;
export default getVisibleLayers; export default getVisibleLayers;

View file

@ -2,34 +2,34 @@
const currentUserId = readCurrentUserId(); const currentUserId = readCurrentUserId();
function useCurrentUser() { function useCurrentUser() {
if (currentUserId == null) { if (currentUserId == null) {
return { return {
isLoggedIn: false, isLoggedIn: false,
id: null, id: null,
}; };
} }
return { return {
isLoggedIn: true, isLoggedIn: true,
id: currentUserId, id: currentUserId,
}; };
} }
function readCurrentUserId() { function readCurrentUserId() {
try { try {
const element = document.querySelector("meta[name=dti-current-user-id]"); const element = document.querySelector("meta[name=dti-current-user-id]");
const value = element.getAttribute("content"); const value = element.getAttribute("content");
if (value === "null") { if (value === "null") {
return null; return null;
} }
return value; return value;
} catch (error) { } catch (error) {
console.error( console.error(
`[readCurrentUserId] Couldn't read user ID, using null instead`, `[readCurrentUserId] Couldn't read user ID, using null instead`,
error, error,
); );
return null; return null;
} }
} }
export default useCurrentUser; export default useCurrentUser;

View file

@ -3,8 +3,8 @@ import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import getVisibleLayers, { import getVisibleLayers, {
itemAppearanceFragmentForGetVisibleLayers, itemAppearanceFragmentForGetVisibleLayers,
petAppearanceFragmentForGetVisibleLayers, petAppearanceFragmentForGetVisibleLayers,
} from "./getVisibleLayers"; } from "./getVisibleLayers";
import { useAltStyle } from "../loaders/alt-styles"; import { useAltStyle } from "../loaders/alt-styles";
@ -13,198 +13,198 @@ import { useAltStyle } from "../loaders/alt-styles";
* visibleLayers for rendering. * visibleLayers for rendering.
*/ */
export default function useOutfitAppearance(outfitState) { export default function useOutfitAppearance(outfitState) {
const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } = const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
outfitState; outfitState;
// We split this query out from the other one, so that we can HTTP cache it. // 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 // While Apollo gives us fine-grained caching during the page session, we can
// only HTTP a full query at a time. // only HTTP a full query at a time.
// //
// This is a minor optimization with respect to keeping the user's cache // This is a minor optimization with respect to keeping the user's cache
// populated with their favorite species/color combinations. Once we start // populated with their favorite species/color combinations. Once we start
// caching the items by body instead of species/color, this could make color // caching the items by body instead of species/color, this could make color
// changes really snappy! // changes really snappy!
// //
// The larger optimization is that this enables the CDN to edge-cache the // The larger optimization is that this enables the CDN to edge-cache the
// most popular species/color combinations, for very fast previews on 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 // 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! // assume because our traffic isn't enough - so let's keep an eye on this!
const { const {
loading: loading1, loading: loading1,
error: error1, error: error1,
data: data1, data: data1,
} = useQuery( } = useQuery(
appearanceId == null appearanceId == null
? gql` ? gql`
query OutfitPetAppearance( query OutfitPetAppearance(
$speciesId: ID! $speciesId: ID!
$colorId: ID! $colorId: ID!
$pose: Pose! $pose: Pose!
) { ) {
petAppearance( petAppearance(
speciesId: $speciesId speciesId: $speciesId
colorId: $colorId colorId: $colorId
pose: $pose pose: $pose
) { ) {
...PetAppearanceForOutfitPreview ...PetAppearanceForOutfitPreview
} }
} }
${petAppearanceFragment} ${petAppearanceFragment}
` `
: gql` : gql`
query OutfitPetAppearanceById($appearanceId: ID!) { query OutfitPetAppearanceById($appearanceId: ID!) {
petAppearance: petAppearanceById(id: $appearanceId) { petAppearance: petAppearanceById(id: $appearanceId) {
...PetAppearanceForOutfitPreview ...PetAppearanceForOutfitPreview
} }
} }
${petAppearanceFragment} ${petAppearanceFragment}
`, `,
{ {
variables: { variables: {
speciesId, speciesId,
colorId, colorId,
pose, pose,
appearanceId, appearanceId,
}, },
skip: skip:
speciesId == null || speciesId == null ||
colorId == null || colorId == null ||
(pose == null && appearanceId == null), (pose == null && appearanceId == null),
}, },
); );
const { const {
loading: loading2, loading: loading2,
error: error2, error: error2,
data: data2, data: data2,
} = useQuery( } = useQuery(
gql` gql`
query OutfitItemsAppearance( query OutfitItemsAppearance(
$speciesId: ID! $speciesId: ID!
$colorId: ID! $colorId: ID!
$altStyleId: ID $altStyleId: ID
$wornItemIds: [ID!]! $wornItemIds: [ID!]!
) { ) {
items(ids: $wornItemIds) { items(ids: $wornItemIds) {
id id
name # HACK: This is for HTML5 detection UI in OutfitControls! name # HACK: This is for HTML5 detection UI in OutfitControls!
appearance: appearanceOn( appearance: appearanceOn(
speciesId: $speciesId speciesId: $speciesId
colorId: $colorId colorId: $colorId
altStyleId: $altStyleId altStyleId: $altStyleId
) { ) {
...ItemAppearanceForOutfitPreview ...ItemAppearanceForOutfitPreview
} }
} }
} }
${itemAppearanceFragment} ${itemAppearanceFragment}
`, `,
{ {
variables: { variables: {
speciesId, speciesId,
colorId, colorId,
altStyleId, altStyleId,
wornItemIds, wornItemIds,
}, },
skip: speciesId == null || colorId == null || wornItemIds.length === 0, skip: speciesId == null || colorId == null || wornItemIds.length === 0,
}, },
); );
const { const {
isLoading: loading3, isLoading: loading3,
error: error3, error: error3,
data: altStyle, data: altStyle,
} = useAltStyle(altStyleId, speciesId); } = useAltStyle(altStyleId, speciesId);
const petAppearance = altStyle?.appearance ?? data1?.petAppearance; const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
const items = data2?.items; const items = data2?.items;
const itemAppearances = React.useMemo( const itemAppearances = React.useMemo(
() => (items || []).map((i) => i.appearance), () => (items || []).map((i) => i.appearance),
[items], [items],
); );
const visibleLayers = React.useMemo( const visibleLayers = React.useMemo(
() => getVisibleLayers(petAppearance, itemAppearances), () => getVisibleLayers(petAppearance, itemAppearances),
[petAppearance, itemAppearances], [petAppearance, itemAppearances],
); );
const bodyId = petAppearance?.bodyId; const bodyId = petAppearance?.bodyId;
return { return {
loading: loading1 || loading2 || loading3, loading: loading1 || loading2 || loading3,
error: error1 || error2 || error3, error: error1 || error2 || error3,
petAppearance, petAppearance,
items: items || [], items: items || [],
itemAppearances, itemAppearances,
visibleLayers, visibleLayers,
bodyId, bodyId,
}; };
} }
export const appearanceLayerFragment = gql` export const appearanceLayerFragment = gql`
fragment AppearanceLayerForOutfitPreview on AppearanceLayer { fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
id id
svgUrl svgUrl
canvasMovieLibraryUrl canvasMovieLibraryUrl
imageUrl: imageUrlV2(idealSize: SIZE_600) imageUrl: imageUrlV2(idealSize: SIZE_600)
bodyId bodyId
knownGlitches # For HTML5 & Known Glitches UI knownGlitches # For HTML5 & Known Glitches UI
zone { zone {
id id
depth depth
label label
} }
swfUrl # For the layer info modal swfUrl # For the layer info modal
} }
`; `;
export const appearanceLayerFragmentForSupport = gql` export const appearanceLayerFragmentForSupport = gql`
fragment AppearanceLayerForSupport on AppearanceLayer { fragment AppearanceLayerForSupport on AppearanceLayer {
id id
remoteId # HACK: This is for Support tools, but other views don't need it 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 swfUrl # HACK: This is for Support tools, but other views don't need it
zone { zone {
id id
label # HACK: This is for Support tools, but other views don't need it label # HACK: This is for Support tools, but other views don't need it
} }
} }
`; `;
export const itemAppearanceFragment = gql` export const itemAppearanceFragment = gql`
fragment ItemAppearanceForOutfitPreview on ItemAppearance { fragment ItemAppearanceForOutfitPreview on ItemAppearance {
id id
layers { layers {
id id
...AppearanceLayerForOutfitPreview ...AppearanceLayerForOutfitPreview
...AppearanceLayerForSupport # HACK: Most users don't need this! ...AppearanceLayerForSupport # HACK: Most users don't need this!
} }
...ItemAppearanceForGetVisibleLayers ...ItemAppearanceForGetVisibleLayers
} }
${appearanceLayerFragment} ${appearanceLayerFragment}
${appearanceLayerFragmentForSupport} ${appearanceLayerFragmentForSupport}
${itemAppearanceFragmentForGetVisibleLayers} ${itemAppearanceFragmentForGetVisibleLayers}
`; `;
export const petAppearanceFragment = gql` export const petAppearanceFragment = gql`
fragment PetAppearanceForOutfitPreview on PetAppearance { fragment PetAppearanceForOutfitPreview on PetAppearance {
id id
bodyId bodyId
pose # For Known Glitches UI pose # For Known Glitches UI
isGlitched # For Known Glitches UI isGlitched # For Known Glitches UI
species { species {
id # For Known Glitches UI id # For Known Glitches UI
} }
color { color {
id # For Known Glitches UI id # For Known Glitches UI
} }
layers { layers {
id id
...AppearanceLayerForOutfitPreview ...AppearanceLayerForOutfitPreview
} }
...PetAppearanceForGetVisibleLayers ...PetAppearanceForGetVisibleLayers
} }
${appearanceLayerFragment} ${appearanceLayerFragment}
${petAppearanceFragmentForGetVisibleLayers} ${petAppearanceFragmentForGetVisibleLayers}
`; `;

View file

@ -5,18 +5,18 @@ import { useLocalStorage } from "../util";
* using images.neopets.com, when images.neopets.com is being slow and bleh! * using images.neopets.com, when images.neopets.com is being slow and bleh!
*/ */
function usePreferArchive() { function usePreferArchive() {
const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage( const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
"DTIPreferArchive", "DTIPreferArchive",
null, null,
); );
// Oct 13 2022: I might default this back to on again if the lag gets // 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 // 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 // offer this option, but decent enough that I don't want to turn it on by
// default and break new items yet! // default and break new items yet!
const preferArchive = preferArchiveSavedValue ?? false; const preferArchive = preferArchiveSavedValue ?? false;
return [preferArchive, setPreferArchive]; return [preferArchive, setPreferArchive];
} }
export default usePreferArchive; export default usePreferArchive;

View file

@ -11,7 +11,7 @@ export function getSupportSecret() {
function readOrigin() { function readOrigin() {
const node = document.querySelector("meta[name=impress-2020-origin]"); 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() { function readSupportSecret() {

View file

@ -13,9 +13,7 @@ export function useItemAppearances(id, options = {}) {
} }
async function loadItemAppearancesData(id) { async function loadItemAppearancesData(id) {
const res = await fetch( const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`);
`/items/${encodeURIComponent(id)}/appearances.json`,
);
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(

View file

@ -44,9 +44,7 @@ async function loadSavedOutfit(id) {
const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`); const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
`loading outfit failed: ${res.status} ${res.statusText}`,
);
} }
return res.json().then(normalizeOutfit); return res.json().then(normalizeOutfit);
@ -99,9 +97,7 @@ async function saveOutfit({
} }
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
`saving outfit failed: ${res.status} ${res.statusText}`,
);
} }
return res.json().then(normalizeOutfit); return res.json().then(normalizeOutfit);
@ -116,9 +112,7 @@ async function deleteOutfit(id) {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(`deleting outfit failed: ${res.status} ${res.statusText}`);
`deleting outfit failed: ${res.status} ${res.statusText}`,
);
} }
} }
@ -132,9 +126,7 @@ function normalizeOutfit(outfit) {
appearanceId: String(outfit.pet_state_id), appearanceId: String(outfit.pet_state_id),
altStyleId: outfit.alt_style_id ? String(outfit.alt_style_id) : null, altStyleId: outfit.alt_style_id ? String(outfit.alt_style_id) : null,
wornItemIds: (outfit.item_ids?.worn || []).map((id) => String(id)), wornItemIds: (outfit.item_ids?.worn || []).map((id) => String(id)),
closetedItemIds: (outfit.item_ids?.closeted || []).map((id) => closetedItemIds: (outfit.item_ids?.closeted || []).map((id) => String(id)),
String(id),
),
creator: outfit.user ? { id: String(outfit.user.id) } : null, creator: outfit.user ? { id: String(outfit.user.id) } : null,
createdAt: outfit.created_at, createdAt: outfit.created_at,
updatedAt: outfit.updated_at, updatedAt: outfit.updated_at,

View file

@ -1,11 +1,11 @@
import React from "react"; import React from "react";
import { import {
Box, Box,
Flex, Flex,
Grid, Grid,
Heading, Heading,
Link, Link,
useColorModeValue, useColorModeValue,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import loadableLibrary from "@loadable/component"; import loadableLibrary from "@loadable/component";
import * as Sentry from "@sentry/react"; 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 * https://developers.google.com/web/fundamentals/performance/rail
*/ */
export function Delay({ children, ms = 300 }) { export function Delay({ children, ms = 300 }) {
const [isVisible, setIsVisible] = React.useState(false); const [isVisible, setIsVisible] = React.useState(false);
React.useEffect(() => { React.useEffect(() => {
const id = setTimeout(() => setIsVisible(true), ms); const id = setTimeout(() => setIsVisible(true), ms);
return () => clearTimeout(id); return () => clearTimeout(id);
}, [ms, setIsVisible]); }, [ms, setIsVisible]);
return ( return (
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s"> <Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
{children} {children}
</Box> </Box>
); );
} }
/** /**
@ -47,17 +47,17 @@ export function Delay({ children, ms = 300 }) {
* font and some special typographical styles! * font and some special typographical styles!
*/ */
export function Heading1({ children, ...props }) { export function Heading1({ children, ...props }) {
return ( return (
<Heading <Heading
as="h1" as="h1"
size="2xl" size="2xl"
fontFamily="Delicious, sans-serif" fontFamily="Delicious, sans-serif"
fontWeight="800" fontWeight="800"
{...props} {...props}
> >
{children} {children}
</Heading> </Heading>
); );
} }
/** /**
@ -65,17 +65,17 @@ export function Heading1({ children, ...props }) {
* special typographical styles!! * special typographical styles!!
*/ */
export function Heading2({ children, ...props }) { export function Heading2({ children, ...props }) {
return ( return (
<Heading <Heading
as="h2" as="h2"
size="xl" size="xl"
fontFamily="Delicious, sans-serif" fontFamily="Delicious, sans-serif"
fontWeight="700" fontWeight="700"
{...props} {...props}
> >
{children} {children}
</Heading> </Heading>
); );
} }
/** /**
@ -83,111 +83,111 @@ export function Heading2({ children, ...props }) {
* special typographical styles!! * special typographical styles!!
*/ */
export function Heading3({ children, ...props }) { export function Heading3({ children, ...props }) {
return ( return (
<Heading <Heading
as="h3" as="h3"
size="lg" size="lg"
fontFamily="Delicious, sans-serif" fontFamily="Delicious, sans-serif"
fontWeight="700" fontWeight="700"
{...props} {...props}
> >
{children} {children}
</Heading> </Heading>
); );
} }
/** /**
* ErrorMessage is a simple error message for simple errors! * ErrorMessage is a simple error message for simple errors!
*/ */
export function ErrorMessage({ children, ...props }) { export function ErrorMessage({ children, ...props }) {
return ( return (
<Box color="red.400" {...props}> <Box color="red.400" {...props}>
{children} {children}
</Box> </Box>
); );
} }
export function useCommonStyles() { export function useCommonStyles() {
return { return {
brightBackground: useColorModeValue("white", "gray.700"), brightBackground: useColorModeValue("white", "gray.700"),
bodyBackground: useColorModeValue("gray.50", "gray.800"), bodyBackground: useColorModeValue("gray.50", "gray.800"),
}; };
} }
/** /**
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets! * safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
*/ */
export function safeImageUrl( export function safeImageUrl(
urlString, urlString,
{ crossOrigin = null, preferArchive = false } = {}, { crossOrigin = null, preferArchive = false } = {},
) { ) {
if (urlString == null) { if (urlString == null) {
return urlString; return urlString;
} }
let url; let url;
try { try {
url = new URL( url = new URL(
urlString, urlString,
// A few item thumbnail images incorrectly start with "/". When that // A few item thumbnail images incorrectly start with "/". When that
// happens, the correct URL is at images.neopets.com. // happens, the correct URL is at images.neopets.com.
// //
// So, we provide "http://images.neopets.com" as the base URL when // So, we provide "http://images.neopets.com" as the base URL when
// parsing. Most URLs are absolute and will ignore it, but relative URLs // parsing. Most URLs are absolute and will ignore it, but relative URLs
// will resolve relative to that base. // will resolve relative to that base.
"http://images.neopets.com", "http://images.neopets.com",
); );
} catch (e) { } catch (e) {
logAndCapture( logAndCapture(
new Error( new Error(
`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`, `safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
), ),
); );
return buildImpress2020Url("/__error__URL-was-not-parseable__"); return buildImpress2020Url("/__error__URL-was-not-parseable__");
} }
// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our // Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
// proxy if we need CORS headers. // proxy if we need CORS headers.
if ( if (
url.origin === "http://images.neopets.com" || url.origin === "http://images.neopets.com" ||
url.origin === "https://images.neopets.com" url.origin === "https://images.neopets.com"
) { ) {
url.protocol = "https:"; url.protocol = "https:";
if (preferArchive) { if (preferArchive) {
const archiveUrl = new URL( const archiveUrl = new URL(
`/api/readFromArchive`, `/api/readFromArchive`,
window.location.origin, window.location.origin,
); );
archiveUrl.search = new URLSearchParams({ url: url.toString() }); archiveUrl.search = new URLSearchParams({ url: url.toString() });
url = archiveUrl; url = archiveUrl;
} else if (crossOrigin) { } else if (crossOrigin) {
// NOTE: Previously we would rewrite this to our proxy that adds an // NOTE: Previously we would rewrite this to our proxy that adds an
// `Access-Control-Allow-Origin` header (images.neopets-asset-proxy. // `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
// openneo.net), but images.neopets.com now includes this header for us! // openneo.net), but images.neopets.com now includes this header for us!
// //
// So, do nothing! // So, do nothing!
} }
} else if ( } else if (
url.origin === "http://pets.neopets.com" || url.origin === "http://pets.neopets.com" ||
url.origin === "https://pets.neopets.com" url.origin === "https://pets.neopets.com"
) { ) {
url.protocol = "https:"; url.protocol = "https:";
if (crossOrigin) { if (crossOrigin) {
url.host = "pets.neopets-asset-proxy.openneo.net"; url.host = "pets.neopets-asset-proxy.openneo.net";
} }
} }
if (url.protocol !== "https:" && url.hostname !== "localhost") { if (url.protocol !== "https:" && url.hostname !== "localhost") {
logAndCapture( logAndCapture(
new Error( new Error(
`safeImageUrl was provided an unsafe URL, but we don't know how to ` + `safeImageUrl was provided an unsafe URL, but we don't know how to ` +
`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`, `upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
), ),
); );
return buildImpress2020Url("/__error__URL-was-not-HTTPS__"); 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/ * Adapted from https://usehooks.com/useDebounce/
*/ */
export function useDebounce( export function useDebounce(
value, value,
delay, delay,
{ waitForFirstPause = false, initialValue = null, forceReset = null } = {}, { waitForFirstPause = false, initialValue = null, forceReset = null } = {},
) { ) {
// State and setters for debounced value // State and setters for debounced value
const [debouncedValue, setDebouncedValue] = React.useState( const [debouncedValue, setDebouncedValue] = React.useState(
waitForFirstPause ? initialValue : value, waitForFirstPause ? initialValue : value,
); );
React.useEffect( React.useEffect(
() => { () => {
// Update debounced value after delay // Update debounced value after delay
const handler = setTimeout(() => { const handler = setTimeout(() => {
setDebouncedValue(value); setDebouncedValue(value);
}, delay); }, delay);
// Cancel the timeout if value changes (also on delay change or unmount) // 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 ... // This is how we prevent debounced value from updating if value is changed ...
// .. within the delay period. Timeout gets cleared and restarted. // .. within the delay period. Timeout gets cleared and restarted.
return () => { return () => {
clearTimeout(handler); clearTimeout(handler);
}; };
}, },
[value, delay], // Only re-call effect if value or delay changes [value, delay], // Only re-call effect if value or delay changes
); );
// The `forceReset` option helps us decide whether to set the 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 // immediately! We'll update it in an effect for consistency and clarity, but
// also return it immediately rather than wait a tick. // also return it immediately rather than wait a tick.
const shouldForceReset = forceReset && forceReset(debouncedValue, value); const shouldForceReset = forceReset && forceReset(debouncedValue, value);
React.useEffect(() => { React.useEffect(() => {
if (shouldForceReset) { if (shouldForceReset) {
setDebouncedValue(value); setDebouncedValue(value);
} }
}, [shouldForceReset, 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! * Our limited API is designed to match the `use-http` library!
*/ */
export function useFetch(url, { responseType, skip, ...fetchOptions }) { export function useFetch(url, { responseType, skip, ...fetchOptions }) {
// Just trying to be clear about what you'll get back ^_^` If we want to // 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! // fetch non-binary data later, extend this and get something else from res!
if (responseType !== "arrayBuffer") { if (responseType !== "arrayBuffer") {
throw new Error(`unsupported responseType ${responseType}`); throw new Error(`unsupported responseType ${responseType}`);
} }
const [response, setResponse] = React.useState({ const [response, setResponse] = React.useState({
loading: skip ? false : true, loading: skip ? false : true,
error: null, error: null,
data: null, data: null,
}); });
// We expect this to be a simple object, so this helps us only re-send the // 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 // fetch when the options have actually changed, rather than e.g. a new copy
// of an identical object! // of an identical object!
const fetchOptionsAsJson = JSON.stringify(fetchOptions); const fetchOptionsAsJson = JSON.stringify(fetchOptions);
React.useEffect(() => { React.useEffect(() => {
if (skip) { if (skip) {
return; return;
} }
let canceled = false; let canceled = false;
fetch(url, JSON.parse(fetchOptionsAsJson)) fetch(url, JSON.parse(fetchOptionsAsJson))
.then(async (res) => { .then(async (res) => {
if (canceled) { if (canceled) {
return; return;
} }
const arrayBuffer = await res.arrayBuffer(); const arrayBuffer = await res.arrayBuffer();
setResponse({ loading: false, error: null, data: arrayBuffer }); setResponse({ loading: false, error: null, data: arrayBuffer });
}) })
.catch((error) => { .catch((error) => {
if (canceled) { if (canceled) {
return; return;
} }
setResponse({ loading: false, error, data: null }); setResponse({ loading: false, error, data: null });
}); });
return () => { return () => {
canceled = true; canceled = true;
}; };
}, [skip, url, fetchOptionsAsJson]); }, [skip, url, fetchOptionsAsJson]);
return response; return response;
} }
/** /**
@ -303,96 +303,96 @@ export function useFetch(url, { responseType, skip, ...fetchOptions }) {
*/ */
let storageListeners = []; let storageListeners = [];
export function useLocalStorage(key, initialValue) { export function useLocalStorage(key, initialValue) {
const loadValue = React.useCallback(() => { const loadValue = React.useCallback(() => {
if (typeof localStorage === "undefined") { if (typeof localStorage === "undefined") {
return initialValue; return initialValue;
} }
try { try {
const item = localStorage.getItem(key); const item = localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue; return item ? JSON.parse(item) : initialValue;
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return initialValue; return initialValue;
} }
}, [key, initialValue]); }, [key, initialValue]);
const [storedValue, setStoredValue] = React.useState(loadValue); const [storedValue, setStoredValue] = React.useState(loadValue);
const setValue = React.useCallback( const setValue = React.useCallback(
(value) => { (value) => {
try { try {
setStoredValue(value); setStoredValue(value);
window.localStorage.setItem(key, JSON.stringify(value)); window.localStorage.setItem(key, JSON.stringify(value));
storageListeners.forEach((l) => l()); storageListeners.forEach((l) => l());
} catch (error) { } catch (error) {
console.error(error); console.error(error);
} }
}, },
[key], [key],
); );
const reloadValue = React.useCallback(() => { const reloadValue = React.useCallback(() => {
setStoredValue(loadValue()); setStoredValue(loadValue());
}, [loadValue, setStoredValue]); }, [loadValue, setStoredValue]);
// Listen for changes elsewhere on the page, and update here too! // Listen for changes elsewhere on the page, and update here too!
React.useEffect(() => { React.useEffect(() => {
storageListeners.push(reloadValue); storageListeners.push(reloadValue);
return () => { return () => {
storageListeners = storageListeners.filter((l) => l !== reloadValue); storageListeners = storageListeners.filter((l) => l !== reloadValue);
}; };
}, [reloadValue]); }, [reloadValue]);
// Listen for changes in other tabs, and update here too! (This does not // Listen for changes in other tabs, and update here too! (This does not
// catch same-page updates!) // catch same-page updates!)
React.useEffect(() => { React.useEffect(() => {
window.addEventListener("storage", reloadValue); window.addEventListener("storage", reloadValue);
return () => window.removeEventListener("storage", reloadValue); return () => window.removeEventListener("storage", reloadValue);
}, [reloadValue]); }, [reloadValue]);
return [storedValue, setValue]; return [storedValue, setValue];
} }
export function loadImage( export function loadImage(
rawSrc, rawSrc,
{ crossOrigin = null, preferArchive = false } = {}, { crossOrigin = null, preferArchive = false } = {},
) { ) {
const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive }); const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
const image = new Image(); const image = new Image();
let canceled = false; let canceled = false;
let resolved = false; let resolved = false;
const promise = new Promise((resolve, reject) => { const promise = new Promise((resolve, reject) => {
image.onload = () => { image.onload = () => {
if (canceled) return; if (canceled) return;
resolved = true; resolved = true;
resolve(image); resolve(image);
}; };
image.onerror = () => { image.onerror = () => {
if (canceled) return; if (canceled) return;
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`)); reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
}; };
if (crossOrigin) { if (crossOrigin) {
image.crossOrigin = crossOrigin; image.crossOrigin = crossOrigin;
} }
image.src = src; image.src = src;
}); });
promise.cancel = () => { promise.cancel = () => {
// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel // NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
// resolved images. That's because our approach to cancelation // resolved images. That's because our approach to cancelation
// mutates the Image object we already returned, which could be // mutates the Image object we already returned, which could be
// surprising if the caller is using the Image and expected the // surprising if the caller is using the Image and expected the
// `cancel` call to only cancel any in-flight network requests. // `cancel` call to only cancel any in-flight network requests.
// (e.g. we cancel a DTI movie when it unloads from the page, but // (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 // it might stick around in the movie cache, and we want those images
// to still work!) // to still work!)
if (resolved) return; if (resolved) return;
image.src = ""; image.src = "";
canceled = true; 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! * because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
*/ */
export function loadable(load, options) { export function loadable(load, options) {
return loadableLibrary( return loadableLibrary(
() => () =>
load().catch((e) => { load().catch((e) => {
console.error("Error loading page, reloading:", e); console.error("Error loading page, reloading:", e);
window.location.reload(); window.location.reload();
// Return a component that renders nothing, while we reload! // Return a component that renders nothing, while we reload!
return () => null; return () => null;
}), }),
options, options,
); );
} }
/** /**
@ -420,113 +420,113 @@ export function loadable(load, options) {
* genuinely unexpected error worth logging. * genuinely unexpected error worth logging.
*/ */
export function logAndCapture(e) { export function logAndCapture(e) {
console.error(e); console.error(e);
Sentry.captureException(e); Sentry.captureException(e);
} }
export function getGraphQLErrorMessage(error) { export function getGraphQLErrorMessage(error) {
// If this is a GraphQL Bad Request error, show the message of the first // 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! // error the server returned. Otherwise, just use the normal error message!
return ( return (
error?.networkError?.result?.errors?.[0]?.message || error?.message || null error?.networkError?.result?.errors?.[0]?.message || error?.message || null
); );
} }
export function MajorErrorMessage({ error = null, variant = "unexpected" }) { export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
// Log the detailed error to the console, so we can have a good debug // Log the detailed error to the console, so we can have a good debug
// experience without the parent worrying about it! // experience without the parent worrying about it!
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
console.error(error); console.error(error);
} }
}, [error]); }, [error]);
return ( return (
<Flex justify="center" marginTop="8"> <Flex justify="center" marginTop="8">
<Grid <Grid
templateAreas='"icon title" "icon description" "icon details"' templateAreas='"icon title" "icon description" "icon details"'
templateColumns="auto minmax(0, 1fr)" templateColumns="auto minmax(0, 1fr)"
maxWidth="500px" maxWidth="500px"
marginX="8" marginX="8"
columnGap="4" columnGap="4"
> >
<Box gridArea="icon" marginTop="2"> <Box gridArea="icon" marginTop="2">
<Box <Box
borderRadius="full" borderRadius="full"
boxShadow="md" boxShadow="md"
overflow="hidden" overflow="hidden"
width="100px" width="100px"
height="100px" height="100px"
> >
<img <img
src={ErrorGrundoImg} src={ErrorGrundoImg}
srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`} srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
alt="Distressed Grundo programmer" alt="Distressed Grundo programmer"
width={100} width={100}
height={100} height={100}
/> />
</Box> </Box>
</Box> </Box>
<Box gridArea="title" fontSize="lg" marginBottom="1"> <Box gridArea="title" fontSize="lg" marginBottom="1">
{variant === "unexpected" && <>Ah dang, I broke it 😖</>} {variant === "unexpected" && <>Ah dang, I broke it 😖</>}
{variant === "network" && <>Oops, it didn't work, sorry 😖</>} {variant === "network" && <>Oops, it didn't work, sorry 😖</>}
{variant === "not-found" && <>Oops, page not found 😖</>} {variant === "not-found" && <>Oops, page not found 😖</>}
</Box> </Box>
<Box gridArea="description" marginBottom="2"> <Box gridArea="description" marginBottom="2">
{variant === "unexpected" && ( {variant === "unexpected" && (
<> <>
There was an error displaying this page. I'll get info about it There was an error displaying this page. I'll get info about it
automatically, but you can tell me more at{" "} automatically, but you can tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400"> <Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net matchu@openneo.net
</Link> </Link>
! !
</> </>
)} )}
{variant === "network" && ( {variant === "network" && (
<> <>
There was an error displaying this page. Check your internet There was an error displaying this page. Check your internet
connection and try againand if you keep having trouble, please connection and try againand if you keep having trouble, please
tell me more at{" "} tell me more at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400"> <Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net matchu@openneo.net
</Link> </Link>
! !
</> </>
)} )}
{variant === "not-found" && ( {variant === "not-found" && (
<> <>
We couldn't find this page. Maybe it's been deleted? Check the URL 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 and try againand if you keep having trouble, please tell me more
at{" "} at{" "}
<Link href="mailto:matchu@openneo.net" color="green.400"> <Link href="mailto:matchu@openneo.net" color="green.400">
matchu@openneo.net matchu@openneo.net
</Link> </Link>
! !
</> </>
)} )}
</Box> </Box>
{error && ( {error && (
<Box gridArea="details" fontSize="xs" opacity="0.8"> <Box gridArea="details" fontSize="xs" opacity="0.8">
<WarningIcon <WarningIcon
marginRight="1.5" marginRight="1.5"
marginTop="-2px" marginTop="-2px"
aria-label="Error message" aria-label="Error message"
/> />
"{getGraphQLErrorMessage(error)}" "{getGraphQLErrorMessage(error)}"
</Box> </Box>
)} )}
</Grid> </Grid>
</Flex> </Flex>
); );
} }
export function TestErrorSender() { export function TestErrorSender() {
React.useEffect(() => { React.useEffect(() => {
if (window.location.href.includes("send-test-error-for-sentry")) { if (window.location.href.includes("send-test-error-for-sentry")) {
throw new Error("Test error for Sentry"); throw new Error("Test error for Sentry");
} }
}); });
return null; return null;
} }

View file

@ -1,53 +1,57 @@
{ {
"name": "impress", "name": "impress",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@apollo/client": "^3.6.9", "@apollo/client": "^3.6.9",
"@chakra-ui/icons": "^1.0.4", "@chakra-ui/icons": "^1.0.4",
"@chakra-ui/react": "^1.6.0", "@chakra-ui/react": "^1.6.0",
"@emotion/react": "^11.1.4", "@emotion/react": "^11.1.4",
"@emotion/styled": "^11.0.0", "@emotion/styled": "^11.0.0",
"@hotwired/turbo-rails": "^8.0.4", "@hotwired/turbo-rails": "^8.0.4",
"@loadable/component": "^5.12.0", "@loadable/component": "^5.12.0",
"@sentry/react": "^5.30.0", "@sentry/react": "^5.30.0",
"@sentry/tracing": "^5.30.0", "@sentry/tracing": "^5.30.0",
"@tanstack/react-query": "^5.4.3", "@tanstack/react-query": "^5.4.3",
"apollo-link-persisted-queries": "^0.2.2", "apollo-link-persisted-queries": "^0.2.2",
"easeljs": "^1.0.2", "easeljs": "^1.0.2",
"esbuild": "^0.19.0", "esbuild": "^0.19.0",
"framer-motion": "^4.1.11", "framer-motion": "^4.1.11",
"graphql": "^15.5.0", "graphql": "^15.5.0",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"immer": "^9.0.6", "immer": "^9.0.6",
"lru-cache": "^6.0.0", "lru-cache": "^6.0.0",
"react": "^18.2.0", "react": "^18.2.0",
"react-autosuggest": "^10.0.2", "react-autosuggest": "^10.0.2",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-icons": "^4.2.0", "react-icons": "^4.2.0",
"react-router-dom": "^6.15.0", "react-router-dom": "^6.15.0",
"react-transition-group": "^4.3.0", "react-transition-group": "^4.3.0",
"tweenjs": "^1.0.2" "tweenjs": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^7.8.0", "@typescript-eslint/eslint-plugin": "^7.8.0",
"@typescript-eslint/parser": "^7.8.0", "@typescript-eslint/parser": "^7.8.0",
"eslint": "^8.52.0", "eslint": "^8.52.0",
"eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-react": "^7.33.2", "eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-hooks": "^4.6.0",
"express": "^4.18.3", "express": "^4.18.3",
"husky": "^8.0.3", "husky": "^8.0.3",
"node-fetch": "^3.3.2", "node-fetch": "^3.3.2",
"oauth2-mock-server": "^7.1.1", "oauth2-mock-server": "^7.1.1",
"prettier": "^3.0.3", "prettier": "^3.0.3",
"typescript": "^5.2.2" "typescript": "^5.2.2"
}, },
"scripts": { "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": "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", "build:dev": "yarn build --public-path=/dev-assets",
"dev": "yarn build:dev --watch", "dev": "yarn build:dev --watch",
"lint": "eslint app/javascript", "format": "prettier -w app/javascript app/assets/javascripts",
"prepare": "husky install" "lint": "eslint app/javascript",
}, "prepare": "husky install"
"packageManager": "yarn@4.4.1" },
"prettier": {
"useTabs": true
},
"packageManager": "yarn@4.4.1"
} }