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:
parent
71ffb7f1be
commit
0e314482f7
57 changed files with 11694 additions and 11709 deletions
1
.prettierignore
Normal file
1
.prettierignore
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/app/assets/javascripts/lib
|
|
@ -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
|
@ -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);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
@ -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());
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -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();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
@ -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 incorrectly—but we're not sure if it's on our end, or TNT's.
|
display incorrectly—but we're not sure if it's on our end, or TNT's.
|
||||||
If you own this item, please email me at matchu@openneo.net to let us
|
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
|
||||||
design—but the pet's gender does not actually affect which horn you'll
|
design—but 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 incorrectly—but we're not sure if it's
|
zone that causes it to display incorrectly—but we're not sure if it's
|
||||||
on our end, or TNT's. If you have this pet, please email me at
|
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
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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 again—and if you keep having trouble, please
|
connection and try again—and 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 again—and if you keep having trouble, please tell me more
|
and try again—and 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;
|
||||||
}
|
}
|
||||||
|
|
106
package.json
106
package.json
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue