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 () {
|
||||
var CSRFProtection;
|
||||
var token = $('meta[name="csrf-token"]').attr("content");
|
||||
if (token) {
|
||||
CSRFProtection = function (xhr, settings) {
|
||||
var sendToken =
|
||||
typeof settings.useCSRFProtection === "undefined" || // default to true
|
||||
settings.useCSRFProtection;
|
||||
if (sendToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
CSRFProtection = $.noop;
|
||||
}
|
||||
var CSRFProtection;
|
||||
var token = $('meta[name="csrf-token"]').attr("content");
|
||||
if (token) {
|
||||
CSRFProtection = function (xhr, settings) {
|
||||
var sendToken =
|
||||
typeof settings.useCSRFProtection === "undefined" || // default to true
|
||||
settings.useCSRFProtection;
|
||||
if (sendToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
CSRFProtection = $.noop;
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: CSRFProtection,
|
||||
});
|
||||
$.ajaxSetup({
|
||||
beforeSend: CSRFProtection,
|
||||
});
|
||||
})();
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,8 +1,8 @@
|
|||
(function () {
|
||||
function setChecked() {
|
||||
var el = $(this);
|
||||
el.closest("li").toggleClass("checked", el.is(":checked"));
|
||||
}
|
||||
function setChecked() {
|
||||
var el = $(this);
|
||||
el.closest("li").toggleClass("checked", el.is(":checked"));
|
||||
}
|
||||
|
||||
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
|
||||
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
|
||||
})();
|
||||
|
|
|
@ -1,7 +1,5 @@
|
|||
document.addEventListener("change", ({ target }) => {
|
||||
if (target.matches('select[name="closet_list[visibility]"]')) {
|
||||
target
|
||||
.closest("form")
|
||||
.setAttribute("data-list-visibility", target.value);
|
||||
target.closest("form").setAttribute("data-list-visibility", target.value);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
(function() {
|
||||
$('span.choose-outfit select').change(function(e) {
|
||||
var select = $(this);
|
||||
select.closest('li').find('input[type=text]').val(select.val());
|
||||
});
|
||||
(function () {
|
||||
$("span.choose-outfit select").change(function (e) {
|
||||
var select = $(this);
|
||||
select.closest("li").find("input[type=text]").val(select.val());
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,102 +1,100 @@
|
|||
// When the species face picker changes, update and submit the main picker form.
|
||||
document.addEventListener("change", (e) => {
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form",
|
||||
);
|
||||
const mainSpeciesField = mainPickerForm.querySelector(
|
||||
"[name='preview[species_id]']",
|
||||
);
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form",
|
||||
);
|
||||
const mainSpeciesField = mainPickerForm.querySelector(
|
||||
"[name='preview[species_id]']",
|
||||
);
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
// If the preview frame fails to load, try a full pageload.
|
||||
document.addEventListener("turbo:frame-missing", (e) => {
|
||||
if (!e.target.matches("#item-preview")) return;
|
||||
if (!e.target.matches("#item-preview")) return;
|
||||
|
||||
e.detail.visit(e.detail.response.url);
|
||||
e.preventDefault();
|
||||
e.detail.visit(e.detail.response.url);
|
||||
e.preventDefault();
|
||||
});
|
||||
|
||||
class SpeciesColorPicker extends HTMLElement {
|
||||
#internals;
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePicker extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePickerOptions extends HTMLElement {
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
class MeasuredContent extends HTMLElement {
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
|
||||
#measure() {
|
||||
// Find our `<measured-container>` parent, and set our natural width
|
||||
// as `var(--natural-width)` in the context of its CSS styles.
|
||||
const container = this.closest("measured-container");
|
||||
if (container == null) {
|
||||
throw new Error(
|
||||
`<measured-content> must be in a <measured-container>`,
|
||||
);
|
||||
}
|
||||
container.style.setProperty("--natural-width", this.offsetWidth + "px");
|
||||
}
|
||||
#measure() {
|
||||
// Find our `<measured-container>` parent, and set our natural width
|
||||
// as `var(--natural-width)` in the context of its CSS styles.
|
||||
const container = this.closest("measured-container");
|
||||
if (container == null) {
|
||||
throw new Error(`<measured-content> must be in a <measured-container>`);
|
||||
}
|
||||
container.style.setProperty("--natural-width", this.offsetWidth + "px");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
|
|
|
@ -108,9 +108,7 @@ class OutfitLayer extends HTMLElement {
|
|||
this.#setStatus("loading");
|
||||
this.#sendMessageToIframe({ type: "requestStatus" });
|
||||
window.addEventListener("message", (m) => this.#onMessage(m));
|
||||
this.iframe.addEventListener("error", () =>
|
||||
this.#setStatus("error"),
|
||||
);
|
||||
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
||||
} else {
|
||||
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
||||
}
|
||||
|
@ -137,8 +135,7 @@ class OutfitLayer extends HTMLElement {
|
|||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`<outfit-layer> got unexpected message: ` +
|
||||
JSON.stringify(data),
|
||||
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,272 +1,272 @@
|
|||
(function () {
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
|
||||
$.each(query_string.substr(1).split("&"), function () {
|
||||
var split_piece = this.split("=");
|
||||
if (split_piece.length == 2) {
|
||||
PetQuery[split_piece[0]] = split_piece[1];
|
||||
}
|
||||
});
|
||||
$.each(query_string.substr(1).split("&"), function () {
|
||||
var split_piece = this.split("=");
|
||||
if (split_piece.length == 2) {
|
||||
PetQuery[split_piece[0]] = split_piece[1];
|
||||
}
|
||||
});
|
||||
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function loadNotable() {
|
||||
// TODO: add HTTPS to notables
|
||||
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
|
||||
// var notables = response.notables;
|
||||
// var i = Math.floor(Math.random() * notables.length);
|
||||
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
|
||||
// if(!Preview.Job.current) {
|
||||
// Preview.Job.fallback.setAsCurrent();
|
||||
// }
|
||||
// });
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
function loadNotable() {
|
||||
// TODO: add HTTPS to notables
|
||||
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
|
||||
// var notables = response.notables;
|
||||
// var i = Math.floor(Math.random() * notables.length);
|
||||
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
|
||||
// if(!Preview.Job.current) {
|
||||
// Preview.Job.fallback.setAsCurrent();
|
||||
// }
|
||||
// });
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
} else {
|
||||
loadNotable();
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
} else {
|
||||
loadNotable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
loadFeature();
|
||||
loadFeature();
|
||||
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
|
||||
function getImageSrc() {
|
||||
if (key.substr(0, 3) === "a:-") {
|
||||
// lol lazy code for prank image :P
|
||||
// TODO: HTTPS?
|
||||
return (
|
||||
"https://swfimages.impress.openneo.net" +
|
||||
"/biology/000/000/0-2/" +
|
||||
key.substr(2) +
|
||||
"/300x300.png"
|
||||
);
|
||||
} else if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
function getImageSrc() {
|
||||
if (key.substr(0, 3) === "a:-") {
|
||||
// lol lazy code for prank image :P
|
||||
// TODO: HTTPS?
|
||||
return (
|
||||
"https://swfimages.impress.openneo.net" +
|
||||
"/biology/000/000/0-2/" +
|
||||
key.substr(2) +
|
||||
"/300x300.png"
|
||||
);
|
||||
} else if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
})();
|
||||
|
|
|
@ -1,208 +1,208 @@
|
|||
var DEBUG = document.location.search.substr(0, 6) == "?debug";
|
||||
|
||||
function petThumbnailUrl(pet_name) {
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
}
|
||||
|
||||
/* Needed items form */
|
||||
(function () {
|
||||
var UI = {};
|
||||
UI.form = $("#needed-items-form");
|
||||
UI.alert = $("#needed-items-alert");
|
||||
UI.pet_name_field = $("#needed-items-pet-name-field");
|
||||
UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
|
||||
UI.pet_header = $("#needed-items-pet-header");
|
||||
UI.reload = $("#needed-items-reload");
|
||||
UI.pet_items = $("#needed-items-pet-items");
|
||||
UI.item_template = $("#item-template");
|
||||
var UI = {};
|
||||
UI.form = $("#needed-items-form");
|
||||
UI.alert = $("#needed-items-alert");
|
||||
UI.pet_name_field = $("#needed-items-pet-name-field");
|
||||
UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
|
||||
UI.pet_header = $("#needed-items-pet-header");
|
||||
UI.reload = $("#needed-items-reload");
|
||||
UI.pet_items = $("#needed-items-pet-items");
|
||||
UI.item_template = $("#item-template");
|
||||
|
||||
var current_request = { abort: function () {} };
|
||||
function sendRequest(options) {
|
||||
current_request = $.ajax(options);
|
||||
}
|
||||
var current_request = { abort: function () {} };
|
||||
function sendRequest(options) {
|
||||
current_request = $.ajax(options);
|
||||
}
|
||||
|
||||
function cancelRequest() {
|
||||
if (DEBUG) console.log("Canceling request", current_request);
|
||||
current_request.abort();
|
||||
}
|
||||
function cancelRequest() {
|
||||
if (DEBUG) console.log("Canceling request", current_request);
|
||||
current_request.abort();
|
||||
}
|
||||
|
||||
/* Pet */
|
||||
/* Pet */
|
||||
|
||||
var last_successful_pet_name = null;
|
||||
var last_successful_pet_name = null;
|
||||
|
||||
function loadPet(pet_name) {
|
||||
// If there is a request in progress, kill it. Our new pet request takes
|
||||
// priority, and, if I submit a name while the previous name is loading, I
|
||||
// don't want to process both responses.
|
||||
cancelRequest();
|
||||
function loadPet(pet_name) {
|
||||
// If there is a request in progress, kill it. Our new pet request takes
|
||||
// priority, and, if I submit a name while the previous name is loading, I
|
||||
// don't want to process both responses.
|
||||
cancelRequest();
|
||||
|
||||
sendRequest({
|
||||
url: UI.form.attr("action") + ".json",
|
||||
dataType: "json",
|
||||
data: { name: pet_name },
|
||||
error: petError,
|
||||
success: function (data) {
|
||||
petSuccess(data, pet_name);
|
||||
},
|
||||
complete: petComplete,
|
||||
});
|
||||
sendRequest({
|
||||
url: UI.form.attr("action") + ".json",
|
||||
dataType: "json",
|
||||
data: { name: pet_name },
|
||||
error: petError,
|
||||
success: function (data) {
|
||||
petSuccess(data, pet_name);
|
||||
},
|
||||
complete: petComplete,
|
||||
});
|
||||
|
||||
UI.form.removeClass("failed").addClass("loading-pet");
|
||||
}
|
||||
UI.form.removeClass("failed").addClass("loading-pet");
|
||||
}
|
||||
|
||||
function petComplete() {
|
||||
UI.form.removeClass("loading-pet");
|
||||
}
|
||||
function petComplete() {
|
||||
UI.form.removeClass("loading-pet");
|
||||
}
|
||||
|
||||
function petError(xhr) {
|
||||
UI.alert.text(xhr.responseText);
|
||||
UI.form.addClass("failed");
|
||||
}
|
||||
function petError(xhr) {
|
||||
UI.alert.text(xhr.responseText);
|
||||
UI.form.addClass("failed");
|
||||
}
|
||||
|
||||
function petSuccess(data, pet_name) {
|
||||
last_successful_pet_name = pet_name;
|
||||
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
|
||||
UI.pet_header.empty();
|
||||
$("#needed-items-pet-header-template")
|
||||
.tmpl({ pet_name: pet_name })
|
||||
.appendTo(UI.pet_header);
|
||||
loadItems(data.query);
|
||||
}
|
||||
function petSuccess(data, pet_name) {
|
||||
last_successful_pet_name = pet_name;
|
||||
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
|
||||
UI.pet_header.empty();
|
||||
$("#needed-items-pet-header-template")
|
||||
.tmpl({ pet_name: pet_name })
|
||||
.appendTo(UI.pet_header);
|
||||
loadItems(data.query);
|
||||
}
|
||||
|
||||
/* Items */
|
||||
/* Items */
|
||||
|
||||
function loadItems(query) {
|
||||
UI.form.addClass("loading-items");
|
||||
sendRequest({
|
||||
url: "/items/needed.json",
|
||||
dataType: "json",
|
||||
data: query,
|
||||
success: itemsSuccess,
|
||||
});
|
||||
}
|
||||
function loadItems(query) {
|
||||
UI.form.addClass("loading-items");
|
||||
sendRequest({
|
||||
url: "/items/needed.json",
|
||||
dataType: "json",
|
||||
data: query,
|
||||
success: itemsSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
function itemsSuccess(items) {
|
||||
if (DEBUG) {
|
||||
// The dev server is missing lots of data, so sends me 2000+ needed
|
||||
// items. We don't need that many for styling, so limit it to 100 to make
|
||||
// my browser happier.
|
||||
items = items.slice(0, 100);
|
||||
}
|
||||
function itemsSuccess(items) {
|
||||
if (DEBUG) {
|
||||
// The dev server is missing lots of data, so sends me 2000+ needed
|
||||
// items. We don't need that many for styling, so limit it to 100 to make
|
||||
// my browser happier.
|
||||
items = items.slice(0, 100);
|
||||
}
|
||||
|
||||
UI.pet_items.empty();
|
||||
UI.item_template.tmpl(items).appendTo(UI.pet_items);
|
||||
UI.pet_items.empty();
|
||||
UI.item_template.tmpl(items).appendTo(UI.pet_items);
|
||||
|
||||
UI.form.removeClass("loading-items").addClass("loaded");
|
||||
}
|
||||
UI.form.removeClass("loading-items").addClass("loaded");
|
||||
}
|
||||
|
||||
UI.form.submit(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(UI.pet_name_field.val());
|
||||
});
|
||||
UI.form.submit(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(UI.pet_name_field.val());
|
||||
});
|
||||
|
||||
UI.reload.click(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(last_successful_pet_name);
|
||||
});
|
||||
UI.reload.click(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(last_successful_pet_name);
|
||||
});
|
||||
})();
|
||||
|
||||
/* Bulk pets form */
|
||||
(function () {
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
|
||||
$(document.body).addClass("js");
|
||||
$(document.body).addClass("js");
|
||||
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import "@hotwired/turbo-rails";
|
||||
|
||||
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!
|
||||
// eslint-disable-next-line react/no-deprecated
|
||||
ReactDOM.render(
|
||||
<AppProvider>
|
||||
<WardrobePage />
|
||||
</AppProvider>,
|
||||
rootNode,
|
||||
<AppProvider>
|
||||
<WardrobePage />
|
||||
</AppProvider>,
|
||||
rootNode,
|
||||
);
|
||||
|
|
|
@ -2,12 +2,12 @@ import React from "react";
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import {
|
||||
ChakraProvider,
|
||||
Box,
|
||||
css as resolveCSS,
|
||||
extendTheme,
|
||||
useColorMode,
|
||||
useTheme,
|
||||
ChakraProvider,
|
||||
Box,
|
||||
css as resolveCSS,
|
||||
extendTheme,
|
||||
useColorMode,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { mode } from "@chakra-ui/theme-tools";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
|
@ -20,15 +20,15 @@ import apolloClient from "./apolloClient";
|
|||
const reactQueryClient = new QueryClient();
|
||||
|
||||
let theme = extendTheme({
|
||||
styles: {
|
||||
global: (props) => ({
|
||||
body: {
|
||||
background: mode("gray.50", "gray.800")(props),
|
||||
color: mode("green.800", "green.50")(props),
|
||||
transition: "all 0.25s",
|
||||
},
|
||||
}),
|
||||
},
|
||||
styles: {
|
||||
global: (props) => ({
|
||||
body: {
|
||||
background: mode("gray.50", "gray.800")(props),
|
||||
color: mode("green.800", "green.50")(props),
|
||||
transition: "all 0.25s",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Capture the global styles function from our theme, but remove it from the
|
||||
|
@ -43,60 +43,60 @@ const globalStyles = theme.styles.global;
|
|||
theme.styles.global = {};
|
||||
|
||||
export default function AppProvider({ children }) {
|
||||
React.useEffect(() => setupLogging(), []);
|
||||
React.useEffect(() => setupLogging(), []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ChakraProvider resetCSS={false} theme={theme}>
|
||||
<ScopedCSSReset>{children}</ScopedCSSReset>
|
||||
</ChakraProvider>
|
||||
</ApolloProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ChakraProvider resetCSS={false} theme={theme}>
|
||||
<ScopedCSSReset>{children}</ScopedCSSReset>
|
||||
</ChakraProvider>
|
||||
</ApolloProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function setupLogging() {
|
||||
Sentry.init({
|
||||
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
|
||||
autoSessionTracking: true,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
beforeNavigate: (context) => ({
|
||||
...context,
|
||||
// Assume any path segment starting with a digit is an ID, and replace
|
||||
// it with `:id`. This will help group related routes in Sentry stats.
|
||||
// NOTE: I'm a bit uncertain about the timing on this for tracking
|
||||
// client-side navs... but we now only track first-time
|
||||
// pageloads, and it definitely works correctly for them!
|
||||
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
|
||||
}),
|
||||
Sentry.init({
|
||||
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
|
||||
autoSessionTracking: true,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
beforeNavigate: (context) => ({
|
||||
...context,
|
||||
// Assume any path segment starting with a digit is an ID, and replace
|
||||
// it with `:id`. This will help group related routes in Sentry stats.
|
||||
// NOTE: I'm a bit uncertain about the timing on this for tracking
|
||||
// client-side navs... but we now only track first-time
|
||||
// pageloads, and it definitely works correctly for them!
|
||||
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
|
||||
}),
|
||||
|
||||
// We have a _lot_ of location changes that don't actually signify useful
|
||||
// navigations, like in the wardrobe page. It could be useful to trace
|
||||
// them with better filtering someday, but frankly we don't use the perf
|
||||
// features besides Web Vitals right now, and those only get tracked on
|
||||
// first-time pageloads, anyway. So, don't track client-side navs!
|
||||
startTransactionOnLocationChange: false,
|
||||
}),
|
||||
],
|
||||
denyUrls: [
|
||||
// Don't log errors that were probably triggered by extensions and not by
|
||||
// our own app. (Apparently Sentry's setting to ignore browser extension
|
||||
// errors doesn't do this anywhere near as consistently as I'd expect?)
|
||||
//
|
||||
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
|
||||
/^chrome-extension:\/\//,
|
||||
/^moz-extension:\/\//,
|
||||
],
|
||||
// We have a _lot_ of location changes that don't actually signify useful
|
||||
// navigations, like in the wardrobe page. It could be useful to trace
|
||||
// them with better filtering someday, but frankly we don't use the perf
|
||||
// features besides Web Vitals right now, and those only get tracked on
|
||||
// first-time pageloads, anyway. So, don't track client-side navs!
|
||||
startTransactionOnLocationChange: false,
|
||||
}),
|
||||
],
|
||||
denyUrls: [
|
||||
// Don't log errors that were probably triggered by extensions and not by
|
||||
// our own app. (Apparently Sentry's setting to ignore browser extension
|
||||
// errors doesn't do this anywhere near as consistently as I'd expect?)
|
||||
//
|
||||
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
|
||||
/^chrome-extension:\/\//,
|
||||
/^moz-extension:\/\//,
|
||||
],
|
||||
|
||||
// Since we're only tracking first-page loads and not navigations, 100%
|
||||
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
// Since we're only tracking first-page loads and not navigations, 100%
|
||||
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -112,308 +112,308 @@ function setupLogging() {
|
|||
* the selector `:where(.chakra-css-reset) h1` is lower specificity.
|
||||
*/
|
||||
function ScopedCSSReset({ children }) {
|
||||
// Get the current theme and color mode.
|
||||
//
|
||||
// NOTE: The theme object returned by `useTheme` has some extensions that are
|
||||
// necessary for the code below, but aren't present in the theme config
|
||||
// returned by `extendTheme`! That's why we use this here instead of `theme`.
|
||||
const liveTheme = useTheme();
|
||||
const colorMode = useColorMode();
|
||||
// Get the current theme and color mode.
|
||||
//
|
||||
// NOTE: The theme object returned by `useTheme` has some extensions that are
|
||||
// necessary for the code below, but aren't present in the theme config
|
||||
// returned by `extendTheme`! That's why we use this here instead of `theme`.
|
||||
const liveTheme = useTheme();
|
||||
const colorMode = useColorMode();
|
||||
|
||||
// Resolve the theme's global styles into CSS objects for Emotion.
|
||||
const globalStylesCSS = resolveCSS(
|
||||
globalStyles({ theme: liveTheme, colorMode }),
|
||||
)(liveTheme);
|
||||
// Resolve the theme's global styles into CSS objects for Emotion.
|
||||
const globalStylesCSS = resolveCSS(
|
||||
globalStyles({ theme: liveTheme, colorMode }),
|
||||
)(liveTheme);
|
||||
|
||||
// Prepend our special scope selector to the global styles.
|
||||
const scopedGlobalStylesCSS = {};
|
||||
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
|
||||
// The `body` selector is where typography etc rules go, but `body` isn't
|
||||
// actually *inside* our scoped element! Instead, ignore the `body` part,
|
||||
// and just apply it to the scoping element itself.
|
||||
if (selector.trim() === "body") {
|
||||
selector = "";
|
||||
}
|
||||
// Prepend our special scope selector to the global styles.
|
||||
const scopedGlobalStylesCSS = {};
|
||||
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
|
||||
// The `body` selector is where typography etc rules go, but `body` isn't
|
||||
// actually *inside* our scoped element! Instead, ignore the `body` part,
|
||||
// and just apply it to the scoping element itself.
|
||||
if (selector.trim() === "body") {
|
||||
selector = "";
|
||||
}
|
||||
|
||||
const scopedSelector =
|
||||
":where(.chakra-css-reset, .chakra-portal) " + selector;
|
||||
scopedGlobalStylesCSS[scopedSelector] = rules;
|
||||
}
|
||||
const scopedSelector =
|
||||
":where(.chakra-css-reset, .chakra-portal) " + selector;
|
||||
scopedGlobalStylesCSS[scopedSelector] = rules;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="chakra-css-reset">{children}</Box>
|
||||
<Global
|
||||
styles={css`
|
||||
/* Chakra's default global styles, placed here so we can override
|
||||
return (
|
||||
<>
|
||||
<Box className="chakra-css-reset">{children}</Box>
|
||||
<Global
|
||||
styles={css`
|
||||
/* Chakra's default global styles, placed here so we can override
|
||||
* the actual _global_ styles in the theme to be empty. That way,
|
||||
* it only affects Chakra stuff, not all elements! */
|
||||
${scopedGlobalStylesCSS}
|
||||
${scopedGlobalStylesCSS}
|
||||
|
||||
/* Chakra's CSS reset, copy-pasted and rescoped! */
|
||||
/* Chakra's CSS reset, copy-pasted and rescoped! */
|
||||
:where(.chakra-css-reset, .chakra-portal) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top-width: 1px;
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
hr {
|
||||
border-top-width: 1px;
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
body,
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner {
|
||||
border: 0 !important;
|
||||
}
|
||||
button::-moz-focus-inner {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
}
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,31 +1,31 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
|
||||
import { loadable } from "../util";
|
||||
|
||||
import {
|
||||
ItemCardContent,
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
MaybeAnimatedBadge,
|
||||
YouOwnThisBadge,
|
||||
YouWantThisBadge,
|
||||
getZoneBadges,
|
||||
ItemCardContent,
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
MaybeAnimatedBadge,
|
||||
YouOwnThisBadge,
|
||||
YouWantThisBadge,
|
||||
getZoneBadges,
|
||||
} from "../components/ItemCard";
|
||||
import SupportOnly from "./support/SupportOnly";
|
||||
import useSupport from "./support/useSupport";
|
||||
|
||||
const LoadableItemSupportDrawer = loadable(() =>
|
||||
import("./support/ItemSupportDrawer"),
|
||||
const LoadableItemSupportDrawer = loadable(
|
||||
() => import("./support/ItemSupportDrawer"),
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -48,79 +48,79 @@ const LoadableItemSupportDrawer = loadable(() =>
|
|||
* devices.
|
||||
*/
|
||||
function Item({
|
||||
item,
|
||||
itemNameId,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
onRemove,
|
||||
isDisabled = false,
|
||||
item,
|
||||
itemNameId,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
onRemove,
|
||||
isDisabled = false,
|
||||
}) {
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ItemContainer isDisabled={isDisabled}>
|
||||
<Box flex="1 1 0" minWidth="0">
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={<ItemBadges item={item} />}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDiabled={isDisabled}
|
||||
focusSelector={containerHasFocus}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="0 0 auto" marginTop="5px">
|
||||
{isInOutfit && (
|
||||
<ItemActionButton
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
onClick={(e) => {
|
||||
onRemove(item.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<EditIcon />}
|
||||
label="Support"
|
||||
onClick={(e) => {
|
||||
setSupportDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
target="_blank"
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
isOpen={supportDrawerIsOpen}
|
||||
onClose={() => setSupportDrawerIsOpen(false)}
|
||||
/>
|
||||
</SupportOnly>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ItemContainer isDisabled={isDisabled}>
|
||||
<Box flex="1 1 0" minWidth="0">
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={<ItemBadges item={item} />}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDiabled={isDisabled}
|
||||
focusSelector={containerHasFocus}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="0 0 auto" marginTop="5px">
|
||||
{isInOutfit && (
|
||||
<ItemActionButton
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
onClick={(e) => {
|
||||
onRemove(item.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<EditIcon />}
|
||||
label="Support"
|
||||
onClick={(e) => {
|
||||
setSupportDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
target="_blank"
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
isOpen={supportDrawerIsOpen}
|
||||
onClose={() => setSupportDrawerIsOpen(false)}
|
||||
/>
|
||||
</SupportOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemSkeleton is a placeholder for when an Item is loading.
|
||||
*/
|
||||
function ItemSkeleton() {
|
||||
return (
|
||||
<ItemContainer isDisabled>
|
||||
<Skeleton width="50px" height="50px" />
|
||||
<Box width="3" />
|
||||
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
|
||||
</ItemContainer>
|
||||
);
|
||||
return (
|
||||
<ItemContainer isDisabled>
|
||||
<Skeleton width="50px" height="50px" />
|
||||
<Box width="3" />
|
||||
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
|
||||
</ItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -131,152 +131,152 @@ function ItemSkeleton() {
|
|||
* .item-container parent!
|
||||
*/
|
||||
function ItemContainer({ children, isDisabled = false }) {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["100"],
|
||||
theme.colors.gray["700"],
|
||||
);
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["100"],
|
||||
theme.colors.gray["700"],
|
||||
);
|
||||
|
||||
const activeBorderColor = useColorModeValue(
|
||||
theme.colors.green["400"],
|
||||
theme.colors.green["500"],
|
||||
);
|
||||
const activeBorderColor = useColorModeValue(
|
||||
theme.colors.green["400"],
|
||||
theme.colors.green["500"],
|
||||
);
|
||||
|
||||
const focusCheckedBorderColor = useColorModeValue(
|
||||
theme.colors.green["800"],
|
||||
theme.colors.green["300"],
|
||||
);
|
||||
const focusCheckedBorderColor = useColorModeValue(
|
||||
theme.colors.green["800"],
|
||||
theme.colors.green["300"],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
p="1"
|
||||
my="1"
|
||||
borderRadius="lg"
|
||||
d="flex"
|
||||
cursor={isDisabled ? undefined : "pointer"}
|
||||
border="1px"
|
||||
borderColor="transparent"
|
||||
className={cx([
|
||||
"item-container",
|
||||
!isDisabled &&
|
||||
css`
|
||||
&:hover,
|
||||
input:focus + & {
|
||||
background-color: ${focusBackgroundColor};
|
||||
}
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
p="1"
|
||||
my="1"
|
||||
borderRadius="lg"
|
||||
d="flex"
|
||||
cursor={isDisabled ? undefined : "pointer"}
|
||||
border="1px"
|
||||
borderColor="transparent"
|
||||
className={cx([
|
||||
"item-container",
|
||||
!isDisabled &&
|
||||
css`
|
||||
&:hover,
|
||||
input:focus + & {
|
||||
background-color: ${focusBackgroundColor};
|
||||
}
|
||||
|
||||
input:active + & {
|
||||
border-color: ${activeBorderColor};
|
||||
}
|
||||
input:active + & {
|
||||
border-color: ${activeBorderColor};
|
||||
}
|
||||
|
||||
input:checked:focus + & {
|
||||
border-color: ${focusCheckedBorderColor};
|
||||
}
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
input:checked:focus + & {
|
||||
border-color: ${focusCheckedBorderColor};
|
||||
}
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemBadges({ item }) {
|
||||
const { isSupportUser } = useSupport();
|
||||
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
|
||||
const restrictedZones = item.appearanceOn.restrictedZones.filter(
|
||||
(z) => z.isCommonlyUsedByItems,
|
||||
);
|
||||
const isMaybeAnimated = item.appearanceOn.layers.some(
|
||||
(l) => l.canvasMovieLibraryUrl,
|
||||
);
|
||||
const { isSupportUser } = useSupport();
|
||||
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
|
||||
const restrictedZones = item.appearanceOn.restrictedZones.filter(
|
||||
(z) => z.isCommonlyUsedByItems,
|
||||
);
|
||||
const isMaybeAnimated = item.appearanceOn.layers.some(
|
||||
(l) => l.canvasMovieLibraryUrl,
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemBadgeList>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
|
||||
{
|
||||
// This badge is unreliable, but it's helpful for looking for animated
|
||||
// items to test, so we show it only to support. We use this form
|
||||
// instead of <SupportOnly />, to avoid adding extra badge list spacing
|
||||
// on the additional empty child.
|
||||
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
|
||||
}
|
||||
{getZoneBadges(occupiedZones, { variant: "occupies" })}
|
||||
{getZoneBadges(restrictedZones, { variant: "restricts" })}
|
||||
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
|
||||
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
|
||||
</ItemBadgeList>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeList>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
|
||||
{
|
||||
// This badge is unreliable, but it's helpful for looking for animated
|
||||
// items to test, so we show it only to support. We use this form
|
||||
// instead of <SupportOnly />, to avoid adding extra badge list spacing
|
||||
// on the additional empty child.
|
||||
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
|
||||
}
|
||||
{getZoneBadges(occupiedZones, { variant: "occupies" })}
|
||||
{getZoneBadges(restrictedZones, { variant: "restricts" })}
|
||||
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
|
||||
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
|
||||
</ItemBadgeList>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemActionButton is one of a list of actions a user can take for this item.
|
||||
*/
|
||||
function ItemActionButton({ icon, label, to, onClick, ...props }) {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["300"],
|
||||
theme.colors.gray["800"],
|
||||
);
|
||||
const focusColor = useColorModeValue(
|
||||
theme.colors.gray["700"],
|
||||
theme.colors.gray["200"],
|
||||
);
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["300"],
|
||||
theme.colors.gray["800"],
|
||||
);
|
||||
const focusColor = useColorModeValue(
|
||||
theme.colors.gray["700"],
|
||||
theme.colors.gray["200"],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
{...props}
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
{...props}
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
${containerHasFocus} {
|
||||
opacity: 1;
|
||||
}
|
||||
${containerHasFocus} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: ${focusBackgroundColor};
|
||||
color: ${focusColor};
|
||||
}
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: ${focusBackgroundColor};
|
||||
color: ${focusColor};
|
||||
}
|
||||
|
||||
/* On touch devices, always show the buttons! This avoids having to
|
||||
/* On touch devices, always show the buttons! This avoids having to
|
||||
* tap to reveal them (which toggles the item), or worse,
|
||||
* accidentally tapping a hidden button without realizing! */
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, component, ...props }) {
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return <ButtonComponent as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return <ButtonComponent as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
|
|||
* components in this to ensure a consistent list layout.
|
||||
*/
|
||||
export function ItemListContainer({ children, ...props }) {
|
||||
return (
|
||||
<Flex direction="column" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex direction="column" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
|
|||
* Items are loading.
|
||||
*/
|
||||
export function ItemListSkeleton({ count, ...props }) {
|
||||
return (
|
||||
<ItemListContainer {...props}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</ItemListContainer>
|
||||
);
|
||||
return (
|
||||
<ItemListContainer {...props}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</ItemListContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -311,6 +311,6 @@ export function ItemListSkeleton({ count, ...props }) {
|
|||
* focused.
|
||||
*/
|
||||
const containerHasFocus =
|
||||
".item-container:hover &, input:focus + .item-container &";
|
||||
".item-container:hover &, input:focus + .item-container &";
|
||||
|
||||
export default React.memo(Item);
|
||||
|
|
|
@ -21,72 +21,72 @@ import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
|
|||
* state and refs.
|
||||
*/
|
||||
function ItemsAndSearchPanels({
|
||||
loading,
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
outfitState,
|
||||
outfitSaving,
|
||||
dispatchToOutfit,
|
||||
loading,
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
outfitState,
|
||||
outfitSaving,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
const scrollContainerRef = React.useRef();
|
||||
const searchQueryRef = React.useRef();
|
||||
const firstSearchResultRef = React.useRef();
|
||||
const scrollContainerRef = React.useRef();
|
||||
const searchQueryRef = React.useRef();
|
||||
const firstSearchResultRef = React.useRef();
|
||||
|
||||
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
|
||||
const [canUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
|
||||
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
|
||||
const [canUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Flex direction="column" height="100%">
|
||||
{isShowingSearchFooter && <Box height="2" />}
|
||||
{!isShowingSearchFooter && (
|
||||
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
onChange={onChangeSearchQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
|
||||
<Box
|
||||
key="search-panel"
|
||||
flex="1 0 0"
|
||||
position="relative"
|
||||
overflowY="scroll"
|
||||
ref={scrollContainerRef}
|
||||
data-test-id="search-panel-scroll-container"
|
||||
>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="relative" overflow="auto" key="items-panel">
|
||||
<Box px="4" py="2">
|
||||
<ItemsPanel
|
||||
loading={loading}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Flex direction="column" height="100%">
|
||||
{isShowingSearchFooter && <Box height="2" />}
|
||||
{!isShowingSearchFooter && (
|
||||
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
onChange={onChangeSearchQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
|
||||
<Box
|
||||
key="search-panel"
|
||||
flex="1 0 0"
|
||||
position="relative"
|
||||
overflowY="scroll"
|
||||
ref={scrollContainerRef}
|
||||
data-test-id="search-panel-scroll-container"
|
||||
>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="relative" overflow="auto" key="items-panel">
|
||||
<Box px="4" py="2">
|
||||
<ItemsPanel
|
||||
loading={loading}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemsAndSearchPanels;
|
||||
|
|
|
@ -1,38 +1,38 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Editable,
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
Box,
|
||||
Editable,
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
QuestionIcon,
|
||||
WarningTwoIcon,
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
QuestionIcon,
|
||||
WarningTwoIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { IoBagCheck } from "react-icons/io5";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
@ -59,70 +59,70 @@ import { useDeleteOutfitMutation } from "../loaders/outfits";
|
|||
* full width of the container, it doesn't look like it!
|
||||
*/
|
||||
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
||||
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
|
||||
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box>
|
||||
<Box px="1">
|
||||
<OutfitHeading
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
<Flex direction="column">
|
||||
{loading ? (
|
||||
<ItemZoneGroupsSkeleton
|
||||
itemCount={outfitState.allItemIds.length}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TransitionGroup component={null}>
|
||||
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
|
||||
<CSSTransition
|
||||
key={zoneId}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
<ItemZoneGroup
|
||||
zoneLabel={zoneLabel}
|
||||
items={items}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
{incompatibleItems.length > 0 && (
|
||||
<ItemZoneGroup
|
||||
zoneLabel="Incompatible"
|
||||
afterHeader={
|
||||
<Tooltip
|
||||
label={
|
||||
altStyleId != null
|
||||
? "Many items don't fit Alt Style pets"
|
||||
: "These items don't fit this pet"
|
||||
}
|
||||
placement="top"
|
||||
openDelay={100}
|
||||
>
|
||||
<QuestionIcon fontSize="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
items={incompatibleItems}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box>
|
||||
<Box px="1">
|
||||
<OutfitHeading
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
<Flex direction="column">
|
||||
{loading ? (
|
||||
<ItemZoneGroupsSkeleton
|
||||
itemCount={outfitState.allItemIds.length}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TransitionGroup component={null}>
|
||||
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
|
||||
<CSSTransition
|
||||
key={zoneId}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
<ItemZoneGroup
|
||||
zoneLabel={zoneLabel}
|
||||
items={items}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
{incompatibleItems.length > 0 && (
|
||||
<ItemZoneGroup
|
||||
zoneLabel="Incompatible"
|
||||
afterHeader={
|
||||
<Tooltip
|
||||
label={
|
||||
altStyleId != null
|
||||
? "Many items don't fit Alt Style pets"
|
||||
: "These items don't fit this pet"
|
||||
}
|
||||
placement="top"
|
||||
openDelay={100}
|
||||
>
|
||||
<QuestionIcon fontSize="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
items={incompatibleItems}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -134,102 +134,102 @@ function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
|||
* makes the list screen-reader- and keyboard-accessible!
|
||||
*/
|
||||
function ItemZoneGroup({
|
||||
zoneLabel,
|
||||
items,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isDisabled = false,
|
||||
afterHeader = null,
|
||||
zoneLabel,
|
||||
items,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isDisabled = false,
|
||||
afterHeader = null,
|
||||
}) {
|
||||
// onChange is fired when the radio button becomes checked, not unchecked!
|
||||
const onChange = (e) => {
|
||||
const itemId = e.target.value;
|
||||
dispatchToOutfit({ type: "wearItem", itemId });
|
||||
};
|
||||
// onChange is fired when the radio button becomes checked, not unchecked!
|
||||
const onChange = (e) => {
|
||||
const itemId = e.target.value;
|
||||
dispatchToOutfit({ type: "wearItem", itemId });
|
||||
};
|
||||
|
||||
// Clicking the radio button when already selected deselects it - this is how
|
||||
// you can select none!
|
||||
const onClick = (e) => {
|
||||
const itemId = e.target.value;
|
||||
if (outfitState.wornItemIds.includes(itemId)) {
|
||||
// We need the event handler to finish before this, so that simulated
|
||||
// events don't just come back around and undo it - but we can't just
|
||||
// solve that with `preventDefault`, because it breaks the radio's
|
||||
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
||||
// after all event handlers resolve!
|
||||
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
||||
}
|
||||
};
|
||||
// Clicking the radio button when already selected deselects it - this is how
|
||||
// you can select none!
|
||||
const onClick = (e) => {
|
||||
const itemId = e.target.value;
|
||||
if (outfitState.wornItemIds.includes(itemId)) {
|
||||
// We need the event handler to finish before this, so that simulated
|
||||
// events don't just come back around and undo it - but we can't just
|
||||
// solve that with `preventDefault`, because it breaks the radio's
|
||||
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
||||
// after all event handlers resolve!
|
||||
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = React.useCallback(
|
||||
(itemId) => {
|
||||
dispatchToOutfit({ type: "removeItem", itemId });
|
||||
},
|
||||
[dispatchToOutfit],
|
||||
);
|
||||
const onRemove = React.useCallback(
|
||||
(itemId) => {
|
||||
dispatchToOutfit({ type: "removeItem", itemId });
|
||||
},
|
||||
[dispatchToOutfit],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box mb="10">
|
||||
<Heading2 display="flex" alignItems="center" mx="1">
|
||||
{zoneLabel}
|
||||
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
||||
</Heading2>
|
||||
<ItemListContainer>
|
||||
<TransitionGroup component={null}>
|
||||
{items.map((item) => {
|
||||
const itemNameId =
|
||||
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
||||
const itemNode = (
|
||||
<Item
|
||||
item={item}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={
|
||||
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
||||
}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
onRemove={onRemove}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box mb="10">
|
||||
<Heading2 display="flex" alignItems="center" mx="1">
|
||||
{zoneLabel}
|
||||
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
||||
</Heading2>
|
||||
<ItemListContainer>
|
||||
<TransitionGroup component={null}>
|
||||
{items.map((item) => {
|
||||
const itemNameId =
|
||||
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
||||
const itemNode = (
|
||||
<Item
|
||||
item={item}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={
|
||||
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
||||
}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
onRemove={onRemove}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={item.id}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
itemNode
|
||||
) : (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-labelledby={itemNameId}
|
||||
name={zoneLabel}
|
||||
value={item.id}
|
||||
checked={outfitState.wornItemIds.includes(item.id)}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{itemNode}
|
||||
</label>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</ItemListContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<CSSTransition
|
||||
key={item.id}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
itemNode
|
||||
) : (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-labelledby={itemNameId}
|
||||
name={zoneLabel}
|
||||
value={item.id}
|
||||
checked={outfitState.wornItemIds.includes(item.id)}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{itemNode}
|
||||
</label>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</ItemListContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,35 +240,35 @@ function ItemZoneGroup({
|
|||
* we don't show skeleton items that just clear away!
|
||||
*/
|
||||
function ItemZoneGroupsSkeleton({ itemCount }) {
|
||||
const groups = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
||||
// outfit shares I think, but looks bad for pet loading... once shares
|
||||
// become a more common use case, it might be useful to figure out how
|
||||
// to differentiate these cases and show 1-per-group for pets, but
|
||||
// maybe more for built outfits?
|
||||
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
||||
}
|
||||
return groups;
|
||||
const groups = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
||||
// outfit shares I think, but looks bad for pet loading... once shares
|
||||
// become a more common use case, it might be useful to figure out how
|
||||
// to differentiate these cases and show 1-per-group for pets, but
|
||||
// maybe more for built outfits?
|
||||
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
|
||||
*/
|
||||
function ItemZoneGroupSkeleton({ itemCount }) {
|
||||
return (
|
||||
<Box mb="10">
|
||||
<Delay>
|
||||
<Skeleton
|
||||
mx="1"
|
||||
// 2.25rem font size, 1.25rem line height
|
||||
height={`${2.25 * 1.25}rem`}
|
||||
width="12rem"
|
||||
/>
|
||||
<ItemListSkeleton count={itemCount} />
|
||||
</Delay>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box mb="10">
|
||||
<Delay>
|
||||
<Skeleton
|
||||
mx="1"
|
||||
// 2.25rem font size, 1.25rem line height
|
||||
height={`${2.25 * 1.25}rem`}
|
||||
width="12rem"
|
||||
/>
|
||||
<ItemListSkeleton count={itemCount} />
|
||||
</Delay>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -277,36 +277,36 @@ function ItemZoneGroupSkeleton({ itemCount }) {
|
|||
* this is disabled.
|
||||
*/
|
||||
function ShoppingListButton({ outfitState }) {
|
||||
const itemIds = [...outfitState.wornItemIds].sort();
|
||||
const isDisabled = itemIds.length === 0;
|
||||
const itemIds = [...outfitState.wornItemIds].sort();
|
||||
const isDisabled = itemIds.length === 0;
|
||||
|
||||
let targetUrl = `/items/sources/${itemIds.join(",")}`;
|
||||
if (outfitState.name != null && outfitState.name.trim().length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("for", outfitState.name);
|
||||
targetUrl += "?" + params.toString();
|
||||
}
|
||||
let targetUrl = `/items/sources/${itemIds.join(",")}`;
|
||||
if (outfitState.name != null && outfitState.name.trim().length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("for", outfitState.name);
|
||||
targetUrl += "?" + params.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Shopping list"
|
||||
placement="top"
|
||||
background="purple.500"
|
||||
color="white"
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Shopping list"
|
||||
as={isDisabled ? "button" : "a"}
|
||||
href={isDisabled ? undefined : targetUrl}
|
||||
target={isDisabled ? undefined : "_blank"}
|
||||
icon={<IoBagCheck />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
isRound
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label="Shopping list"
|
||||
placement="top"
|
||||
background="purple.500"
|
||||
color="white"
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Shopping list"
|
||||
as={isDisabled ? "button" : "a"}
|
||||
href={isDisabled ? undefined : targetUrl}
|
||||
target={isDisabled ? undefined : "_blank"}
|
||||
icon={<IoBagCheck />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
isRound
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -314,100 +314,100 @@ function ShoppingListButton({ outfitState }) {
|
|||
* if the user can save this outfit. If not, this is empty!
|
||||
*/
|
||||
function OutfitSavingIndicator({ outfitSaving }) {
|
||||
const {
|
||||
canSaveOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
} = outfitSaving;
|
||||
const {
|
||||
canSaveOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
} = outfitSaving;
|
||||
|
||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||
|
||||
if (!canSaveOutfit) {
|
||||
return null;
|
||||
}
|
||||
if (!canSaveOutfit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNewOutfit) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (isNewOutfit) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saving-indicator"
|
||||
>
|
||||
<Spinner
|
||||
size="xs"
|
||||
marginRight="1.5"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saving…
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saving-indicator"
|
||||
>
|
||||
<Spinner
|
||||
size="xs"
|
||||
marginRight="1.5"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saving…
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (latestVersionIsSaved) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saved-indicator"
|
||||
>
|
||||
<CheckIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saved
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (latestVersionIsSaved) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saved-indicator"
|
||||
>
|
||||
<CheckIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saved
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (saveError) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-save-error-indicator"
|
||||
color={errorTextColor}
|
||||
>
|
||||
<WarningTwoIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Error saving
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (saveError) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-save-error-indicator"
|
||||
color={errorTextColor}
|
||||
>
|
||||
<WarningTwoIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Error saving
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// The most common way we'll hit this null is when the outfit is changing,
|
||||
// but the debouncing isn't done yet, so it's not saving yet.
|
||||
return null;
|
||||
// The most common way we'll hit this null is when the outfit is changing,
|
||||
// but the debouncing isn't done yet, so it's not saving yet.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -415,133 +415,133 @@ function OutfitSavingIndicator({ outfitSaving }) {
|
|||
* It also contains the outfit menu, for saving etc.
|
||||
*/
|
||||
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
|
||||
const { canDeleteOutfit } = outfitSaving;
|
||||
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
|
||||
const { canDeleteOutfit } = outfitSaving;
|
||||
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
|
||||
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
// a Rename option.
|
||||
<Editable
|
||||
// Make sure not to ever pass `undefined` into here, or else the
|
||||
// component enters uncontrolled mode, and changing the value
|
||||
// later won't fix it!
|
||||
value={outfitState.name || ""}
|
||||
placeholder="Untitled outfit"
|
||||
onChange={(value) =>
|
||||
dispatchToOutfit({ type: "rename", outfitName: value })
|
||||
}
|
||||
>
|
||||
{({ onEdit }) => (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Box>
|
||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||
<Heading1>
|
||||
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||
<EditableInput lineHeight="48px" />
|
||||
</Heading1>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||
<Box width="3" flex="0 0 auto" />
|
||||
<ShoppingListButton outfitState={outfitState} />
|
||||
<Box width="2" flex="0 0 auto" />
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="ghost"
|
||||
icon={<MdMoreVert />}
|
||||
aria-label="Outfit menu"
|
||||
isRound
|
||||
size="sm"
|
||||
fontSize="24px"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{outfitState.id && (
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
as="a"
|
||||
href={outfitCopyUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit a copy
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<BiRename />}
|
||||
onClick={() => {
|
||||
// Start the rename after a tick, so finishing up the click
|
||||
// won't just immediately remove focus from the Editable.
|
||||
setTimeout(onEdit, 0);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
{canDeleteOutfit && (
|
||||
<DeleteOutfitMenuItem outfitState={outfitState} />
|
||||
)}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
)}
|
||||
</Editable>
|
||||
);
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
// a Rename option.
|
||||
<Editable
|
||||
// Make sure not to ever pass `undefined` into here, or else the
|
||||
// component enters uncontrolled mode, and changing the value
|
||||
// later won't fix it!
|
||||
value={outfitState.name || ""}
|
||||
placeholder="Untitled outfit"
|
||||
onChange={(value) =>
|
||||
dispatchToOutfit({ type: "rename", outfitName: value })
|
||||
}
|
||||
>
|
||||
{({ onEdit }) => (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Box>
|
||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||
<Heading1>
|
||||
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||
<EditableInput lineHeight="48px" />
|
||||
</Heading1>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||
<Box width="3" flex="0 0 auto" />
|
||||
<ShoppingListButton outfitState={outfitState} />
|
||||
<Box width="2" flex="0 0 auto" />
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="ghost"
|
||||
icon={<MdMoreVert />}
|
||||
aria-label="Outfit menu"
|
||||
isRound
|
||||
size="sm"
|
||||
fontSize="24px"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{outfitState.id && (
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
as="a"
|
||||
href={outfitCopyUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit a copy
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<BiRename />}
|
||||
onClick={() => {
|
||||
// Start the rename after a tick, so finishing up the click
|
||||
// won't just immediately remove focus from the Editable.
|
||||
setTimeout(onEdit, 0);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
{canDeleteOutfit && (
|
||||
<DeleteOutfitMenuItem outfitState={outfitState} />
|
||||
)}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
)}
|
||||
</Editable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOutfitMenuItem({ outfitState }) {
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { status, error, mutateAsync } = useDeleteOutfitMutation();
|
||||
const { status, error, mutateAsync } = useDeleteOutfitMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
We'll delete this data and remove it from your list of outfits.
|
||||
Links and image embeds pointing to this outfit will break. Is that
|
||||
okay?
|
||||
{status === "error" && (
|
||||
<ErrorMessage marginTop="1em">
|
||||
Error deleting outfit: "{error.message}". Try again?
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>No, keep this outfit</Button>
|
||||
<Box flex="1 0 auto" width="2" />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() =>
|
||||
mutateAsync(id)
|
||||
.then(() => {
|
||||
window.location = "/your-outfits";
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
})
|
||||
}
|
||||
// We continue to show the loading spinner in the success case,
|
||||
// while we redirect away!
|
||||
isLoading={status === "pending" || status === "success"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
We'll delete this data and remove it from your list of outfits.
|
||||
Links and image embeds pointing to this outfit will break. Is that
|
||||
okay?
|
||||
{status === "error" && (
|
||||
<ErrorMessage marginTop="1em">
|
||||
Error deleting outfit: "{error.message}". Try again?
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>No, keep this outfit</Button>
|
||||
<Box flex="1 0 auto" width="2" />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() =>
|
||||
mutateAsync(id)
|
||||
.then(() => {
|
||||
window.location = "/your-outfits";
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
})
|
||||
}
|
||||
// We continue to show the loading spinner in the success case,
|
||||
// while we redirect away!
|
||||
isLoading={status === "pending" || status === "success"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -555,24 +555,24 @@ function DeleteOutfitMenuItem({ outfitState }) {
|
|||
* See react-transition-group docs for more info!
|
||||
*/
|
||||
const fadeOutAndRollUpTransition = (css) => ({
|
||||
classNames: css`
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
classNames: css`
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&-exit-active {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
`,
|
||||
timeout: 500,
|
||||
onExit: (e) => {
|
||||
e.style.height = e.offsetHeight + "px";
|
||||
},
|
||||
&-exit-active {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
`,
|
||||
timeout: 500,
|
||||
onExit: (e) => {
|
||||
e.style.height = e.offsetHeight + "px";
|
||||
},
|
||||
});
|
||||
|
||||
export default ItemsPanel;
|
||||
|
|
|
@ -1,92 +1,92 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
Box,
|
||||
Button,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalCloseButton,
|
||||
ModalContent,
|
||||
ModalHeader,
|
||||
ModalOverlay,
|
||||
Table,
|
||||
Tbody,
|
||||
Td,
|
||||
Th,
|
||||
Thead,
|
||||
Tr,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
function LayersInfoModal({ isOpen, onClose, visibleLayers }) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay>
|
||||
<ModalContent maxWidth="800px">
|
||||
<ModalHeader>Outfit layers</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<LayerTable layers={visibleLayers} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
);
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={onClose} size="xl">
|
||||
<ModalOverlay>
|
||||
<ModalContent maxWidth="800px">
|
||||
<ModalHeader>Outfit layers</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
<LayerTable layers={visibleLayers} />
|
||||
</ModalBody>
|
||||
</ModalContent>
|
||||
</ModalOverlay>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerTable({ layers }) {
|
||||
return (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Preview</Th>
|
||||
<Th>DTI ID</Th>
|
||||
<Th>Zone</Th>
|
||||
<Th>Links</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{layers.map((layer) => (
|
||||
<LayerTableRow key={layer.id} layer={layer} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
return (
|
||||
<Table>
|
||||
<Thead>
|
||||
<Tr>
|
||||
<Th>Preview</Th>
|
||||
<Th>DTI ID</Th>
|
||||
<Th>Zone</Th>
|
||||
<Th>Links</Th>
|
||||
</Tr>
|
||||
</Thead>
|
||||
<Tbody>
|
||||
{layers.map((layer) => (
|
||||
<LayerTableRow key={layer.id} layer={layer} />
|
||||
))}
|
||||
</Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
function LayerTableRow({ layer, ...props }) {
|
||||
return (
|
||||
<Tr {...props}>
|
||||
<Td>
|
||||
<Box
|
||||
as="img"
|
||||
src={layer.imageUrl}
|
||||
width="60px"
|
||||
height="60px"
|
||||
boxShadow="md"
|
||||
/>
|
||||
</Td>
|
||||
<Td>{layer.id}</Td>
|
||||
<Td>{layer.zone.label}</Td>
|
||||
<Td>
|
||||
<Box display="flex" gap=".5em">
|
||||
{layer.imageUrl && (
|
||||
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
|
||||
PNG
|
||||
</Button>
|
||||
)}
|
||||
{layer.swfUrl && (
|
||||
<Button as="a" href={layer.swfUrl} size="sm" download>
|
||||
SWF
|
||||
</Button>
|
||||
)}
|
||||
{layer.svgUrl && (
|
||||
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
|
||||
SVG
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
return (
|
||||
<Tr {...props}>
|
||||
<Td>
|
||||
<Box
|
||||
as="img"
|
||||
src={layer.imageUrl}
|
||||
width="60px"
|
||||
height="60px"
|
||||
boxShadow="md"
|
||||
/>
|
||||
</Td>
|
||||
<Td>{layer.id}</Td>
|
||||
<Td>{layer.zone.label}</Td>
|
||||
<Td>
|
||||
<Box display="flex" gap=".5em">
|
||||
{layer.imageUrl && (
|
||||
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
|
||||
PNG
|
||||
</Button>
|
||||
)}
|
||||
{layer.swfUrl && (
|
||||
<Button as="a" href={layer.swfUrl} size="sm" download>
|
||||
SWF
|
||||
</Button>
|
||||
)}
|
||||
{layer.svgUrl && (
|
||||
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
|
||||
SVG
|
||||
</Button>
|
||||
)}
|
||||
</Box>
|
||||
</Td>
|
||||
</Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default LayersInfoModal;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,310 +7,310 @@ import getVisibleLayers from "../components/getVisibleLayers";
|
|||
import { useLocalStorage } from "../util";
|
||||
|
||||
function OutfitKnownGlitchesBadge({ appearance }) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const { petAppearance, items } = appearance;
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const { petAppearance, items } = appearance;
|
||||
|
||||
const glitchMessages = [];
|
||||
const glitchMessages = [];
|
||||
|
||||
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
|
||||
// just mark Incompatible someday instead; or with correctly partially-hidden
|
||||
// art.
|
||||
//
|
||||
// NOTE: This particular glitch is checking for the *absence* of layers, so
|
||||
// we skip it if we're still loading!
|
||||
if (!appearance.loading) {
|
||||
for (const item of items) {
|
||||
// HACK: We use `getVisibleLayers` with just this pet appearance and item
|
||||
// appearance, to run the logic for which layers are compatible with
|
||||
// this pet. But `getVisibleLayers` does other things too, so it's
|
||||
// plausible that this could do not quite what we want in some cases!
|
||||
const allItemLayers = item.appearance.layers;
|
||||
const compatibleItemLayers = getVisibleLayers(petAppearance, [
|
||||
item.appearance,
|
||||
]).filter((l) => l.source === "item");
|
||||
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
|
||||
// just mark Incompatible someday instead; or with correctly partially-hidden
|
||||
// art.
|
||||
//
|
||||
// NOTE: This particular glitch is checking for the *absence* of layers, so
|
||||
// we skip it if we're still loading!
|
||||
if (!appearance.loading) {
|
||||
for (const item of items) {
|
||||
// HACK: We use `getVisibleLayers` with just this pet appearance and item
|
||||
// appearance, to run the logic for which layers are compatible with
|
||||
// this pet. But `getVisibleLayers` does other things too, so it's
|
||||
// plausible that this could do not quite what we want in some cases!
|
||||
const allItemLayers = item.appearance.layers;
|
||||
const compatibleItemLayers = getVisibleLayers(petAppearance, [
|
||||
item.appearance,
|
||||
]).filter((l) => l.source === "item");
|
||||
|
||||
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
|
||||
glitchMessages.push(
|
||||
<Box key={`total-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't actually compatible with this special pet.
|
||||
We're hiding the item art, which is outdated behavior, and we should
|
||||
instead be treating it as entirely incompatible. Fixing this is in
|
||||
our todo list, sorry for the confusing UI!
|
||||
</Box>,
|
||||
);
|
||||
} else if (compatibleItemLayers.length < allItemLayers.length) {
|
||||
glitchMessages.push(
|
||||
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i>'s compatibility with this pet is complicated, but
|
||||
we believe this is how it looks: some zones are visible, and some
|
||||
zones are hidden. If this isn't quite right, please email me at
|
||||
matchu@openneo.net and let me know!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
|
||||
glitchMessages.push(
|
||||
<Box key={`total-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't actually compatible with this special pet.
|
||||
We're hiding the item art, which is outdated behavior, and we should
|
||||
instead be treating it as entirely incompatible. Fixing this is in
|
||||
our todo list, sorry for the confusing UI!
|
||||
</Box>,
|
||||
);
|
||||
} else if (compatibleItemLayers.length < allItemLayers.length) {
|
||||
glitchMessages.push(
|
||||
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
|
||||
<i>{item.name}</i>'s compatibility with this pet is complicated, but
|
||||
we believe this is how it looks: some zones are visible, and some
|
||||
zones are hidden. If this isn't quite right, please email me at
|
||||
matchu@openneo.net and let me know!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
|
||||
);
|
||||
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
|
||||
(l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
|
||||
!layerUsesHTML5(l),
|
||||
);
|
||||
if (itemHasBrokenOnNeopetsDotCom) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
|
||||
{itemHasBrokenUnconvertedLayers ? (
|
||||
<>
|
||||
We're aware of a glitch affecting the art for <i>{item.name}</i>.
|
||||
Last time we checked, this glitch affected its appearance on
|
||||
Neopets.com, too. Hopefully this will be fixed once it's converted
|
||||
to HTML5!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
We're aware of a previous glitch affecting the art for{" "}
|
||||
<i>{item.name}</i>, but it might have been resolved during HTML5
|
||||
conversion. Please use the feedback form on the homepage to let us
|
||||
know if it looks right, or still looks wrong! Thank you!
|
||||
</>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
|
||||
);
|
||||
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
|
||||
(l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
|
||||
!layerUsesHTML5(l),
|
||||
);
|
||||
if (itemHasBrokenOnNeopetsDotCom) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
|
||||
{itemHasBrokenUnconvertedLayers ? (
|
||||
<>
|
||||
We're aware of a glitch affecting the art for <i>{item.name}</i>.
|
||||
Last time we checked, this glitch affected its appearance on
|
||||
Neopets.com, too. Hopefully this will be fixed once it's converted
|
||||
to HTML5!
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
We're aware of a previous glitch affecting the art for{" "}
|
||||
<i>{item.name}</i>, but it might have been resolved during HTML5
|
||||
conversion. Please use the feedback form on the homepage to let us
|
||||
know if it looks right, or still looks wrong! Thank you!
|
||||
</>
|
||||
)}
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i>, and we believe it
|
||||
looks this way on-site, too. But our version might be out of date! If
|
||||
you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i>, and we believe it
|
||||
looks this way on-site, too. But our version might be out of date! If
|
||||
you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
|
||||
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
|
||||
if (hiResMode) {
|
||||
for (const item of items) {
|
||||
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that prevents us
|
||||
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
|
||||
PNG, which might look a bit blurry on larger screens.
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
|
||||
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
|
||||
if (hiResMode) {
|
||||
for (const item of items) {
|
||||
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that prevents us
|
||||
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
|
||||
PNG, which might look a bit blurry on larger screens.
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes(
|
||||
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
|
||||
),
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that causes it to
|
||||
display incorrectly—but we're not sure if it's on our end, or TNT's.
|
||||
If you own this item, please email me at matchu@openneo.net to let us
|
||||
know how it looks in the on-site customizer!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const item of items) {
|
||||
const itemHasGlitch = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes(
|
||||
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
|
||||
),
|
||||
);
|
||||
if (itemHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
|
||||
There's a glitch in the art for <i>{item.name}</i> that causes it to
|
||||
display incorrectly—but we're not sure if it's on our end, or TNT's.
|
||||
If you own this item, please email me at matchu@openneo.net to let us
|
||||
know how it looks in the on-site customizer!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasOfficialBodyIdIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
|
||||
Last we checked, <i>{item.name}</i> actually is compatible with this
|
||||
pet, even though it seems like it shouldn't be. But TNT might change
|
||||
this at any time, so be careful!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
|
||||
for (const item of items) {
|
||||
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
|
||||
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
|
||||
);
|
||||
if (itemHasOfficialBodyIdIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
|
||||
Last we checked, <i>{item.name}</i> actually is compatible with this
|
||||
pet, even though it seems like it shouldn't be. But TNT might change
|
||||
this at any time, so be careful!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for Dyeworks items that aren't converted yet.
|
||||
for (const item of items) {
|
||||
const itemIsDyeworks = item.name.includes("Dyeworks");
|
||||
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
|
||||
// Look for Dyeworks items that aren't converted yet.
|
||||
for (const item of items) {
|
||||
const itemIsDyeworks = item.name.includes("Dyeworks");
|
||||
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
|
||||
|
||||
if (itemIsDyeworks && !itemIsConverted) {
|
||||
glitchMessages.push(
|
||||
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
|
||||
code often shows old Dyeworks items in the wrong color. Once it's
|
||||
converted, we'll display it correctly!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (itemIsDyeworks && !itemIsConverted) {
|
||||
glitchMessages.push(
|
||||
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
|
||||
code often shows old Dyeworks items in the wrong color. Once it's
|
||||
converted, we'll display it correctly!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for Baby Body Paint items.
|
||||
for (const item of items) {
|
||||
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
|
||||
if (itemIsBabyBodyPaint) {
|
||||
glitchMessages.push(
|
||||
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> seems to have new zone restriction rules that our
|
||||
system doesn't support yet, whuh oh! This might require major changes
|
||||
to how we handle zones. Until then, this item will be very buggy,
|
||||
sorry!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for Baby Body Paint items.
|
||||
for (const item of items) {
|
||||
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
|
||||
if (itemIsBabyBodyPaint) {
|
||||
glitchMessages.push(
|
||||
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
|
||||
<i>{item.name}</i> seems to have new zone restriction rules that our
|
||||
system doesn't support yet, whuh oh! This might require major changes
|
||||
to how we handle zones. Until then, this item will be very buggy,
|
||||
sorry!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
|
||||
if (petAppearance?.color?.id === "38") {
|
||||
glitchMessages.push(
|
||||
<Box key={`invisible-pet-warning`}>
|
||||
Invisible pets are affected by a number of glitches, including faces
|
||||
sometimes being visible on-site, and errors in the HTML5 conversion. If
|
||||
this pose looks incorrect, you can try another by clicking the emoji
|
||||
face next to the species/color picker. But be aware that Neopets.com
|
||||
might look different!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
|
||||
if (petAppearance?.color?.id === "38") {
|
||||
glitchMessages.push(
|
||||
<Box key={`invisible-pet-warning`}>
|
||||
Invisible pets are affected by a number of glitches, including faces
|
||||
sometimes being visible on-site, and errors in the HTML5 conversion. If
|
||||
this pose looks incorrect, you can try another by clicking the emoji
|
||||
face next to the species/color picker. But be aware that Neopets.com
|
||||
might look different!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
|
||||
if (
|
||||
petAppearance?.color?.id === "26" &&
|
||||
petAppearance?.species?.id === "49"
|
||||
) {
|
||||
glitchMessages.push(
|
||||
<Box key={`faerie-uni-dithering-horn-warning`}>
|
||||
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
|
||||
sometimes yellow. To help you design for both cases, we show the blue
|
||||
horn with the feminine design, and the yellow horn with the masculine
|
||||
design—but the pet's gender does not actually affect which horn you'll
|
||||
get, and it will often change over time!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
|
||||
if (
|
||||
petAppearance?.color?.id === "26" &&
|
||||
petAppearance?.species?.id === "49"
|
||||
) {
|
||||
glitchMessages.push(
|
||||
<Box key={`faerie-uni-dithering-horn-warning`}>
|
||||
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
|
||||
sometimes yellow. To help you design for both cases, we show the blue
|
||||
horn with the feminine design, and the yellow horn with the masculine
|
||||
design—but the pet's gender does not actually affect which horn you'll
|
||||
get, and it will often change over time!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
// Check whether the pet appearance is marked as Glitched.
|
||||
if (petAppearance?.isGlitched) {
|
||||
glitchMessages.push(
|
||||
// NOTE: This message assumes that the current pet appearance is the
|
||||
// best canonical one, but it's _possible_ to view Glitched
|
||||
// appearances even if we _do_ have a better one saved... but
|
||||
// only the Support UI ever takes you there.
|
||||
<Box key={`pet-appearance-is-glitched`}>
|
||||
We know that the art for this pet is incorrect, but we still haven't
|
||||
seen a <em>correct</em> model for this pose yet. Once someone models the
|
||||
correct data, we'll use that instead. For now, you could also try
|
||||
switching to another pose, by clicking the emoji face next to the
|
||||
species/color picker!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
// Check whether the pet appearance is marked as Glitched.
|
||||
if (petAppearance?.isGlitched) {
|
||||
glitchMessages.push(
|
||||
// NOTE: This message assumes that the current pet appearance is the
|
||||
// best canonical one, but it's _possible_ to view Glitched
|
||||
// appearances even if we _do_ have a better one saved... but
|
||||
// only the Support UI ever takes you there.
|
||||
<Box key={`pet-appearance-is-glitched`}>
|
||||
We know that the art for this pet is incorrect, but we still haven't
|
||||
seen a <em>correct</em> model for this pose yet. Once someone models the
|
||||
correct data, we'll use that instead. For now, you could also try
|
||||
switching to another pose, by clicking the emoji face next to the
|
||||
species/color picker!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
|
||||
const petLayers = petAppearance?.layers || [];
|
||||
const petLayers = petAppearance?.layers || [];
|
||||
|
||||
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"OFFICIAL_SWF_IS_INCORRECT",
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
We're aware of a glitch affecting the art for this pet's{" "}
|
||||
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
|
||||
affected its appearance on Neopets.com, too. But our version might be
|
||||
out of date! If you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"OFFICIAL_SWF_IS_INCORRECT",
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
We're aware of a glitch affecting the art for this pet's{" "}
|
||||
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
|
||||
affected its appearance on Neopets.com, too. But our version might be
|
||||
out of date! If you've seen it look better on-site, please email me at
|
||||
matchu@openneo.net so we can fix it!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
|
||||
if (hiResMode) {
|
||||
for (const layer of petLayers) {
|
||||
const layerHasOfficialSvgIsIncorrect = (
|
||||
layer.knownGlitches || []
|
||||
).includes("OFFICIAL_SVG_IS_INCORRECT");
|
||||
if (layerHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that prevents us from showing the SVG image for Hi-Res Mode.
|
||||
Instead, we're showing a PNG, which might look a bit blurry on
|
||||
larger screens.
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
|
||||
if (hiResMode) {
|
||||
for (const layer of petLayers) {
|
||||
const layerHasOfficialSvgIsIncorrect = (
|
||||
layer.knownGlitches || []
|
||||
).includes("OFFICIAL_SVG_IS_INCORRECT");
|
||||
if (layerHasOfficialSvgIsIncorrect) {
|
||||
glitchMessages.push(
|
||||
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that prevents us from showing the SVG image for Hi-Res Mode.
|
||||
Instead, we're showing a PNG, which might look a bit blurry on
|
||||
larger screens.
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box
|
||||
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
|
||||
>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that causes it to display incorrectly—but we're not sure if it's
|
||||
on our end, or TNT's. If you have this pet, please email me at
|
||||
matchu@openneo.net to let us know how it looks in the on-site
|
||||
customizer!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
|
||||
for (const layer of petLayers) {
|
||||
const layerHasGlitch = (layer.knownGlitches || []).includes(
|
||||
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
|
||||
);
|
||||
if (layerHasGlitch) {
|
||||
glitchMessages.push(
|
||||
<Box
|
||||
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
|
||||
>
|
||||
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
|
||||
zone that causes it to display incorrectly—but we're not sure if it's
|
||||
on our end, or TNT's. If you have this pet, please email me at
|
||||
matchu@openneo.net to let us know how it looks in the on-site
|
||||
customizer!
|
||||
</Box>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (glitchMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (glitchMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
aria-label="Has known glitches"
|
||||
tooltipLabel={
|
||||
<Box>
|
||||
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
|
||||
Known glitches
|
||||
</Box>
|
||||
<VStack spacing="1em">{glitchMessages}</VStack>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<FaBug />
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
aria-label="Has known glitches"
|
||||
tooltipLabel={
|
||||
<Box>
|
||||
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
|
||||
Known glitches
|
||||
</Box>
|
||||
<VStack spacing="1em">{glitchMessages}</VStack>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<FaBug />
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitKnownGlitchesBadge;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -11,70 +11,70 @@ import { useSearchResults } from "./useSearchResults";
|
|||
* while still keeping the rest of the item screen open!
|
||||
*/
|
||||
function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
|
||||
const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
const [canUseSearchFooter, setCanUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
|
||||
const { items, numTotalPages } = useSearchResults(
|
||||
searchQuery,
|
||||
outfitState,
|
||||
1,
|
||||
);
|
||||
const { items, numTotalPages } = useSearchResults(
|
||||
searchQuery,
|
||||
outfitState,
|
||||
1,
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (window.location.search.includes("feature-flag-can-use-search-footer")) {
|
||||
setCanUseSearchFooter(true);
|
||||
}
|
||||
}, [setCanUseSearchFooter]);
|
||||
React.useEffect(() => {
|
||||
if (window.location.search.includes("feature-flag-can-use-search-footer")) {
|
||||
setCanUseSearchFooter(true);
|
||||
}
|
||||
}, [setCanUseSearchFooter]);
|
||||
|
||||
// TODO: Show the new footer to other users, too!
|
||||
if (!canUseSearchFooter) {
|
||||
return null;
|
||||
}
|
||||
// TODO: Show the new footer to other users, too!
|
||||
if (!canUseSearchFooter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Box>
|
||||
<Box paddingX="4" paddingY="4">
|
||||
<Flex as="label" align="center">
|
||||
<Box fontWeight="600" flex="0 0 auto">
|
||||
Add new items:
|
||||
</Box>
|
||||
<Box width="8" />
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
onChange={onChangeSearchQuery}
|
||||
flex="0 1 100%"
|
||||
suggestionsPlacement="top"
|
||||
/>
|
||||
<Box width="8" />
|
||||
{numTotalPages != null && (
|
||||
<Box flex="0 0 auto">
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={1}
|
||||
goToPageNumber={() => alert("TODO")}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box maxHeight="32" overflow="auto">
|
||||
<Box as="ul" listStyleType="disc" paddingLeft="8">
|
||||
{items.map((item) => (
|
||||
<Box key={item.id} as="li">
|
||||
{item.name}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Box>
|
||||
<Box paddingX="4" paddingY="4">
|
||||
<Flex as="label" align="center">
|
||||
<Box fontWeight="600" flex="0 0 auto">
|
||||
Add new items:
|
||||
</Box>
|
||||
<Box width="8" />
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
onChange={onChangeSearchQuery}
|
||||
flex="0 1 100%"
|
||||
suggestionsPlacement="top"
|
||||
/>
|
||||
<Box width="8" />
|
||||
{numTotalPages != null && (
|
||||
<Box flex="0 0 auto">
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={1}
|
||||
goToPageNumber={() => alert("TODO")}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box maxHeight="32" overflow="auto">
|
||||
<Box as="ul" listStyleType="disc" paddingLeft="8">
|
||||
{items.map((item) => (
|
||||
<Box key={item.id} as="li">
|
||||
{item.name}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchFooter;
|
||||
|
|
|
@ -16,54 +16,54 @@ export const SEARCH_PER_PAGE = 30;
|
|||
* keyboard and focus interactions.
|
||||
*/
|
||||
function SearchPanel({
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
scrollContainerRef,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef,
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
scrollContainerRef,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef,
|
||||
}) {
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
const scrollToTop = React.useCallback(() => {
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
}, [scrollContainerRef]);
|
||||
|
||||
// Sometimes we want to give focus back to the search field!
|
||||
const onMoveFocusUpToQuery = (e) => {
|
||||
if (searchQueryRef.current) {
|
||||
searchQueryRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
// Sometimes we want to give focus back to the search field!
|
||||
const onMoveFocusUpToQuery = (e) => {
|
||||
if (searchQueryRef.current) {
|
||||
searchQueryRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box
|
||||
onKeyDown={(e) => {
|
||||
// This will catch any Escape presses when the user's focus is inside
|
||||
// the SearchPanel.
|
||||
if (e.key === "Escape") {
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchResults
|
||||
// When the query changes, replace the SearchResults component with a
|
||||
// new instance. This resets both `currentPageNumber`, to take us back
|
||||
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
|
||||
// item you like in one search, then immediately do a second search and
|
||||
// try a conflicting item, we'll restore the item you liked from your
|
||||
// first search!
|
||||
key={serializeQuery(query)}
|
||||
query={query}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
scrollToTop={scrollToTop}
|
||||
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
onKeyDown={(e) => {
|
||||
// This will catch any Escape presses when the user's focus is inside
|
||||
// the SearchPanel.
|
||||
if (e.key === "Escape") {
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SearchResults
|
||||
// When the query changes, replace the SearchResults component with a
|
||||
// new instance. This resets both `currentPageNumber`, to take us back
|
||||
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
|
||||
// item you like in one search, then immediately do a second search and
|
||||
// try a conflicting item, we'll restore the item you liked from your
|
||||
// first search!
|
||||
key={serializeQuery(query)}
|
||||
query={query}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
scrollToTop={scrollToTop}
|
||||
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -75,191 +75,191 @@ function SearchPanel({
|
|||
* the list screen-reader- and keyboard-accessible!
|
||||
*/
|
||||
function SearchResults({
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
firstSearchResultRef,
|
||||
scrollToTop,
|
||||
onMoveFocusUpToQuery,
|
||||
query,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
firstSearchResultRef,
|
||||
scrollToTop,
|
||||
onMoveFocusUpToQuery,
|
||||
}) {
|
||||
const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
|
||||
const { loading, error, items, numTotalPages } = useSearchResults(
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber,
|
||||
);
|
||||
const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
|
||||
const { loading, error, items, numTotalPages } = useSearchResults(
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber,
|
||||
);
|
||||
|
||||
// Preload the previous and next page of search results, with this quick
|
||||
// ~hacky trick: just `useSearchResults` two more times, with some extra
|
||||
// attention to skip the query when we don't know if it will exist!
|
||||
useSearchResults(query, outfitState, currentPageNumber - 1, {
|
||||
skip: currentPageNumber <= 1,
|
||||
});
|
||||
useSearchResults(query, outfitState, currentPageNumber + 1, {
|
||||
skip: numTotalPages == null || currentPageNumber >= numTotalPages,
|
||||
});
|
||||
// Preload the previous and next page of search results, with this quick
|
||||
// ~hacky trick: just `useSearchResults` two more times, with some extra
|
||||
// attention to skip the query when we don't know if it will exist!
|
||||
useSearchResults(query, outfitState, currentPageNumber - 1, {
|
||||
skip: currentPageNumber <= 1,
|
||||
});
|
||||
useSearchResults(query, outfitState, currentPageNumber + 1, {
|
||||
skip: numTotalPages == null || currentPageNumber >= numTotalPages,
|
||||
});
|
||||
|
||||
// This will save the `wornItemIds` when the SearchResults first mounts, and
|
||||
// keep it saved even after the outfit changes. We use this to try to restore
|
||||
// these items after the user makes changes, e.g., after they try on another
|
||||
// Background we want to restore the previous one!
|
||||
const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
|
||||
// This will save the `wornItemIds` when the SearchResults first mounts, and
|
||||
// keep it saved even after the outfit changes. We use this to try to restore
|
||||
// these items after the user makes changes, e.g., after they try on another
|
||||
// Background we want to restore the previous one!
|
||||
const [itemIdsToReconsider] = React.useState(outfitState.wornItemIds);
|
||||
|
||||
// Whenever the page number changes, scroll back to the top!
|
||||
React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
|
||||
// Whenever the page number changes, scroll back to the top!
|
||||
React.useEffect(() => scrollToTop(), [currentPageNumber, scrollToTop]);
|
||||
|
||||
// You can use UpArrow/DownArrow to navigate between items, and even back up
|
||||
// to the search field!
|
||||
const goToPrevItem = React.useCallback(
|
||||
(e) => {
|
||||
const prevLabel = e.target.closest("label").previousSibling;
|
||||
if (prevLabel) {
|
||||
prevLabel.querySelector("input[type=checkbox]").focus();
|
||||
prevLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// If we're at the top of the list, move back up to the search box!
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
},
|
||||
[onMoveFocusUpToQuery],
|
||||
);
|
||||
const goToNextItem = React.useCallback((e) => {
|
||||
const nextLabel = e.target.closest("label").nextSibling;
|
||||
if (nextLabel) {
|
||||
nextLabel.querySelector("input[type=checkbox]").focus();
|
||||
nextLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
// You can use UpArrow/DownArrow to navigate between items, and even back up
|
||||
// to the search field!
|
||||
const goToPrevItem = React.useCallback(
|
||||
(e) => {
|
||||
const prevLabel = e.target.closest("label").previousSibling;
|
||||
if (prevLabel) {
|
||||
prevLabel.querySelector("input[type=checkbox]").focus();
|
||||
prevLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
} else {
|
||||
// If we're at the top of the list, move back up to the search box!
|
||||
onMoveFocusUpToQuery(e);
|
||||
}
|
||||
},
|
||||
[onMoveFocusUpToQuery],
|
||||
);
|
||||
const goToNextItem = React.useCallback((e) => {
|
||||
const nextLabel = e.target.closest("label").nextSibling;
|
||||
if (nextLabel) {
|
||||
nextLabel.querySelector("input[type=checkbox]").focus();
|
||||
nextLabel.scrollIntoView({ block: "center" });
|
||||
e.preventDefault();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const searchPanelBackground = useColorModeValue("white", "gray.900");
|
||||
const searchPanelBackground = useColorModeValue("white", "gray.900");
|
||||
|
||||
if (error) {
|
||||
return <MajorErrorMessage error={error} variant="network" />;
|
||||
}
|
||||
if (error) {
|
||||
return <MajorErrorMessage error={error} variant="network" />;
|
||||
}
|
||||
|
||||
// Finally, render the item list, with checkboxes and Item components!
|
||||
// We also render some extra skeleton items at the bottom during infinite
|
||||
// scroll loading.
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
position="sticky"
|
||||
top="0"
|
||||
background={searchPanelBackground}
|
||||
zIndex="2"
|
||||
paddingX="5"
|
||||
paddingBottom="2"
|
||||
paddingTop="1"
|
||||
>
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={currentPageNumber}
|
||||
goToPageNumber={setCurrentPageNumber}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
<ItemListContainer paddingX="4" paddingBottom="2">
|
||||
{items.map((item, index) => (
|
||||
<SearchResultItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
itemIdsToReconsider={itemIdsToReconsider}
|
||||
isWorn={outfitState.wornItemIds.includes(item.id)}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
checkboxRef={index === 0 ? firstSearchResultRef : null}
|
||||
goToPrevItem={goToPrevItem}
|
||||
goToNextItem={goToNextItem}
|
||||
/>
|
||||
))}
|
||||
</ItemListContainer>
|
||||
{loading && (
|
||||
<ItemListSkeleton
|
||||
count={SEARCH_PER_PAGE}
|
||||
paddingX="4"
|
||||
paddingBottom="2"
|
||||
/>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<Text paddingX="4">
|
||||
We couldn't find any matching items{" "}
|
||||
<span role="img" aria-label="(thinking emoji)">
|
||||
🤔
|
||||
</span>{" "}
|
||||
Try again?
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
// Finally, render the item list, with checkboxes and Item components!
|
||||
// We also render some extra skeleton items at the bottom during infinite
|
||||
// scroll loading.
|
||||
return (
|
||||
<Box>
|
||||
<Box
|
||||
position="sticky"
|
||||
top="0"
|
||||
background={searchPanelBackground}
|
||||
zIndex="2"
|
||||
paddingX="5"
|
||||
paddingBottom="2"
|
||||
paddingTop="1"
|
||||
>
|
||||
<PaginationToolbar
|
||||
numTotalPages={numTotalPages}
|
||||
currentPageNumber={currentPageNumber}
|
||||
goToPageNumber={setCurrentPageNumber}
|
||||
buildPageUrl={() => null}
|
||||
size="sm"
|
||||
/>
|
||||
</Box>
|
||||
<ItemListContainer paddingX="4" paddingBottom="2">
|
||||
{items.map((item, index) => (
|
||||
<SearchResultItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
itemIdsToReconsider={itemIdsToReconsider}
|
||||
isWorn={outfitState.wornItemIds.includes(item.id)}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
checkboxRef={index === 0 ? firstSearchResultRef : null}
|
||||
goToPrevItem={goToPrevItem}
|
||||
goToNextItem={goToNextItem}
|
||||
/>
|
||||
))}
|
||||
</ItemListContainer>
|
||||
{loading && (
|
||||
<ItemListSkeleton
|
||||
count={SEARCH_PER_PAGE}
|
||||
paddingX="4"
|
||||
paddingBottom="2"
|
||||
/>
|
||||
)}
|
||||
{!loading && items.length === 0 && (
|
||||
<Text paddingX="4">
|
||||
We couldn't find any matching items{" "}
|
||||
<span role="img" aria-label="(thinking emoji)">
|
||||
🤔
|
||||
</span>{" "}
|
||||
Try again?
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchResultItem({
|
||||
item,
|
||||
itemIdsToReconsider,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
dispatchToOutfit,
|
||||
checkboxRef,
|
||||
goToPrevItem,
|
||||
goToNextItem,
|
||||
item,
|
||||
itemIdsToReconsider,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
dispatchToOutfit,
|
||||
checkboxRef,
|
||||
goToPrevItem,
|
||||
goToNextItem,
|
||||
}) {
|
||||
// It's important to use `useCallback` for `onRemove`, to avoid re-rendering
|
||||
// the whole list of <Item>s!
|
||||
const onRemove = React.useCallback(
|
||||
() =>
|
||||
dispatchToOutfit({
|
||||
type: "removeItem",
|
||||
itemId: item.id,
|
||||
itemIdsToReconsider,
|
||||
}),
|
||||
[item.id, itemIdsToReconsider, dispatchToOutfit],
|
||||
);
|
||||
// It's important to use `useCallback` for `onRemove`, to avoid re-rendering
|
||||
// the whole list of <Item>s!
|
||||
const onRemove = React.useCallback(
|
||||
() =>
|
||||
dispatchToOutfit({
|
||||
type: "removeItem",
|
||||
itemId: item.id,
|
||||
itemIdsToReconsider,
|
||||
}),
|
||||
[item.id, itemIdsToReconsider, dispatchToOutfit],
|
||||
);
|
||||
|
||||
return (
|
||||
// We're wrapping the control inside the label, which works just fine!
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="checkbox"
|
||||
aria-label={`Wear "${item.name}"`}
|
||||
value={item.id}
|
||||
checked={isWorn}
|
||||
ref={checkboxRef}
|
||||
onChange={(e) => {
|
||||
const itemId = e.target.value;
|
||||
const willBeWorn = e.target.checked;
|
||||
if (willBeWorn) {
|
||||
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
|
||||
} else {
|
||||
dispatchToOutfit({
|
||||
type: "unwearItem",
|
||||
itemId,
|
||||
itemIdsToReconsider,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.target.click();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
goToPrevItem(e);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
goToNextItem(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Item
|
||||
item={item}
|
||||
isWorn={isWorn}
|
||||
isInOutfit={isInOutfit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
// We're wrapping the control inside the label, which works just fine!
|
||||
// eslint-disable-next-line jsx-a11y/label-has-associated-control
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="checkbox"
|
||||
aria-label={`Wear "${item.name}"`}
|
||||
value={item.id}
|
||||
checked={isWorn}
|
||||
ref={checkboxRef}
|
||||
onChange={(e) => {
|
||||
const itemId = e.target.value;
|
||||
const willBeWorn = e.target.checked;
|
||||
if (willBeWorn) {
|
||||
dispatchToOutfit({ type: "wearItem", itemId, itemIdsToReconsider });
|
||||
} else {
|
||||
dispatchToOutfit({
|
||||
type: "unwearItem",
|
||||
itemId,
|
||||
itemIdsToReconsider,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.target.click();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
goToPrevItem(e);
|
||||
} else if (e.key === "ArrowDown") {
|
||||
goToNextItem(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Item
|
||||
item={item}
|
||||
isWorn={isWorn}
|
||||
isInOutfit={isInOutfit}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -267,12 +267,12 @@ function SearchResultItem({
|
|||
* JS comparison.
|
||||
*/
|
||||
function serializeQuery(query) {
|
||||
return `${JSON.stringify([
|
||||
query.value,
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
])}`;
|
||||
return `${JSON.stringify([
|
||||
query.value,
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
])}`;
|
||||
}
|
||||
|
||||
export default SearchPanel;
|
||||
|
|
|
@ -2,21 +2,21 @@ import React from "react";
|
|||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
Box,
|
||||
IconButton,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputLeftAddon,
|
||||
InputLeftElement,
|
||||
InputRightElement,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CloseIcon,
|
||||
SearchIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
CloseIcon,
|
||||
SearchIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import Autosuggest from "react-autosuggest";
|
||||
|
@ -25,25 +25,25 @@ import useCurrentUser from "../components/useCurrentUser";
|
|||
import { logAndCapture } from "../util";
|
||||
|
||||
export const emptySearchQuery = {
|
||||
value: "",
|
||||
filterToZoneLabel: null,
|
||||
filterToItemKind: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
value: "",
|
||||
filterToZoneLabel: null,
|
||||
filterToItemKind: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
};
|
||||
|
||||
export function searchQueryIsEmpty(query) {
|
||||
return Object.values(query).every((value) => !value);
|
||||
return Object.values(query).every((value) => !value);
|
||||
}
|
||||
|
||||
const SUGGESTIONS_PLACEMENT_PROPS = {
|
||||
inline: {
|
||||
borderBottomRadius: "md",
|
||||
},
|
||||
top: {
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
borderTopRadius: "md",
|
||||
},
|
||||
inline: {
|
||||
borderBottomRadius: "md",
|
||||
},
|
||||
top: {
|
||||
position: "absolute",
|
||||
bottom: "100%",
|
||||
borderTopRadius: "md",
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -56,387 +56,387 @@ const SUGGESTIONS_PLACEMENT_PROPS = {
|
|||
* from anywhere, or UpArrow from the first result!)
|
||||
*/
|
||||
function SearchToolbar({
|
||||
query,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef = null,
|
||||
onChange,
|
||||
autoFocus,
|
||||
showItemsLabel = false,
|
||||
background = null,
|
||||
boxShadow = null,
|
||||
suggestionsPlacement = "inline",
|
||||
...props
|
||||
query,
|
||||
searchQueryRef,
|
||||
firstSearchResultRef = null,
|
||||
onChange,
|
||||
autoFocus,
|
||||
showItemsLabel = false,
|
||||
background = null,
|
||||
boxShadow = null,
|
||||
suggestionsPlacement = "inline",
|
||||
...props
|
||||
}) {
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
|
||||
const { isLoggedIn } = useCurrentUser();
|
||||
const [suggestions, setSuggestions] = React.useState([]);
|
||||
const [advancedSearchIsOpen, setAdvancedSearchIsOpen] = React.useState(false);
|
||||
const { isLoggedIn } = useCurrentUser();
|
||||
|
||||
// NOTE: This query should always load ~instantly, from the client cache.
|
||||
const { data } = useQuery(gql`
|
||||
query SearchToolbarZones {
|
||||
allZones {
|
||||
id
|
||||
label
|
||||
depth
|
||||
isCommonlyUsedByItems
|
||||
}
|
||||
}
|
||||
`);
|
||||
const zones = data?.allZones || [];
|
||||
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
|
||||
// NOTE: This query should always load ~instantly, from the client cache.
|
||||
const { data } = useQuery(gql`
|
||||
query SearchToolbarZones {
|
||||
allZones {
|
||||
id
|
||||
label
|
||||
depth
|
||||
isCommonlyUsedByItems
|
||||
}
|
||||
}
|
||||
`);
|
||||
const zones = data?.allZones || [];
|
||||
const itemZones = zones.filter((z) => z.isCommonlyUsedByItems);
|
||||
|
||||
let zoneLabels = itemZones.map((z) => z.label);
|
||||
zoneLabels = [...new Set(zoneLabels)];
|
||||
zoneLabels.sort();
|
||||
let zoneLabels = itemZones.map((z) => z.label);
|
||||
zoneLabels = [...new Set(zoneLabels)];
|
||||
zoneLabels.sort();
|
||||
|
||||
const onMoveFocusDownToResults = (e) => {
|
||||
if (firstSearchResultRef && firstSearchResultRef.current) {
|
||||
firstSearchResultRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
const onMoveFocusDownToResults = (e) => {
|
||||
if (firstSearchResultRef && firstSearchResultRef.current) {
|
||||
firstSearchResultRef.current.focus();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
|
||||
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
|
||||
const suggestionBgColor = useColorModeValue("white", "whiteAlpha.100");
|
||||
const highlightedBgColor = useColorModeValue("gray.100", "whiteAlpha.300");
|
||||
|
||||
const renderSuggestion = React.useCallback(
|
||||
({ text }, { isHighlighted }) => (
|
||||
<Box
|
||||
fontWeight={isHighlighted ? "bold" : "normal"}
|
||||
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
|
||||
padding="2"
|
||||
paddingLeft="2.5rem"
|
||||
fontSize="sm"
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
),
|
||||
[suggestionBgColor, highlightedBgColor],
|
||||
);
|
||||
const renderSuggestion = React.useCallback(
|
||||
({ text }, { isHighlighted }) => (
|
||||
<Box
|
||||
fontWeight={isHighlighted ? "bold" : "normal"}
|
||||
background={isHighlighted ? highlightedBgColor : suggestionBgColor}
|
||||
padding="2"
|
||||
paddingLeft="2.5rem"
|
||||
fontSize="sm"
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
),
|
||||
[suggestionBgColor, highlightedBgColor],
|
||||
);
|
||||
|
||||
const renderSuggestionsContainer = React.useCallback(
|
||||
({ containerProps, children }) => {
|
||||
const { className, ...otherContainerProps } = containerProps;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
{...otherContainerProps}
|
||||
boxShadow="md"
|
||||
overflow="auto"
|
||||
transition="all 0.4s"
|
||||
maxHeight="48"
|
||||
width="100%"
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
`,
|
||||
)}
|
||||
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
|
||||
>
|
||||
{children}
|
||||
{!children && advancedSearchIsOpen && (
|
||||
<Box
|
||||
padding="4"
|
||||
fontSize="sm"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
>
|
||||
No more filters available!
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
},
|
||||
[advancedSearchIsOpen, suggestionsPlacement],
|
||||
);
|
||||
const renderSuggestionsContainer = React.useCallback(
|
||||
({ containerProps, children }) => {
|
||||
const { className, ...otherContainerProps } = containerProps;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
{...otherContainerProps}
|
||||
boxShadow="md"
|
||||
overflow="auto"
|
||||
transition="all 0.4s"
|
||||
maxHeight="48"
|
||||
width="100%"
|
||||
className={cx(
|
||||
className,
|
||||
css`
|
||||
li {
|
||||
list-style: none;
|
||||
}
|
||||
`,
|
||||
)}
|
||||
{...SUGGESTIONS_PLACEMENT_PROPS[suggestionsPlacement]}
|
||||
>
|
||||
{children}
|
||||
{!children && advancedSearchIsOpen && (
|
||||
<Box
|
||||
padding="4"
|
||||
fontSize="sm"
|
||||
fontStyle="italic"
|
||||
textAlign="center"
|
||||
>
|
||||
No more filters available!
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
},
|
||||
[advancedSearchIsOpen, suggestionsPlacement],
|
||||
);
|
||||
|
||||
// When we change the query filters, clear out the suggestions.
|
||||
React.useEffect(() => {
|
||||
setSuggestions([]);
|
||||
}, [
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
]);
|
||||
// When we change the query filters, clear out the suggestions.
|
||||
React.useEffect(() => {
|
||||
setSuggestions([]);
|
||||
}, [
|
||||
query.filterToItemKind,
|
||||
query.filterToZoneLabel,
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
]);
|
||||
|
||||
let queryFilterText = getQueryFilterText(query);
|
||||
if (showItemsLabel) {
|
||||
queryFilterText = queryFilterText ? (
|
||||
<>
|
||||
<Box as="span" fontWeight="600">
|
||||
Items:
|
||||
</Box>{" "}
|
||||
{queryFilterText}
|
||||
</>
|
||||
) : (
|
||||
<Box as="span" fontWeight="600">
|
||||
Items
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
let queryFilterText = getQueryFilterText(query);
|
||||
if (showItemsLabel) {
|
||||
queryFilterText = queryFilterText ? (
|
||||
<>
|
||||
<Box as="span" fontWeight="600">
|
||||
Items:
|
||||
</Box>{" "}
|
||||
{queryFilterText}
|
||||
</>
|
||||
) : (
|
||||
<Box as="span" fontWeight="600">
|
||||
Items
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
|
||||
showAll: true,
|
||||
});
|
||||
const allSuggestions = getSuggestions(null, query, zoneLabels, isLoggedIn, {
|
||||
showAll: true,
|
||||
});
|
||||
|
||||
// Once you remove the final suggestion available, close Advanced Search. We
|
||||
// have placeholder text available, sure, but this feels more natural!
|
||||
React.useEffect(() => {
|
||||
if (allSuggestions.length === 0) {
|
||||
setAdvancedSearchIsOpen(false);
|
||||
}
|
||||
}, [allSuggestions.length]);
|
||||
// Once you remove the final suggestion available, close Advanced Search. We
|
||||
// have placeholder text available, sure, but this feels more natural!
|
||||
React.useEffect(() => {
|
||||
if (allSuggestions.length === 0) {
|
||||
setAdvancedSearchIsOpen(false);
|
||||
}
|
||||
}, [allSuggestions.length]);
|
||||
|
||||
const focusBorderColor = useColorModeValue("green.600", "green.400");
|
||||
const focusBorderColor = useColorModeValue("green.600", "green.400");
|
||||
|
||||
return (
|
||||
<Box position="relative" {...props}>
|
||||
<Autosuggest
|
||||
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
|
||||
onSuggestionsFetchRequested={({ value }) => {
|
||||
// HACK: I'm not sure why, but apparently this gets called with value
|
||||
// set to the _chosen suggestion_ after choosing it? Has that
|
||||
// always happened? Idk? Let's just, gate around it, I guess?
|
||||
if (typeof value === "string") {
|
||||
setSuggestions(
|
||||
getSuggestions(value, query, zoneLabels, isLoggedIn),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSuggestionSelected={(e, { suggestion }) => {
|
||||
onChange({
|
||||
...query,
|
||||
// If the suggestion was from typing, remove the last word of the
|
||||
// query value. Or, if it was from Advanced Search, leave it alone!
|
||||
value: advancedSearchIsOpen
|
||||
? query.value
|
||||
: removeLastWord(query.value),
|
||||
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
|
||||
filterToItemKind: suggestion.itemKind || query.filterToItemKind,
|
||||
filterToCurrentUserOwnsOrWants:
|
||||
suggestion.userOwnsOrWants ||
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
});
|
||||
}}
|
||||
getSuggestionValue={(zl) => zl}
|
||||
alwaysRenderSuggestions={true}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderInputComponent={(inputProps) => (
|
||||
<InputGroup boxShadow={boxShadow} borderRadius="md">
|
||||
{queryFilterText ? (
|
||||
<InputLeftAddon>
|
||||
<SearchIcon color="gray.400" marginRight="3" />
|
||||
<Box fontSize="sm">{queryFilterText}</Box>
|
||||
</InputLeftAddon>
|
||||
) : (
|
||||
<InputLeftElement>
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
)}
|
||||
<Input
|
||||
background={background}
|
||||
// TODO: How to improve a11y here?
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
{...inputProps}
|
||||
/>
|
||||
<InputRightElement
|
||||
width="auto"
|
||||
justifyContent="flex-end"
|
||||
paddingRight="2px"
|
||||
paddingY="2px"
|
||||
>
|
||||
{!searchQueryIsEmpty(query) && (
|
||||
<Tooltip label="Clear">
|
||||
<IconButton
|
||||
icon={<CloseIcon fontSize="0.6em" />}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
marginLeft="1"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSuggestions([]);
|
||||
onChange(emptySearchQuery);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Advanced search">
|
||||
<IconButton
|
||||
icon={
|
||||
advancedSearchIsOpen ? (
|
||||
<ChevronUpIcon fontSize="1.5em" />
|
||||
) : (
|
||||
<ChevronDownIcon fontSize="1.5em" />
|
||||
)
|
||||
}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
aria-label="Open advanced search"
|
||||
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)}
|
||||
inputProps={{
|
||||
placeholder: "Search all items…",
|
||||
focusBorderColor: focusBorderColor,
|
||||
value: query.value || "",
|
||||
ref: searchQueryRef,
|
||||
minWidth: 0,
|
||||
"data-test-id": "item-search-input",
|
||||
onChange: (e, { newValue, method }) => {
|
||||
// The Autosuggest tries to change the _entire_ value of the element
|
||||
// when navigating suggestions, which isn't actually what we want.
|
||||
// Only accept value changes that are typed by the user!
|
||||
if (method === "type") {
|
||||
onChange({ ...query, value: newValue });
|
||||
}
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
onChange(emptySearchQuery);
|
||||
e.target.blur();
|
||||
} else if (e.key === "Enter") {
|
||||
// Pressing Enter doesn't actually submit because it's all on
|
||||
// debounce, but it can be a declaration that the query is done, so
|
||||
// filter suggestions should go away!
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
onMoveFocusDownToResults(e);
|
||||
} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
|
||||
onChange({
|
||||
...query,
|
||||
filterToItemKind: null,
|
||||
filterToZoneLabel: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box position="relative" {...props}>
|
||||
<Autosuggest
|
||||
suggestions={advancedSearchIsOpen ? allSuggestions : suggestions}
|
||||
onSuggestionsFetchRequested={({ value }) => {
|
||||
// HACK: I'm not sure why, but apparently this gets called with value
|
||||
// set to the _chosen suggestion_ after choosing it? Has that
|
||||
// always happened? Idk? Let's just, gate around it, I guess?
|
||||
if (typeof value === "string") {
|
||||
setSuggestions(
|
||||
getSuggestions(value, query, zoneLabels, isLoggedIn),
|
||||
);
|
||||
}
|
||||
}}
|
||||
onSuggestionSelected={(e, { suggestion }) => {
|
||||
onChange({
|
||||
...query,
|
||||
// If the suggestion was from typing, remove the last word of the
|
||||
// query value. Or, if it was from Advanced Search, leave it alone!
|
||||
value: advancedSearchIsOpen
|
||||
? query.value
|
||||
: removeLastWord(query.value),
|
||||
filterToZoneLabel: suggestion.zoneLabel || query.filterToZoneLabel,
|
||||
filterToItemKind: suggestion.itemKind || query.filterToItemKind,
|
||||
filterToCurrentUserOwnsOrWants:
|
||||
suggestion.userOwnsOrWants ||
|
||||
query.filterToCurrentUserOwnsOrWants,
|
||||
});
|
||||
}}
|
||||
getSuggestionValue={(zl) => zl}
|
||||
alwaysRenderSuggestions={true}
|
||||
renderSuggestion={renderSuggestion}
|
||||
renderSuggestionsContainer={renderSuggestionsContainer}
|
||||
renderInputComponent={(inputProps) => (
|
||||
<InputGroup boxShadow={boxShadow} borderRadius="md">
|
||||
{queryFilterText ? (
|
||||
<InputLeftAddon>
|
||||
<SearchIcon color="gray.400" marginRight="3" />
|
||||
<Box fontSize="sm">{queryFilterText}</Box>
|
||||
</InputLeftAddon>
|
||||
) : (
|
||||
<InputLeftElement>
|
||||
<SearchIcon color="gray.400" />
|
||||
</InputLeftElement>
|
||||
)}
|
||||
<Input
|
||||
background={background}
|
||||
// TODO: How to improve a11y here?
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus={autoFocus}
|
||||
{...inputProps}
|
||||
/>
|
||||
<InputRightElement
|
||||
width="auto"
|
||||
justifyContent="flex-end"
|
||||
paddingRight="2px"
|
||||
paddingY="2px"
|
||||
>
|
||||
{!searchQueryIsEmpty(query) && (
|
||||
<Tooltip label="Clear">
|
||||
<IconButton
|
||||
icon={<CloseIcon fontSize="0.6em" />}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
marginLeft="1"
|
||||
aria-label="Clear search"
|
||||
onClick={() => {
|
||||
setSuggestions([]);
|
||||
onChange(emptySearchQuery);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Advanced search">
|
||||
<IconButton
|
||||
icon={
|
||||
advancedSearchIsOpen ? (
|
||||
<ChevronUpIcon fontSize="1.5em" />
|
||||
) : (
|
||||
<ChevronDownIcon fontSize="1.5em" />
|
||||
)
|
||||
}
|
||||
color="gray.400"
|
||||
variant="ghost"
|
||||
height="100%"
|
||||
aria-label="Open advanced search"
|
||||
onClick={() => setAdvancedSearchIsOpen((isOpen) => !isOpen)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</InputRightElement>
|
||||
</InputGroup>
|
||||
)}
|
||||
inputProps={{
|
||||
placeholder: "Search all items…",
|
||||
focusBorderColor: focusBorderColor,
|
||||
value: query.value || "",
|
||||
ref: searchQueryRef,
|
||||
minWidth: 0,
|
||||
"data-test-id": "item-search-input",
|
||||
onChange: (e, { newValue, method }) => {
|
||||
// The Autosuggest tries to change the _entire_ value of the element
|
||||
// when navigating suggestions, which isn't actually what we want.
|
||||
// Only accept value changes that are typed by the user!
|
||||
if (method === "type") {
|
||||
onChange({ ...query, value: newValue });
|
||||
}
|
||||
},
|
||||
onKeyDown: (e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
onChange(emptySearchQuery);
|
||||
e.target.blur();
|
||||
} else if (e.key === "Enter") {
|
||||
// Pressing Enter doesn't actually submit because it's all on
|
||||
// debounce, but it can be a declaration that the query is done, so
|
||||
// filter suggestions should go away!
|
||||
if (suggestions.length > 0) {
|
||||
setSuggestions([]);
|
||||
return;
|
||||
}
|
||||
} else if (e.key === "ArrowDown") {
|
||||
if (suggestions.length > 0) {
|
||||
return;
|
||||
}
|
||||
onMoveFocusDownToResults(e);
|
||||
} else if (e.key === "Backspace" && e.target.selectionStart === 0) {
|
||||
onChange({
|
||||
...query,
|
||||
filterToItemKind: null,
|
||||
filterToZoneLabel: null,
|
||||
filterToCurrentUserOwnsOrWants: null,
|
||||
});
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function getSuggestions(
|
||||
value,
|
||||
query,
|
||||
zoneLabels,
|
||||
isLoggedIn,
|
||||
{ showAll = false } = {},
|
||||
value,
|
||||
query,
|
||||
zoneLabels,
|
||||
isLoggedIn,
|
||||
{ showAll = false } = {},
|
||||
) {
|
||||
if (!value && !showAll) {
|
||||
return [];
|
||||
}
|
||||
if (!value && !showAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const words = (value || "").split(/\s+/);
|
||||
const lastWord = words[words.length - 1];
|
||||
if (lastWord.length < 2 && !showAll) {
|
||||
return [];
|
||||
}
|
||||
const words = (value || "").split(/\s+/);
|
||||
const lastWord = words[words.length - 1];
|
||||
if (lastWord.length < 2 && !showAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const suggestions = [];
|
||||
const suggestions = [];
|
||||
|
||||
if (query.filterToItemKind == null) {
|
||||
if (
|
||||
wordMatches("NC", lastWord) ||
|
||||
wordMatches("Neocash", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NC", text: "Neocash items" });
|
||||
}
|
||||
if (query.filterToItemKind == null) {
|
||||
if (
|
||||
wordMatches("NC", lastWord) ||
|
||||
wordMatches("Neocash", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NC", text: "Neocash items" });
|
||||
}
|
||||
|
||||
if (
|
||||
wordMatches("NP", lastWord) ||
|
||||
wordMatches("Neopoints", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NP", text: "Neopoint items" });
|
||||
}
|
||||
if (
|
||||
wordMatches("NP", lastWord) ||
|
||||
wordMatches("Neopoints", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "NP", text: "Neopoint items" });
|
||||
}
|
||||
|
||||
if (
|
||||
wordMatches("PB", lastWord) ||
|
||||
wordMatches("Paintbrush", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
|
||||
}
|
||||
}
|
||||
if (
|
||||
wordMatches("PB", lastWord) ||
|
||||
wordMatches("Paintbrush", lastWord) ||
|
||||
showAll
|
||||
) {
|
||||
suggestions.push({ itemKind: "PB", text: "Paintbrush items" });
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
|
||||
if (wordMatches("Items you own", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
|
||||
}
|
||||
if (isLoggedIn && query.filterToCurrentUserOwnsOrWants == null) {
|
||||
if (wordMatches("Items you own", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "OWNS", text: "Items you own" });
|
||||
}
|
||||
|
||||
if (wordMatches("Items you want", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
|
||||
}
|
||||
}
|
||||
if (wordMatches("Items you want", lastWord) || showAll) {
|
||||
suggestions.push({ userOwnsOrWants: "WANTS", text: "Items you want" });
|
||||
}
|
||||
}
|
||||
|
||||
if (query.filterToZoneLabel == null) {
|
||||
for (const zoneLabel of zoneLabels) {
|
||||
if (wordMatches(zoneLabel, lastWord) || showAll) {
|
||||
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (query.filterToZoneLabel == null) {
|
||||
for (const zoneLabel of zoneLabels) {
|
||||
if (wordMatches(zoneLabel, lastWord) || showAll) {
|
||||
suggestions.push({ zoneLabel, text: `Zone: ${zoneLabel}` });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
function wordMatches(target, word) {
|
||||
return target.toLowerCase().includes(word.toLowerCase());
|
||||
return target.toLowerCase().includes(word.toLowerCase());
|
||||
}
|
||||
|
||||
function getQueryFilterText(query) {
|
||||
const textWords = [];
|
||||
const textWords = [];
|
||||
|
||||
if (query.filterToItemKind) {
|
||||
textWords.push(query.filterToItemKind);
|
||||
}
|
||||
if (query.filterToItemKind) {
|
||||
textWords.push(query.filterToItemKind);
|
||||
}
|
||||
|
||||
if (query.filterToZoneLabel) {
|
||||
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
|
||||
}
|
||||
if (query.filterToZoneLabel) {
|
||||
textWords.push(pluralizeZoneLabel(query.filterToZoneLabel));
|
||||
}
|
||||
|
||||
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you own");
|
||||
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you want");
|
||||
}
|
||||
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you own");
|
||||
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
||||
if (!query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("Items");
|
||||
} else if (query.filterToItemKind && !query.filterToZoneLabel) {
|
||||
textWords.push("items");
|
||||
}
|
||||
textWords.push("you want");
|
||||
}
|
||||
|
||||
return textWords.join(" ");
|
||||
return textWords.join(" ");
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -446,13 +446,13 @@ function getQueryFilterText(query) {
|
|||
* manually creating the plural for each zone. But, ehh! ¯\_ (ツ)_/¯
|
||||
*/
|
||||
function pluralizeZoneLabel(zoneLabel) {
|
||||
if (zoneLabel.endsWith("ss")) {
|
||||
return zoneLabel + "es";
|
||||
} else if (zoneLabel.endsWith("s")) {
|
||||
return zoneLabel;
|
||||
} else {
|
||||
return zoneLabel + "s";
|
||||
}
|
||||
if (zoneLabel.endsWith("ss")) {
|
||||
return zoneLabel + "es";
|
||||
} else if (zoneLabel.endsWith("s")) {
|
||||
return zoneLabel;
|
||||
} else {
|
||||
return zoneLabel + "s";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -460,22 +460,22 @@ function pluralizeZoneLabel(zoneLabel) {
|
|||
* preceding space removed.
|
||||
*/
|
||||
function removeLastWord(text) {
|
||||
// This regex matches the full text, and assigns the last word and any
|
||||
// preceding text to subgroup 2, and all preceding text to subgroup 1. If
|
||||
// there's no last word, we'll still match, and the full string will be in
|
||||
// subgroup 1, including any space - no changes made!
|
||||
const match = text.match(/^(.*?)(\s*\S+)?$/);
|
||||
if (!match) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion failure: pattern should match any input text, ` +
|
||||
`but failed to match ${JSON.stringify(text)}`,
|
||||
),
|
||||
);
|
||||
return text;
|
||||
}
|
||||
// This regex matches the full text, and assigns the last word and any
|
||||
// preceding text to subgroup 2, and all preceding text to subgroup 1. If
|
||||
// there's no last word, we'll still match, and the full string will be in
|
||||
// subgroup 1, including any space - no changes made!
|
||||
const match = text.match(/^(.*?)(\s*\S+)?$/);
|
||||
if (!match) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion failure: pattern should match any input text, ` +
|
||||
`but failed to match ${JSON.stringify(text)}`,
|
||||
),
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
return match[1];
|
||||
return match[1];
|
||||
}
|
||||
|
||||
export default SearchToolbar;
|
||||
|
|
|
@ -3,65 +3,65 @@ import { Box, Grid, useColorModeValue, useToken } from "@chakra-ui/react";
|
|||
import { useCommonStyles } from "../util";
|
||||
|
||||
function WardrobePageLayout({
|
||||
previewAndControls = null,
|
||||
itemsAndMaybeSearchPanel = null,
|
||||
searchFooter = null,
|
||||
previewAndControls = null,
|
||||
itemsAndMaybeSearchPanel = null,
|
||||
searchFooter = null,
|
||||
}) {
|
||||
const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
|
||||
const searchBackground = useCommonStyles().bodyBackground;
|
||||
const searchShadowColorValue = useToken("colors", "gray.400");
|
||||
const itemsAndSearchBackground = useColorModeValue("white", "gray.900");
|
||||
const searchBackground = useCommonStyles().bodyBackground;
|
||||
const searchShadowColorValue = useToken("colors", "gray.400");
|
||||
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
// Create a stacking context, so that our drawers and modals don't fight
|
||||
// with the z-indexes in here!
|
||||
zIndex="0"
|
||||
>
|
||||
<Grid
|
||||
templateAreas={{
|
||||
base: `"previewAndControls"
|
||||
return (
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
right="0"
|
||||
// Create a stacking context, so that our drawers and modals don't fight
|
||||
// with the z-indexes in here!
|
||||
zIndex="0"
|
||||
>
|
||||
<Grid
|
||||
templateAreas={{
|
||||
base: `"previewAndControls"
|
||||
"itemsAndMaybeSearchPanel"`,
|
||||
md: `"previewAndControls itemsAndMaybeSearchPanel"
|
||||
md: `"previewAndControls itemsAndMaybeSearchPanel"
|
||||
"searchFooter searchFooter"`,
|
||||
}}
|
||||
templateRows={{
|
||||
base: "minmax(100px, 45%) minmax(300px, 55%)",
|
||||
md: "minmax(300px, 1fr) auto",
|
||||
}}
|
||||
templateColumns={{
|
||||
base: "100%",
|
||||
md: "50% 50%",
|
||||
}}
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<Box
|
||||
gridArea="previewAndControls"
|
||||
bg="gray.900"
|
||||
color="gray.50"
|
||||
position="relative"
|
||||
>
|
||||
{previewAndControls}
|
||||
</Box>
|
||||
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
|
||||
{itemsAndMaybeSearchPanel}
|
||||
</Box>
|
||||
<Box
|
||||
gridArea="searchFooter"
|
||||
bg={searchBackground}
|
||||
boxShadow={`0 0 8px ${searchShadowColorValue}`}
|
||||
display={{ base: "none", md: "block" }}
|
||||
>
|
||||
{searchFooter}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}}
|
||||
templateRows={{
|
||||
base: "minmax(100px, 45%) minmax(300px, 55%)",
|
||||
md: "minmax(300px, 1fr) auto",
|
||||
}}
|
||||
templateColumns={{
|
||||
base: "100%",
|
||||
md: "50% 50%",
|
||||
}}
|
||||
height="100%"
|
||||
width="100%"
|
||||
>
|
||||
<Box
|
||||
gridArea="previewAndControls"
|
||||
bg="gray.900"
|
||||
color="gray.50"
|
||||
position="relative"
|
||||
>
|
||||
{previewAndControls}
|
||||
</Box>
|
||||
<Box gridArea="itemsAndMaybeSearchPanel" bg={itemsAndSearchBackground}>
|
||||
{itemsAndMaybeSearchPanel}
|
||||
</Box>
|
||||
<Box
|
||||
gridArea="searchFooter"
|
||||
bg={searchBackground}
|
||||
boxShadow={`0 0 8px ${searchShadowColorValue}`}
|
||||
display={{ base: "none", md: "block" }}
|
||||
>
|
||||
{searchFooter}
|
||||
</Box>
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default WardrobePageLayout;
|
||||
|
|
|
@ -11,43 +11,43 @@ import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
|
|||
const OutfitControls = loadable(() => import("./OutfitControls"));
|
||||
|
||||
function WardrobePreviewAndControls({
|
||||
isLoading,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isLoading,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
// Whether the current outfit preview has animations. Determines whether we
|
||||
// show the play/pause button.
|
||||
const [hasAnimations, setHasAnimations] = React.useState(false);
|
||||
// Whether the current outfit preview has animations. Determines whether we
|
||||
// show the play/pause button.
|
||||
const [hasAnimations, setHasAnimations] = React.useState(false);
|
||||
|
||||
const { appearance, preview } = useOutfitPreview({
|
||||
isLoading: isLoading,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
altStyleId: outfitState.altStyleId,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
wornItemIds: outfitState.wornItemIds,
|
||||
onChangeHasAnimations: setHasAnimations,
|
||||
placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
|
||||
"data-test-id": "wardrobe-outfit-preview",
|
||||
});
|
||||
const { appearance, preview } = useOutfitPreview({
|
||||
isLoading: isLoading,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
altStyleId: outfitState.altStyleId,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
wornItemIds: outfitState.wornItemIds,
|
||||
onChangeHasAnimations: setHasAnimations,
|
||||
placeholder: <OutfitThumbnailIfCached outfitId={outfitState.id} />,
|
||||
"data-test-id": "wardrobe-outfit-preview",
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Center position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<DarkMode>{preview}</DarkMode>
|
||||
</Center>
|
||||
<Box position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<OutfitControls
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
showAnimationControls={hasAnimations}
|
||||
appearance={appearance}
|
||||
/>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Center position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<DarkMode>{preview}</DarkMode>
|
||||
</Center>
|
||||
<Box position="absolute" top="0" bottom="0" left="0" right="0">
|
||||
<OutfitControls
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
showAnimationControls={hasAnimations}
|
||||
appearance={appearance}
|
||||
/>
|
||||
</Box>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -61,40 +61,40 @@ function WardrobePreviewAndControls({
|
|||
* like usual!
|
||||
*/
|
||||
function OutfitThumbnailIfCached({ outfitId }) {
|
||||
const { data } = useQuery(
|
||||
gql`
|
||||
query OutfitThumbnailIfCached($outfitId: ID!) {
|
||||
outfit(id: $outfitId) {
|
||||
id
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
outfitId,
|
||||
},
|
||||
skip: outfitId == null,
|
||||
fetchPolicy: "cache-only",
|
||||
onError: (e) => console.error(e),
|
||||
},
|
||||
);
|
||||
const { data } = useQuery(
|
||||
gql`
|
||||
query OutfitThumbnailIfCached($outfitId: ID!) {
|
||||
outfit(id: $outfitId) {
|
||||
id
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
outfitId,
|
||||
},
|
||||
skip: outfitId == null,
|
||||
fetchPolicy: "cache-only",
|
||||
onError: (e) => console.error(e),
|
||||
},
|
||||
);
|
||||
|
||||
if (!data?.outfit) {
|
||||
return null;
|
||||
}
|
||||
if (!data?.outfit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OutfitThumbnail
|
||||
outfitId={data.outfit.id}
|
||||
updatedAt={data.outfit.updatedAt}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter="blur(2px)"
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<OutfitThumbnail
|
||||
outfitId={data.outfit.id}
|
||||
updatedAt={data.outfit.updatedAt}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
width="100%"
|
||||
height="100%"
|
||||
filter="blur(2px)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default WardrobePreviewAndControls;
|
||||
|
|
|
@ -21,93 +21,93 @@ import WardrobePreviewAndControls from "./WardrobePreviewAndControls";
|
|||
* page layout.
|
||||
*/
|
||||
function WardrobePage() {
|
||||
const toast = useToast();
|
||||
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
||||
const toast = useToast();
|
||||
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
|
||||
|
||||
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
||||
const [searchQuery, setSearchQuery] = React.useState(emptySearchQuery);
|
||||
|
||||
// We manage outfit saving up here, rather than at the point of the UI where
|
||||
// "Saving" indicators appear. That way, auto-saving still happens even when
|
||||
// the indicator isn't on the page, e.g. when searching.
|
||||
// NOTE: This only applies to navigations leaving the wardrobe-2020 app, not
|
||||
// within!
|
||||
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
||||
// We manage outfit saving up here, rather than at the point of the UI where
|
||||
// "Saving" indicators appear. That way, auto-saving still happens even when
|
||||
// the indicator isn't on the page, e.g. when searching.
|
||||
// NOTE: This only applies to navigations leaving the wardrobe-2020 app, not
|
||||
// within!
|
||||
const outfitSaving = useOutfitSaving(outfitState, dispatchToOutfit);
|
||||
|
||||
// TODO: I haven't found a great place for this error UI yet, and this case
|
||||
// isn't very common, so this lil toast notification seems good enough!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: "We couldn't load this outfit 😖",
|
||||
description: "Please reload the page to try again. Sorry!",
|
||||
status: "error",
|
||||
isClosable: true,
|
||||
duration: 999999999,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
// TODO: I haven't found a great place for this error UI yet, and this case
|
||||
// isn't very common, so this lil toast notification seems good enough!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: "We couldn't load this outfit 😖",
|
||||
description: "Please reload the page to try again. Sorry!",
|
||||
status: "error",
|
||||
isClosable: true,
|
||||
duration: 999999999,
|
||||
});
|
||||
}
|
||||
}, [error, toast]);
|
||||
|
||||
// For new outfits, we only block navigation while saving. For existing
|
||||
// outfits, we block navigation while there are any unsaved changes.
|
||||
const shouldBlockNavigation =
|
||||
outfitSaving.canSaveOutfit &&
|
||||
((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
|
||||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
|
||||
// For new outfits, we only block navigation while saving. For existing
|
||||
// outfits, we block navigation while there are any unsaved changes.
|
||||
const shouldBlockNavigation =
|
||||
outfitSaving.canSaveOutfit &&
|
||||
((outfitSaving.isNewOutfit && outfitSaving.isSaving) ||
|
||||
(!outfitSaving.isNewOutfit && !outfitSaving.latestVersionIsSaved));
|
||||
|
||||
// In addition to a <Prompt /> for client-side nav, we need to block full nav!
|
||||
React.useEffect(() => {
|
||||
if (shouldBlockNavigation) {
|
||||
const onBeforeUnload = (e) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
// In addition to a <Prompt /> for client-side nav, we need to block full nav!
|
||||
React.useEffect(() => {
|
||||
if (shouldBlockNavigation) {
|
||||
const onBeforeUnload = (e) => {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowEventHandlers/onbeforeunload#example
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}
|
||||
}, [shouldBlockNavigation]);
|
||||
window.addEventListener("beforeunload", onBeforeUnload);
|
||||
return () => window.removeEventListener("beforeunload", onBeforeUnload);
|
||||
}
|
||||
}, [shouldBlockNavigation]);
|
||||
|
||||
const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
|
||||
React.useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
const title = `${outfitState.name || "Untitled outfit"} | Dress to Impress`;
|
||||
React.useEffect(() => {
|
||||
document.title = title;
|
||||
}, [title]);
|
||||
|
||||
// NOTE: Most components pass around outfitState directly, to make the data
|
||||
// relationships more explicit... but there are some deep components
|
||||
// that need it, where it's more useful and more performant to access
|
||||
// via context.
|
||||
return (
|
||||
<OutfitStateContext.Provider value={outfitState}>
|
||||
<WardrobePageLayout
|
||||
previewAndControls={
|
||||
<WardrobePreviewAndControls
|
||||
isLoading={loading}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
itemsAndMaybeSearchPanel={
|
||||
<ItemsAndSearchPanels
|
||||
loading={loading}
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
searchFooter={
|
||||
<SearchFooter
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OutfitStateContext.Provider>
|
||||
);
|
||||
// NOTE: Most components pass around outfitState directly, to make the data
|
||||
// relationships more explicit... but there are some deep components
|
||||
// that need it, where it's more useful and more performant to access
|
||||
// via context.
|
||||
return (
|
||||
<OutfitStateContext.Provider value={outfitState}>
|
||||
<WardrobePageLayout
|
||||
previewAndControls={
|
||||
<WardrobePreviewAndControls
|
||||
isLoading={loading}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
itemsAndMaybeSearchPanel={
|
||||
<ItemsAndSearchPanels
|
||||
loading={loading}
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
}
|
||||
searchFooter={
|
||||
<SearchFooter
|
||||
searchQuery={searchQuery}
|
||||
onChangeSearchQuery={setSearchQuery}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OutfitStateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export default WardrobePage;
|
||||
|
|
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";
|
||||
|
||||
function ItemSupportAppearanceLayer({
|
||||
item,
|
||||
itemLayer,
|
||||
biologyLayers,
|
||||
outfitState,
|
||||
item,
|
||||
itemLayer,
|
||||
biologyLayers,
|
||||
outfitState,
|
||||
}) {
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const iconButtonBgColor = useColorModeValue("green.100", "green.300");
|
||||
const iconButtonColor = useColorModeValue("green.800", "gray.900");
|
||||
const iconButtonBgColor = useColorModeValue("green.100", "green.300");
|
||||
const iconButtonColor = useColorModeValue("green.800", "gray.900");
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
as="button"
|
||||
width="150px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Box
|
||||
width="150px"
|
||||
height="150px"
|
||||
marginBottom="1"
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
position="relative"
|
||||
>
|
||||
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
|
||||
<Box
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
as="button"
|
||||
width="150px"
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
onClick={onOpen}
|
||||
>
|
||||
<Box
|
||||
width="150px"
|
||||
height="150px"
|
||||
marginBottom="1"
|
||||
boxShadow="md"
|
||||
borderRadius="md"
|
||||
position="relative"
|
||||
>
|
||||
<OutfitLayers visibleLayers={[...biologyLayers, itemLayer]} />
|
||||
<Box
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
|
||||
button:hover &,
|
||||
button:focus & {
|
||||
opacity: 1;
|
||||
}
|
||||
button:hover &,
|
||||
button:focus & {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* On touch devices, always show the icon, to clarify that this is
|
||||
/* On touch devices, always show the icon, to clarify that this is
|
||||
* an interactable object! (Whereas I expect other devices to
|
||||
* discover things by exploratory hover or focus!) */
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
background={iconButtonBgColor}
|
||||
color={iconButtonColor}
|
||||
borderRadius="full"
|
||||
boxShadow="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
padding="2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="32px"
|
||||
height="32px"
|
||||
>
|
||||
<EditIcon
|
||||
boxSize="16px"
|
||||
position="relative"
|
||||
top="-2px"
|
||||
right="-1px"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box as="span" fontWeight="700">
|
||||
{itemLayer.zone.label}
|
||||
</Box>{" "}
|
||||
<Box as="span" fontWeight="600">
|
||||
(Zone {itemLayer.zone.id})
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>Neopets ID: {itemLayer.remoteId}</Box>
|
||||
<Box>DTI ID: {itemLayer.id}</Box>
|
||||
<AppearanceLayerSupportModal
|
||||
item={item}
|
||||
layer={itemLayer}
|
||||
outfitState={outfitState}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
background={iconButtonBgColor}
|
||||
color={iconButtonColor}
|
||||
borderRadius="full"
|
||||
boxShadow="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
padding="2"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
width="32px"
|
||||
height="32px"
|
||||
>
|
||||
<EditIcon
|
||||
boxSize="16px"
|
||||
position="relative"
|
||||
top="-2px"
|
||||
right="-1px"
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box as="span" fontWeight="700">
|
||||
{itemLayer.zone.label}
|
||||
</Box>{" "}
|
||||
<Box as="span" fontWeight="600">
|
||||
(Zone {itemLayer.zone.id})
|
||||
</Box>
|
||||
</Box>
|
||||
<Box>Neopets ID: {itemLayer.remoteId}</Box>
|
||||
<Box>DTI ID: {itemLayer.id}</Box>
|
||||
<AppearanceLayerSupportModal
|
||||
item={item}
|
||||
layer={itemLayer}
|
||||
outfitState={outfitState}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemSupportAppearanceLayer;
|
||||
|
|
|
@ -2,34 +2,34 @@ import * as React from "react";
|
|||
import gql from "graphql-tag";
|
||||
import { useQuery, useMutation } from "@apollo/client";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Link,
|
||||
Select,
|
||||
Spinner,
|
||||
Stack,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Drawer,
|
||||
DrawerBody,
|
||||
DrawerCloseButton,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerOverlay,
|
||||
Flex,
|
||||
FormControl,
|
||||
FormErrorMessage,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
HStack,
|
||||
Link,
|
||||
Select,
|
||||
Spinner,
|
||||
Stack,
|
||||
Text,
|
||||
useBreakpointValue,
|
||||
useColorModeValue,
|
||||
useDisclosure,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
CheckCircleIcon,
|
||||
ChevronRightIcon,
|
||||
ExternalLinkIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
|
||||
import AllItemLayersSupportModal from "./AllItemLayersSupportModal";
|
||||
|
@ -46,362 +46,362 @@ import ItemSupportAppearanceLayer from "./ItemSupportAppearanceLayer";
|
|||
* from another lazy-loaded component!
|
||||
*/
|
||||
function ItemSupportDrawer({ item, isOpen, onClose }) {
|
||||
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
|
||||
const placement = useBreakpointValue({ base: "bottom", lg: "right" });
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
placement={placement}
|
||||
size="md"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
|
||||
// default implementation breaks out layout somehow 🤔 idk, let's not!
|
||||
blockScrollOnMount={false}
|
||||
>
|
||||
<DrawerOverlay>
|
||||
<DrawerContent
|
||||
maxHeight={placement === "bottom" ? "90vh" : undefined}
|
||||
overflow="auto"
|
||||
>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>
|
||||
{item.name}
|
||||
<Badge colorScheme="pink" marginLeft="3">
|
||||
Support <span aria-hidden="true">💖</span>
|
||||
</Badge>
|
||||
</DrawerHeader>
|
||||
<DrawerBody paddingBottom="5">
|
||||
<Metadata>
|
||||
<MetadataLabel>Item ID:</MetadataLabel>
|
||||
<MetadataValue>{item.id}</MetadataValue>
|
||||
<MetadataLabel>Restricted zones:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<ItemSupportRestrictedZones item={item} />
|
||||
</MetadataValue>
|
||||
</Metadata>
|
||||
<Stack spacing="8" marginTop="6">
|
||||
<ItemSupportFields item={item} />
|
||||
<ItemSupportAppearanceLayers item={item} />
|
||||
</Stack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</DrawerOverlay>
|
||||
</Drawer>
|
||||
);
|
||||
return (
|
||||
<Drawer
|
||||
placement={placement}
|
||||
size="md"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
// blockScrollOnMount doesn't matter on our fullscreen UI, but the
|
||||
// default implementation breaks out layout somehow 🤔 idk, let's not!
|
||||
blockScrollOnMount={false}
|
||||
>
|
||||
<DrawerOverlay>
|
||||
<DrawerContent
|
||||
maxHeight={placement === "bottom" ? "90vh" : undefined}
|
||||
overflow="auto"
|
||||
>
|
||||
<DrawerCloseButton />
|
||||
<DrawerHeader>
|
||||
{item.name}
|
||||
<Badge colorScheme="pink" marginLeft="3">
|
||||
Support <span aria-hidden="true">💖</span>
|
||||
</Badge>
|
||||
</DrawerHeader>
|
||||
<DrawerBody paddingBottom="5">
|
||||
<Metadata>
|
||||
<MetadataLabel>Item ID:</MetadataLabel>
|
||||
<MetadataValue>{item.id}</MetadataValue>
|
||||
<MetadataLabel>Restricted zones:</MetadataLabel>
|
||||
<MetadataValue>
|
||||
<ItemSupportRestrictedZones item={item} />
|
||||
</MetadataValue>
|
||||
</Metadata>
|
||||
<Stack spacing="8" marginTop="6">
|
||||
<ItemSupportFields item={item} />
|
||||
<ItemSupportAppearanceLayers item={item} />
|
||||
</Stack>
|
||||
</DrawerBody>
|
||||
</DrawerContent>
|
||||
</DrawerOverlay>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportRestrictedZones({ item }) {
|
||||
const { speciesId, colorId } = React.useContext(OutfitStateContext);
|
||||
const { speciesId, colorId } = React.useContext(OutfitStateContext);
|
||||
|
||||
// NOTE: It would be a better reflection of the data to just query restricted
|
||||
// zones right off the item... but we already have them in cache from
|
||||
// the appearance, so query them that way to be instant in practice!
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportRestrictedZones(
|
||||
$itemId: ID!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
restrictedZones {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ variables: { itemId: item.id, speciesId, colorId } },
|
||||
);
|
||||
// NOTE: It would be a better reflection of the data to just query restricted
|
||||
// zones right off the item... but we already have them in cache from
|
||||
// the appearance, so query them that way to be instant in practice!
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportRestrictedZones(
|
||||
$itemId: ID!
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
appearanceOn(speciesId: $speciesId, colorId: $colorId) {
|
||||
restrictedZones {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{ variables: { itemId: item.id, speciesId, colorId } },
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return <Spinner size="xs" />;
|
||||
}
|
||||
if (loading) {
|
||||
return <Spinner size="xs" />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return <Text color="red.400">{error.message}</Text>;
|
||||
}
|
||||
if (error) {
|
||||
return <Text color="red.400">{error.message}</Text>;
|
||||
}
|
||||
|
||||
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
|
||||
if (restrictedZones.length === 0) {
|
||||
return "None";
|
||||
}
|
||||
const restrictedZones = data?.item?.appearanceOn?.restrictedZones || [];
|
||||
if (restrictedZones.length === 0) {
|
||||
return "None";
|
||||
}
|
||||
|
||||
return restrictedZones
|
||||
.map((z) => `${z.label} (${z.id})`)
|
||||
.sort()
|
||||
.join(", ");
|
||||
return restrictedZones
|
||||
.map((z) => `${z.label} (${z.id})`)
|
||||
.sort()
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function ItemSupportFields({ item }) {
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportFields($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId: item.id },
|
||||
const { loading, error, data } = useQuery(
|
||||
gql`
|
||||
query ItemSupportFields($itemId: ID!) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { itemId: item.id },
|
||||
|
||||
// HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
|
||||
// optimistic response sets `manualSpecialColor` to null, the query
|
||||
// doesn't update, even though its cache has updated :/
|
||||
//
|
||||
// This cheap trick of changing the display name every re-render
|
||||
// persuades Apollo that this is a different query, so it re-checks
|
||||
// its cache and finds the empty `manualSpecialColor`. Weird!
|
||||
displayName: `ItemSupportFields-${new Date()}`,
|
||||
},
|
||||
);
|
||||
// HACK: I think it's a bug in @apollo/client 3.1.1 that, if the
|
||||
// optimistic response sets `manualSpecialColor` to null, the query
|
||||
// doesn't update, even though its cache has updated :/
|
||||
//
|
||||
// This cheap trick of changing the display name every re-render
|
||||
// persuades Apollo that this is a different query, so it re-checks
|
||||
// its cache and finds the empty `manualSpecialColor`. Weird!
|
||||
displayName: `ItemSupportFields-${new Date()}`,
|
||||
},
|
||||
);
|
||||
|
||||
const errorColor = useColorModeValue("red.500", "red.300");
|
||||
const errorColor = useColorModeValue("red.500", "red.300");
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && <Box color={errorColor}>{error.message}</Box>}
|
||||
<ItemSupportSpecialColorFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
manualSpecialColor={data?.item?.manualSpecialColor?.id}
|
||||
/>
|
||||
<ItemSupportPetCompatibilityRuleFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{error && <Box color={errorColor}>{error.message}</Box>}
|
||||
<ItemSupportSpecialColorFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
manualSpecialColor={data?.item?.manualSpecialColor?.id}
|
||||
/>
|
||||
<ItemSupportPetCompatibilityRuleFields
|
||||
loading={loading}
|
||||
error={error}
|
||||
item={item}
|
||||
explicitlyBodySpecific={data?.item?.explicitlyBodySpecific}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportSpecialColorFields({
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
manualSpecialColor,
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
manualSpecialColor,
|
||||
}) {
|
||||
const { supportSecret } = useSupport();
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const {
|
||||
loading: colorsLoading,
|
||||
error: colorsError,
|
||||
data: colorsData,
|
||||
} = useQuery(gql`
|
||||
query ItemSupportDrawerAllColors {
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard
|
||||
}
|
||||
}
|
||||
`);
|
||||
const {
|
||||
loading: colorsLoading,
|
||||
error: colorsError,
|
||||
data: colorsData,
|
||||
} = useQuery(gql`
|
||||
query ItemSupportDrawerAllColors {
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetManualSpecialColor(
|
||||
$itemId: ID!
|
||||
$colorId: ID
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setManualSpecialColor(
|
||||
itemId: $itemId
|
||||
colorId: $colorId
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetManualSpecialColor(
|
||||
$itemId: ID!
|
||||
$colorId: ID
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setManualSpecialColor(
|
||||
itemId: $itemId
|
||||
colorId: $colorId
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
manualSpecialColor {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const colorId = e.target.value || null;
|
||||
const color =
|
||||
colorId != null ? { __typename: "Color", id: colorId } : null;
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
colorId,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setManualSpecialColor: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
manualSpecialColor: color,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret],
|
||||
);
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const colorId = e.target.value || null;
|
||||
const color =
|
||||
colorId != null ? { __typename: "Color", id: colorId } : null;
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
colorId,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setManualSpecialColor: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
manualSpecialColor: color,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret],
|
||||
);
|
||||
|
||||
const nonStandardColors =
|
||||
colorsData?.allColors?.filter((c) => !c.isStandard) || [];
|
||||
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const nonStandardColors =
|
||||
colorsData?.allColors?.filter((c) => !c.isStandard) || [];
|
||||
nonStandardColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const linkColor = useColorModeValue("green.500", "green.300");
|
||||
const linkColor = useColorModeValue("green.500", "green.300");
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
|
||||
<FormLabel>Special color</FormLabel>
|
||||
<Select
|
||||
placeholder={
|
||||
loading || colorsLoading
|
||||
? "Loading…"
|
||||
: "Default: Auto-detect from item description"
|
||||
}
|
||||
value={manualSpecialColor?.id}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || colorsLoading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{nonStandardColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{colorsError && (
|
||||
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
|
||||
)}
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!colorsError && !mutationError && (
|
||||
<FormHelperText>
|
||||
This controls which previews we show on the{" "}
|
||||
<Link
|
||||
href={`https://impress.openneo.net/items/${
|
||||
item.id
|
||||
}-${item.name.replace(/ /g, "-")}`}
|
||||
color={linkColor}
|
||||
isExternal
|
||||
>
|
||||
classic item page <ExternalLinkIcon />
|
||||
</Link>
|
||||
.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || colorsError || mutationError)}>
|
||||
<FormLabel>Special color</FormLabel>
|
||||
<Select
|
||||
placeholder={
|
||||
loading || colorsLoading
|
||||
? "Loading…"
|
||||
: "Default: Auto-detect from item description"
|
||||
}
|
||||
value={manualSpecialColor?.id}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || colorsLoading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{nonStandardColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
{colorsError && (
|
||||
<FormErrorMessage>{colorsError.message}</FormErrorMessage>
|
||||
)}
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!colorsError && !mutationError && (
|
||||
<FormHelperText>
|
||||
This controls which previews we show on the{" "}
|
||||
<Link
|
||||
href={`https://impress.openneo.net/items/${
|
||||
item.id
|
||||
}-${item.name.replace(/ /g, "-")}`}
|
||||
color={linkColor}
|
||||
isExternal
|
||||
>
|
||||
classic item page <ExternalLinkIcon />
|
||||
</Link>
|
||||
.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemSupportPetCompatibilityRuleFields({
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
explicitlyBodySpecific,
|
||||
loading,
|
||||
error,
|
||||
item,
|
||||
explicitlyBodySpecific,
|
||||
}) {
|
||||
const { supportSecret } = useSupport();
|
||||
const { supportSecret } = useSupport();
|
||||
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
|
||||
$itemId: ID!
|
||||
$explicitlyBodySpecific: Boolean!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setItemExplicitlyBodySpecific(
|
||||
itemId: $itemId
|
||||
explicitlyBodySpecific: $explicitlyBodySpecific
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`);
|
||||
const [
|
||||
mutate,
|
||||
{ loading: mutationLoading, error: mutationError, data: mutationData },
|
||||
] = useMutation(gql`
|
||||
mutation ItemSupportDrawerSetItemExplicitlyBodySpecific(
|
||||
$itemId: ID!
|
||||
$explicitlyBodySpecific: Boolean!
|
||||
$supportSecret: String!
|
||||
) {
|
||||
setItemExplicitlyBodySpecific(
|
||||
itemId: $itemId
|
||||
explicitlyBodySpecific: $explicitlyBodySpecific
|
||||
supportSecret: $supportSecret
|
||||
) {
|
||||
id
|
||||
explicitlyBodySpecific
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const explicitlyBodySpecific = e.target.value === "true";
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
explicitlyBodySpecific,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setItemExplicitlyBodySpecific: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
explicitlyBodySpecific,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret],
|
||||
);
|
||||
const onChange = React.useCallback(
|
||||
(e) => {
|
||||
const explicitlyBodySpecific = e.target.value === "true";
|
||||
mutate({
|
||||
variables: {
|
||||
itemId: item.id,
|
||||
explicitlyBodySpecific,
|
||||
supportSecret,
|
||||
},
|
||||
optimisticResponse: {
|
||||
__typename: "Mutation",
|
||||
setItemExplicitlyBodySpecific: {
|
||||
__typename: "Item",
|
||||
id: item.id,
|
||||
explicitlyBodySpecific,
|
||||
},
|
||||
},
|
||||
}).catch((e) => {
|
||||
// Ignore errors from the promise, because we'll handle them on render!
|
||||
});
|
||||
},
|
||||
[item.id, mutate, supportSecret],
|
||||
);
|
||||
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || mutationError)}>
|
||||
<FormLabel>Pet compatibility rule</FormLabel>
|
||||
<Select
|
||||
value={explicitlyBodySpecific ? "true" : "false"}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{loading ? (
|
||||
<option>Loading…</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="false">
|
||||
Default: Auto-detect whether this fits all pets
|
||||
</option>
|
||||
<option value="true">
|
||||
Body specific: Always different for each pet body
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!mutationError && (
|
||||
<FormHelperText>
|
||||
By default, we assume Background-y zones fit all pets the same. When
|
||||
items don't follow that rule, we can override it.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
return (
|
||||
<FormControl isInvalid={Boolean(error || mutationError)}>
|
||||
<FormLabel>Pet compatibility rule</FormLabel>
|
||||
<Select
|
||||
value={explicitlyBodySpecific ? "true" : "false"}
|
||||
isDisabled={mutationLoading}
|
||||
icon={
|
||||
loading || mutationLoading ? (
|
||||
<Spinner />
|
||||
) : mutationData ? (
|
||||
<CheckCircleIcon />
|
||||
) : undefined
|
||||
}
|
||||
onChange={onChange}
|
||||
>
|
||||
{loading ? (
|
||||
<option>Loading…</option>
|
||||
) : (
|
||||
<>
|
||||
<option value="false">
|
||||
Default: Auto-detect whether this fits all pets
|
||||
</option>
|
||||
<option value="true">
|
||||
Body specific: Always different for each pet body
|
||||
</option>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{mutationError && (
|
||||
<FormErrorMessage>{mutationError.message}</FormErrorMessage>
|
||||
)}
|
||||
{!mutationError && (
|
||||
<FormHelperText>
|
||||
By default, we assume Background-y zones fit all pets the same. When
|
||||
items don't follow that rule, we can override it.
|
||||
</FormHelperText>
|
||||
)}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -412,51 +412,51 @@ function ItemSupportPetCompatibilityRuleFields({
|
|||
* it here, only when the drawer is open!
|
||||
*/
|
||||
function ItemSupportAppearanceLayers({ item }) {
|
||||
const outfitState = React.useContext(OutfitStateContext);
|
||||
const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
|
||||
const { error, visibleLayers } = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds: [item.id],
|
||||
});
|
||||
const outfitState = React.useContext(OutfitStateContext);
|
||||
const { speciesId, colorId, pose, altStyleId, appearanceId } = outfitState;
|
||||
const { error, visibleLayers } = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds: [item.id],
|
||||
});
|
||||
|
||||
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
|
||||
const itemLayers = visibleLayers.filter((l) => l.source === "item");
|
||||
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
const biologyLayers = visibleLayers.filter((l) => l.source === "pet");
|
||||
const itemLayers = visibleLayers.filter((l) => l.source === "item");
|
||||
itemLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
|
||||
const modalState = useDisclosure();
|
||||
const modalState = useDisclosure();
|
||||
|
||||
return (
|
||||
<FormControl>
|
||||
<Flex align="center">
|
||||
<FormLabel>Appearance layers</FormLabel>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<Button size="xs" onClick={modalState.onOpen}>
|
||||
View on all pets <ChevronRightIcon />
|
||||
</Button>
|
||||
<AllItemLayersSupportModal
|
||||
item={item}
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={modalState.onClose}
|
||||
/>
|
||||
</Flex>
|
||||
<HStack spacing="4" overflow="auto" paddingX="1">
|
||||
{itemLayers.map((itemLayer) => (
|
||||
<ItemSupportAppearanceLayer
|
||||
key={itemLayer.id}
|
||||
item={item}
|
||||
itemLayer={itemLayer}
|
||||
biologyLayers={biologyLayers}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
);
|
||||
return (
|
||||
<FormControl>
|
||||
<Flex align="center">
|
||||
<FormLabel>Appearance layers</FormLabel>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<Button size="xs" onClick={modalState.onOpen}>
|
||||
View on all pets <ChevronRightIcon />
|
||||
</Button>
|
||||
<AllItemLayersSupportModal
|
||||
item={item}
|
||||
isOpen={modalState.isOpen}
|
||||
onClose={modalState.onClose}
|
||||
/>
|
||||
</Flex>
|
||||
<HStack spacing="4" overflow="auto" paddingX="1">
|
||||
{itemLayers.map((itemLayer) => (
|
||||
<ItemSupportAppearanceLayer
|
||||
key={itemLayer.id}
|
||||
item={item}
|
||||
itemLayer={itemLayer}
|
||||
biologyLayers={biologyLayers}
|
||||
outfitState={outfitState}
|
||||
/>
|
||||
))}
|
||||
</HStack>
|
||||
{error && <FormErrorMessage>{error.message}</FormErrorMessage>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemSupportDrawer;
|
||||
|
|
|
@ -6,34 +6,34 @@ import { Box } from "@chakra-ui/react";
|
|||
* and their values.
|
||||
*/
|
||||
function Metadata({ children, ...props }) {
|
||||
return (
|
||||
<Box
|
||||
as="dl"
|
||||
display="grid"
|
||||
gridTemplateColumns="max-content auto"
|
||||
gridRowGap="1"
|
||||
gridColumnGap="2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
as="dl"
|
||||
display="grid"
|
||||
gridTemplateColumns="max-content auto"
|
||||
gridRowGap="1"
|
||||
gridColumnGap="2"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataLabel({ children, ...props }) {
|
||||
return (
|
||||
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box as="dt" gridColumn="1" fontWeight="bold" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function MetadataValue({ children, ...props }) {
|
||||
return (
|
||||
<Box as="dd" gridColumn="2" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box as="dd" gridColumn="2" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export default Metadata;
|
||||
|
|
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.
|
||||
*/
|
||||
function SupportOnly({ children }) {
|
||||
const { isSupportUser } = useSupport();
|
||||
return isSupportUser ? children : null;
|
||||
const { isSupportUser } = useSupport();
|
||||
return isSupportUser ? children : null;
|
||||
}
|
||||
|
||||
export default SupportOnly;
|
||||
|
|
|
@ -23,11 +23,11 @@ import { getSupportSecret } from "../../impress-2020-config";
|
|||
* the server checks the provided secret for each Support request.
|
||||
*/
|
||||
function useSupport() {
|
||||
const supportSecret = getSupportSecret();
|
||||
const supportSecret = getSupportSecret();
|
||||
|
||||
const isSupportUser = supportSecret != null;
|
||||
const isSupportUser = supportSecret != null;
|
||||
|
||||
return { isSupportUser, supportSecret };
|
||||
return { isSupportUser, supportSecret };
|
||||
}
|
||||
|
||||
export default useSupport;
|
||||
|
|
|
@ -7,166 +7,166 @@ import { outfitStatesAreEqual } from "./useOutfitState";
|
|||
import { useSaveOutfitMutation } from "../loaders/outfits";
|
||||
|
||||
function useOutfitSaving(outfitState, dispatchToOutfit) {
|
||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { isLoggedIn, id: currentUserId } = useCurrentUser();
|
||||
const { pathname } = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
|
||||
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
|
||||
// to the server.
|
||||
const isNewOutfit = outfitState.id == null;
|
||||
// Whether this outfit is new, i.e. local-only, i.e. has _never_ been saved
|
||||
// to the server.
|
||||
const isNewOutfit = outfitState.id == null;
|
||||
|
||||
// Whether this outfit's latest local changes have been saved to the server.
|
||||
// And log it to the console!
|
||||
const latestVersionIsSaved =
|
||||
outfitState.savedOutfitState &&
|
||||
outfitStatesAreEqual(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
console.debug(
|
||||
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
);
|
||||
}, [
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
]);
|
||||
// Whether this outfit's latest local changes have been saved to the server.
|
||||
// And log it to the console!
|
||||
const latestVersionIsSaved =
|
||||
outfitState.savedOutfitState &&
|
||||
outfitStatesAreEqual(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
console.debug(
|
||||
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
);
|
||||
}, [
|
||||
latestVersionIsSaved,
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
outfitState.savedOutfitState,
|
||||
]);
|
||||
|
||||
// Only logged-in users can save outfits - and they can only save new outfits,
|
||||
// or outfits they created.
|
||||
const canSaveOutfit =
|
||||
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
||||
// Only logged-in users can save outfits - and they can only save new outfits,
|
||||
// or outfits they created.
|
||||
const canSaveOutfit =
|
||||
isLoggedIn && (isNewOutfit || outfitState.creator?.id === currentUserId);
|
||||
|
||||
// Users can delete their own outfits too. The logic is slightly different
|
||||
// than for saving, because you can save an outfit that hasn't been saved
|
||||
// yet, but you can't delete it.
|
||||
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
|
||||
// Users can delete their own outfits too. The logic is slightly different
|
||||
// than for saving, because you can save an outfit that hasn't been saved
|
||||
// yet, but you can't delete it.
|
||||
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
|
||||
|
||||
const saveOutfitMutation = useSaveOutfitMutation({
|
||||
onSuccess: (outfit) => {
|
||||
dispatchToOutfit({
|
||||
type: "handleOutfitSaveResponse",
|
||||
outfitData: outfit,
|
||||
});
|
||||
},
|
||||
});
|
||||
const isSaving = saveOutfitMutation.isPending;
|
||||
const saveError = saveOutfitMutation.error;
|
||||
const saveOutfitMutation = useSaveOutfitMutation({
|
||||
onSuccess: (outfit) => {
|
||||
dispatchToOutfit({
|
||||
type: "handleOutfitSaveResponse",
|
||||
outfitData: outfit,
|
||||
});
|
||||
},
|
||||
});
|
||||
const isSaving = saveOutfitMutation.isPending;
|
||||
const saveError = saveOutfitMutation.error;
|
||||
|
||||
const saveOutfitFromProvidedState = React.useCallback(
|
||||
(outfitState) => {
|
||||
saveOutfitMutation
|
||||
.mutateAsync({
|
||||
id: outfitState.id,
|
||||
name: outfitState.name,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
altStyleId: outfitState.altStyleId,
|
||||
wornItemIds: [...outfitState.wornItemIds],
|
||||
closetedItemIds: [...outfitState.closetedItemIds],
|
||||
})
|
||||
.then((outfit) => {
|
||||
// Navigate to the new saved outfit URL. Our Apollo cache should pick
|
||||
// up the data from this mutation response, and combine it with the
|
||||
// existing cached data, to make this smooth without any loading UI.
|
||||
if (pathname !== `/outfits/[outfitId]`) {
|
||||
navigate(`/outfits/${outfit.id}`);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, there was an error saving this outfit!",
|
||||
description: "Maybe check your connection and try again.",
|
||||
});
|
||||
});
|
||||
},
|
||||
// It's important that this callback _doesn't_ change when the outfit
|
||||
// changes, so that the auto-save effect is only responding to the
|
||||
// debounced state!
|
||||
[saveOutfitMutation, pathname, navigate, toast],
|
||||
);
|
||||
const saveOutfitFromProvidedState = React.useCallback(
|
||||
(outfitState) => {
|
||||
saveOutfitMutation
|
||||
.mutateAsync({
|
||||
id: outfitState.id,
|
||||
name: outfitState.name,
|
||||
speciesId: outfitState.speciesId,
|
||||
colorId: outfitState.colorId,
|
||||
pose: outfitState.pose,
|
||||
appearanceId: outfitState.appearanceId,
|
||||
altStyleId: outfitState.altStyleId,
|
||||
wornItemIds: [...outfitState.wornItemIds],
|
||||
closetedItemIds: [...outfitState.closetedItemIds],
|
||||
})
|
||||
.then((outfit) => {
|
||||
// Navigate to the new saved outfit URL. Our Apollo cache should pick
|
||||
// up the data from this mutation response, and combine it with the
|
||||
// existing cached data, to make this smooth without any loading UI.
|
||||
if (pathname !== `/outfits/[outfitId]`) {
|
||||
navigate(`/outfits/${outfit.id}`);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
toast({
|
||||
status: "error",
|
||||
title: "Sorry, there was an error saving this outfit!",
|
||||
description: "Maybe check your connection and try again.",
|
||||
});
|
||||
});
|
||||
},
|
||||
// It's important that this callback _doesn't_ change when the outfit
|
||||
// changes, so that the auto-save effect is only responding to the
|
||||
// debounced state!
|
||||
[saveOutfitMutation, pathname, navigate, toast],
|
||||
);
|
||||
|
||||
const saveOutfit = React.useCallback(
|
||||
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
|
||||
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras],
|
||||
);
|
||||
const saveOutfit = React.useCallback(
|
||||
() => saveOutfitFromProvidedState(outfitState.outfitStateWithoutExtras),
|
||||
[saveOutfitFromProvidedState, outfitState.outfitStateWithoutExtras],
|
||||
);
|
||||
|
||||
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
|
||||
// which only contains the basic fields, and will keep a stable object
|
||||
// identity until actual changes occur. Then, save the outfit after the user
|
||||
// has left it alone for long enough, so long as it's actually different
|
||||
// than the saved state.
|
||||
const debouncedOutfitState = useDebounce(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
2000,
|
||||
{
|
||||
// When the outfit ID changes, update the debounced state immediately!
|
||||
forceReset: (debouncedOutfitState, newOutfitState) =>
|
||||
debouncedOutfitState.id !== newOutfitState.id,
|
||||
},
|
||||
);
|
||||
// HACK: This prevents us from auto-saving the outfit state that's still
|
||||
// loading. I worry that this might not catch other loading scenarios
|
||||
// though, like if the species/color/pose is in the GQL cache, but the
|
||||
// items are still loading in... not sure where this would happen tho!
|
||||
const debouncedOutfitStateIsSaveable =
|
||||
debouncedOutfitState.speciesId &&
|
||||
debouncedOutfitState.colorId &&
|
||||
debouncedOutfitState.pose;
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!isNewOutfit &&
|
||||
canSaveOutfit &&
|
||||
!isSaving &&
|
||||
!saveError &&
|
||||
debouncedOutfitStateIsSaveable &&
|
||||
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
|
||||
) {
|
||||
console.info(
|
||||
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
|
||||
outfitState.savedOutfitState,
|
||||
debouncedOutfitState,
|
||||
);
|
||||
saveOutfitFromProvidedState(debouncedOutfitState);
|
||||
}
|
||||
}, [
|
||||
isNewOutfit,
|
||||
canSaveOutfit,
|
||||
isSaving,
|
||||
saveError,
|
||||
debouncedOutfitState,
|
||||
debouncedOutfitStateIsSaveable,
|
||||
outfitState.savedOutfitState,
|
||||
saveOutfitFromProvidedState,
|
||||
]);
|
||||
// Auto-saving! First, debounce the outfit state. Use `outfitStateWithoutExtras`,
|
||||
// which only contains the basic fields, and will keep a stable object
|
||||
// identity until actual changes occur. Then, save the outfit after the user
|
||||
// has left it alone for long enough, so long as it's actually different
|
||||
// than the saved state.
|
||||
const debouncedOutfitState = useDebounce(
|
||||
outfitState.outfitStateWithoutExtras,
|
||||
2000,
|
||||
{
|
||||
// When the outfit ID changes, update the debounced state immediately!
|
||||
forceReset: (debouncedOutfitState, newOutfitState) =>
|
||||
debouncedOutfitState.id !== newOutfitState.id,
|
||||
},
|
||||
);
|
||||
// HACK: This prevents us from auto-saving the outfit state that's still
|
||||
// loading. I worry that this might not catch other loading scenarios
|
||||
// though, like if the species/color/pose is in the GQL cache, but the
|
||||
// items are still loading in... not sure where this would happen tho!
|
||||
const debouncedOutfitStateIsSaveable =
|
||||
debouncedOutfitState.speciesId &&
|
||||
debouncedOutfitState.colorId &&
|
||||
debouncedOutfitState.pose;
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!isNewOutfit &&
|
||||
canSaveOutfit &&
|
||||
!isSaving &&
|
||||
!saveError &&
|
||||
debouncedOutfitStateIsSaveable &&
|
||||
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
|
||||
) {
|
||||
console.info(
|
||||
"[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
|
||||
outfitState.savedOutfitState,
|
||||
debouncedOutfitState,
|
||||
);
|
||||
saveOutfitFromProvidedState(debouncedOutfitState);
|
||||
}
|
||||
}, [
|
||||
isNewOutfit,
|
||||
canSaveOutfit,
|
||||
isSaving,
|
||||
saveError,
|
||||
debouncedOutfitState,
|
||||
debouncedOutfitStateIsSaveable,
|
||||
outfitState.savedOutfitState,
|
||||
saveOutfitFromProvidedState,
|
||||
]);
|
||||
|
||||
// When the outfit changes, clear out the error state from previous saves.
|
||||
// We'll send the mutation again after the debounce, and we don't want to
|
||||
// show the error UI in the meantime!
|
||||
const resetMutation = saveOutfitMutation.reset;
|
||||
React.useEffect(
|
||||
() => resetMutation(),
|
||||
[outfitState.outfitStateWithoutExtras, resetMutation],
|
||||
);
|
||||
// When the outfit changes, clear out the error state from previous saves.
|
||||
// We'll send the mutation again after the debounce, and we don't want to
|
||||
// show the error UI in the meantime!
|
||||
const resetMutation = saveOutfitMutation.reset;
|
||||
React.useEffect(
|
||||
() => resetMutation(),
|
||||
[outfitState.outfitStateWithoutExtras, resetMutation],
|
||||
);
|
||||
|
||||
return {
|
||||
canSaveOutfit,
|
||||
canDeleteOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
};
|
||||
return {
|
||||
canSaveOutfit,
|
||||
canDeleteOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
};
|
||||
}
|
||||
|
||||
export default useOutfitSaving;
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -7,76 +7,76 @@ import { SEARCH_PER_PAGE } from "./SearchPanel";
|
|||
* useSearchResults manages the actual querying and state management of search!
|
||||
*/
|
||||
export function useSearchResults(
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber,
|
||||
{ skip = false } = {},
|
||||
query,
|
||||
outfitState,
|
||||
currentPageNumber,
|
||||
{ skip = false } = {},
|
||||
) {
|
||||
const { speciesId, colorId, altStyleId } = outfitState;
|
||||
const { speciesId, colorId, altStyleId } = outfitState;
|
||||
|
||||
// We debounce the search query, so that we don't resend a new query whenever
|
||||
// the user types anything.
|
||||
const debouncedQuery = useDebounce(query, 300, {
|
||||
waitForFirstPause: true,
|
||||
initialValue: emptySearchQuery,
|
||||
});
|
||||
// We debounce the search query, so that we don't resend a new query whenever
|
||||
// the user types anything.
|
||||
const debouncedQuery = useDebounce(query, 300, {
|
||||
waitForFirstPause: true,
|
||||
initialValue: emptySearchQuery,
|
||||
});
|
||||
|
||||
const { isLoading, error, data } = useItemSearch(
|
||||
{
|
||||
filters: buildSearchFilters(debouncedQuery, outfitState),
|
||||
withAppearancesFor: { speciesId, colorId, altStyleId },
|
||||
page: currentPageNumber,
|
||||
perPage: SEARCH_PER_PAGE,
|
||||
},
|
||||
{
|
||||
enabled: !skip && !searchQueryIsEmpty(debouncedQuery),
|
||||
},
|
||||
);
|
||||
const { isLoading, error, data } = useItemSearch(
|
||||
{
|
||||
filters: buildSearchFilters(debouncedQuery, outfitState),
|
||||
withAppearancesFor: { speciesId, colorId, altStyleId },
|
||||
page: currentPageNumber,
|
||||
perPage: SEARCH_PER_PAGE,
|
||||
},
|
||||
{
|
||||
enabled: !skip && !searchQueryIsEmpty(debouncedQuery),
|
||||
},
|
||||
);
|
||||
|
||||
const loading = debouncedQuery !== query || isLoading;
|
||||
const items = data?.items ?? [];
|
||||
const numTotalPages = data?.numTotalPages ?? 0;
|
||||
const loading = debouncedQuery !== query || isLoading;
|
||||
const items = data?.items ?? [];
|
||||
const numTotalPages = data?.numTotalPages ?? 0;
|
||||
|
||||
return { loading, error, items, numTotalPages };
|
||||
return { loading, error, items, numTotalPages };
|
||||
}
|
||||
|
||||
function buildSearchFilters(query, { speciesId, colorId, altStyleId }) {
|
||||
const filters = [];
|
||||
const filters = [];
|
||||
|
||||
// TODO: We're missing quote support, like `background "Dyeworks White"`.
|
||||
// It might be good to, rather than parse this out here and send it as
|
||||
// filters, include a text-based part of the query as well, and have
|
||||
// the server merge them? That'd support text-based `is:nc` etc too.
|
||||
const words = query.value.split(/\s+/);
|
||||
for (const word of words) {
|
||||
filters.push({ key: "name", value: word });
|
||||
}
|
||||
// TODO: We're missing quote support, like `background "Dyeworks White"`.
|
||||
// It might be good to, rather than parse this out here and send it as
|
||||
// filters, include a text-based part of the query as well, and have
|
||||
// the server merge them? That'd support text-based `is:nc` etc too.
|
||||
const words = query.value.split(/\s+/);
|
||||
for (const word of words) {
|
||||
filters.push({ key: "name", value: word });
|
||||
}
|
||||
|
||||
if (query.filterToItemKind === "NC") {
|
||||
filters.push({ key: "is_nc" });
|
||||
} else if (query.filterToItemKind === "PB") {
|
||||
filters.push({ key: "is_pb" });
|
||||
} else if (query.filterToItemKind === "NP") {
|
||||
filters.push({ key: "is_np" });
|
||||
}
|
||||
if (query.filterToItemKind === "NC") {
|
||||
filters.push({ key: "is_nc" });
|
||||
} else if (query.filterToItemKind === "PB") {
|
||||
filters.push({ key: "is_pb" });
|
||||
} else if (query.filterToItemKind === "NP") {
|
||||
filters.push({ key: "is_np" });
|
||||
}
|
||||
|
||||
if (query.filterToZoneLabel != null) {
|
||||
filters.push({
|
||||
key: "occupied_zone_set_name",
|
||||
value: query.filterToZoneLabel,
|
||||
});
|
||||
}
|
||||
if (query.filterToZoneLabel != null) {
|
||||
filters.push({
|
||||
key: "occupied_zone_set_name",
|
||||
value: query.filterToZoneLabel,
|
||||
});
|
||||
}
|
||||
|
||||
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
||||
filters.push({ key: "user_closet_hanger_ownership", value: "true" });
|
||||
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
||||
filters.push({ key: "user_closet_hanger_ownership", value: "false" });
|
||||
}
|
||||
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
|
||||
filters.push({ key: "user_closet_hanger_ownership", value: "true" });
|
||||
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
|
||||
filters.push({ key: "user_closet_hanger_ownership", value: "false" });
|
||||
}
|
||||
|
||||
filters.push({
|
||||
key: "fits",
|
||||
value: { speciesId, colorId, altStyleId },
|
||||
});
|
||||
filters.push({
|
||||
key: "fits",
|
||||
value: { speciesId, colorId, altStyleId },
|
||||
});
|
||||
|
||||
return filters;
|
||||
return filters;
|
||||
}
|
||||
|
|
|
@ -6,175 +6,175 @@ import { buildImpress2020Url } from "./impress-2020-config";
|
|||
|
||||
// Use Apollo's error messages in development.
|
||||
if (process.env["NODE_ENV"] === "development") {
|
||||
loadErrorMessages();
|
||||
loadDevMessages();
|
||||
loadErrorMessages();
|
||||
loadDevMessages();
|
||||
}
|
||||
|
||||
// Teach Apollo to load certain fields from the cache, to avoid extra network
|
||||
// requests. This happens a lot - e.g. reusing data from item search on the
|
||||
// outfit immediately!
|
||||
const typePolicies = {
|
||||
Query: {
|
||||
fields: {
|
||||
closetList: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "ClosetList", id: args.id }, true);
|
||||
},
|
||||
items: (_, { args, toReference }) => {
|
||||
return args.ids.map((id) =>
|
||||
toReference({ __typename: "Item", id }, true),
|
||||
);
|
||||
},
|
||||
item: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Item", id: args.id }, true);
|
||||
},
|
||||
petAppearanceById: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "PetAppearance", id: args.id }, true);
|
||||
},
|
||||
species: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Species", id: args.id }, true);
|
||||
},
|
||||
color: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Color", id: args.id }, true);
|
||||
},
|
||||
outfit: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Outfit", id: args.id }, true);
|
||||
},
|
||||
user: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "User", id: args.id }, true);
|
||||
},
|
||||
},
|
||||
},
|
||||
Query: {
|
||||
fields: {
|
||||
closetList: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "ClosetList", id: args.id }, true);
|
||||
},
|
||||
items: (_, { args, toReference }) => {
|
||||
return args.ids.map((id) =>
|
||||
toReference({ __typename: "Item", id }, true),
|
||||
);
|
||||
},
|
||||
item: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Item", id: args.id }, true);
|
||||
},
|
||||
petAppearanceById: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "PetAppearance", id: args.id }, true);
|
||||
},
|
||||
species: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Species", id: args.id }, true);
|
||||
},
|
||||
color: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Color", id: args.id }, true);
|
||||
},
|
||||
outfit: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "Outfit", id: args.id }, true);
|
||||
},
|
||||
user: (_, { args, toReference }) => {
|
||||
return toReference({ __typename: "User", id: args.id }, true);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Item: {
|
||||
fields: {
|
||||
appearanceOn: (appearance, { args, readField, toReference }) => {
|
||||
// If we already have this exact appearance in the cache, serve it!
|
||||
if (appearance) {
|
||||
return appearance;
|
||||
}
|
||||
Item: {
|
||||
fields: {
|
||||
appearanceOn: (appearance, { args, readField, toReference }) => {
|
||||
// If we already have this exact appearance in the cache, serve it!
|
||||
if (appearance) {
|
||||
return appearance;
|
||||
}
|
||||
|
||||
const { speciesId, colorId, altStyleId } = args;
|
||||
console.debug(
|
||||
"[appearanceOn] seeking cached appearance",
|
||||
speciesId,
|
||||
colorId,
|
||||
altStyleId,
|
||||
readField("id"),
|
||||
);
|
||||
const { speciesId, colorId, altStyleId } = args;
|
||||
console.debug(
|
||||
"[appearanceOn] seeking cached appearance",
|
||||
speciesId,
|
||||
colorId,
|
||||
altStyleId,
|
||||
readField("id"),
|
||||
);
|
||||
|
||||
// If this is an alt style, don't try to mess with clever caching.
|
||||
// (Note that, if it's already in the cache, the first condition will
|
||||
// catch that! This won't *always* force a fresh load!)
|
||||
if (altStyleId != null) {
|
||||
return undefined;
|
||||
}
|
||||
// If this is an alt style, don't try to mess with clever caching.
|
||||
// (Note that, if it's already in the cache, the first condition will
|
||||
// catch that! This won't *always* force a fresh load!)
|
||||
if (altStyleId != null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Otherwise, we're going to see if this is a standard color, in which
|
||||
// case we can reuse the standard color appearance if we already have
|
||||
// it! This helps for fast loading when switching between standard
|
||||
// colors.
|
||||
const speciesStandardBodyId = readField(
|
||||
"standardBodyId",
|
||||
toReference({ __typename: "Species", id: speciesId }),
|
||||
);
|
||||
const colorIsStandard = readField(
|
||||
"isStandard",
|
||||
toReference({ __typename: "Color", id: colorId }),
|
||||
);
|
||||
if (speciesStandardBodyId == null || colorIsStandard == null) {
|
||||
// We haven't loaded all the species/colors into cache yet. We might
|
||||
// be loading them, depending on the page? Either way, return
|
||||
// `undefined`, meaning we don't know how to serve this from cache.
|
||||
// This will cause us to start loading it from the server.
|
||||
console.debug("[appearanceOn] species/colors not loaded yet");
|
||||
return undefined;
|
||||
}
|
||||
// Otherwise, we're going to see if this is a standard color, in which
|
||||
// case we can reuse the standard color appearance if we already have
|
||||
// it! This helps for fast loading when switching between standard
|
||||
// colors.
|
||||
const speciesStandardBodyId = readField(
|
||||
"standardBodyId",
|
||||
toReference({ __typename: "Species", id: speciesId }),
|
||||
);
|
||||
const colorIsStandard = readField(
|
||||
"isStandard",
|
||||
toReference({ __typename: "Color", id: colorId }),
|
||||
);
|
||||
if (speciesStandardBodyId == null || colorIsStandard == null) {
|
||||
// We haven't loaded all the species/colors into cache yet. We might
|
||||
// be loading them, depending on the page? Either way, return
|
||||
// `undefined`, meaning we don't know how to serve this from cache.
|
||||
// This will cause us to start loading it from the server.
|
||||
console.debug("[appearanceOn] species/colors not loaded yet");
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (colorIsStandard) {
|
||||
const itemId = readField("id");
|
||||
console.debug(
|
||||
"[appearanceOn] standard color, will read:",
|
||||
`item-${itemId}-body-${speciesStandardBodyId}`,
|
||||
);
|
||||
return toReference({
|
||||
__typename: "ItemAppearance",
|
||||
id: `item-${itemId}-body-${speciesStandardBodyId}`,
|
||||
});
|
||||
} else {
|
||||
console.debug("[appearanceOn] non-standard color, failure");
|
||||
// This isn't a standard color, so we don't support special
|
||||
// cross-color caching for it. Return `undefined`, meaning we don't
|
||||
// know how to serve this from cache. This will cause us to start
|
||||
// loading it from the server.
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
if (colorIsStandard) {
|
||||
const itemId = readField("id");
|
||||
console.debug(
|
||||
"[appearanceOn] standard color, will read:",
|
||||
`item-${itemId}-body-${speciesStandardBodyId}`,
|
||||
);
|
||||
return toReference({
|
||||
__typename: "ItemAppearance",
|
||||
id: `item-${itemId}-body-${speciesStandardBodyId}`,
|
||||
});
|
||||
} else {
|
||||
console.debug("[appearanceOn] non-standard color, failure");
|
||||
// This isn't a standard color, so we don't support special
|
||||
// cross-color caching for it. Return `undefined`, meaning we don't
|
||||
// know how to serve this from cache. This will cause us to start
|
||||
// loading it from the server.
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
currentUserOwnsThis: (cachedValue, { readField }) => {
|
||||
if (cachedValue != null) {
|
||||
return cachedValue;
|
||||
}
|
||||
currentUserOwnsThis: (cachedValue, { readField }) => {
|
||||
if (cachedValue != null) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
// Do we know what items this user owns? If so, scan for this item.
|
||||
const currentUserRef = readField("currentUser", {
|
||||
__ref: "ROOT_QUERY",
|
||||
});
|
||||
if (!currentUserRef) {
|
||||
return undefined;
|
||||
}
|
||||
const thisItemId = readField("id");
|
||||
const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef);
|
||||
if (!itemsTheyOwn) {
|
||||
return undefined;
|
||||
}
|
||||
// Do we know what items this user owns? If so, scan for this item.
|
||||
const currentUserRef = readField("currentUser", {
|
||||
__ref: "ROOT_QUERY",
|
||||
});
|
||||
if (!currentUserRef) {
|
||||
return undefined;
|
||||
}
|
||||
const thisItemId = readField("id");
|
||||
const itemsTheyOwn = readField("itemsTheyOwn", currentUserRef);
|
||||
if (!itemsTheyOwn) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const theyOwnThisItem = itemsTheyOwn.some(
|
||||
(itemRef) => readField("id", itemRef) === thisItemId,
|
||||
);
|
||||
return theyOwnThisItem;
|
||||
},
|
||||
currentUserWantsThis: (cachedValue, { readField }) => {
|
||||
if (cachedValue != null) {
|
||||
return cachedValue;
|
||||
}
|
||||
const theyOwnThisItem = itemsTheyOwn.some(
|
||||
(itemRef) => readField("id", itemRef) === thisItemId,
|
||||
);
|
||||
return theyOwnThisItem;
|
||||
},
|
||||
currentUserWantsThis: (cachedValue, { readField }) => {
|
||||
if (cachedValue != null) {
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
// Do we know what items this user owns? If so, scan for this item.
|
||||
const currentUserRef = readField("currentUser", {
|
||||
__ref: "ROOT_QUERY",
|
||||
});
|
||||
if (!currentUserRef) {
|
||||
return undefined;
|
||||
}
|
||||
const thisItemId = readField("id");
|
||||
const itemsTheyWant = readField("itemsTheyWant", currentUserRef);
|
||||
if (!itemsTheyWant) {
|
||||
return undefined;
|
||||
}
|
||||
// Do we know what items this user owns? If so, scan for this item.
|
||||
const currentUserRef = readField("currentUser", {
|
||||
__ref: "ROOT_QUERY",
|
||||
});
|
||||
if (!currentUserRef) {
|
||||
return undefined;
|
||||
}
|
||||
const thisItemId = readField("id");
|
||||
const itemsTheyWant = readField("itemsTheyWant", currentUserRef);
|
||||
if (!itemsTheyWant) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const theyWantThisItem = itemsTheyWant.some(
|
||||
(itemRef) => readField("id", itemRef) === thisItemId,
|
||||
);
|
||||
return theyWantThisItem;
|
||||
},
|
||||
},
|
||||
},
|
||||
const theyWantThisItem = itemsTheyWant.some(
|
||||
(itemRef) => readField("id", itemRef) === thisItemId,
|
||||
);
|
||||
return theyWantThisItem;
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ClosetList: {
|
||||
fields: {
|
||||
// When loading the updated contents of a list, replace it entirely.
|
||||
items: { merge: false },
|
||||
},
|
||||
},
|
||||
ClosetList: {
|
||||
fields: {
|
||||
// When loading the updated contents of a list, replace it entirely.
|
||||
items: { merge: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const cache = new InMemoryCache({ typePolicies });
|
||||
|
||||
const httpLink = createHttpLink({
|
||||
uri: buildImpress2020Url("/api/graphql"),
|
||||
uri: buildImpress2020Url("/api/graphql"),
|
||||
});
|
||||
|
||||
const link = createPersistedQueryLink({
|
||||
useGETForHashedQueries: true,
|
||||
useGETForHashedQueries: true,
|
||||
}).concat(httpLink);
|
||||
|
||||
/**
|
||||
|
@ -182,9 +182,9 @@ const link = createPersistedQueryLink({
|
|||
* queries. This is how we communicate with the server!
|
||||
*/
|
||||
const apolloClient = new ApolloClient({
|
||||
link,
|
||||
cache,
|
||||
connectToDevTools: true,
|
||||
link,
|
||||
cache,
|
||||
connectToDevTools: true,
|
||||
});
|
||||
|
||||
export default apolloClient;
|
||||
|
|
|
@ -3,145 +3,145 @@ import { Tooltip, useColorModeValue, Flex, Icon } from "@chakra-ui/react";
|
|||
import { CheckCircleIcon, WarningTwoIcon } from "@chakra-ui/icons";
|
||||
|
||||
function HTML5Badge({ usesHTML5, isLoading, tooltipLabel }) {
|
||||
// `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
|
||||
// `isLoading` was `false`. This enables us to keep showing the badge, even
|
||||
// when loading a new appearance - because it's unlikely the badge will
|
||||
// change between different appearances for the same item, and the flicker is
|
||||
// annoying!
|
||||
const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setDelayedUsesHTML5(usesHTML5);
|
||||
}
|
||||
}, [usesHTML5, isLoading]);
|
||||
// `delayedUsesHTML5` stores the last known value of `usesHTML5`, when
|
||||
// `isLoading` was `false`. This enables us to keep showing the badge, even
|
||||
// when loading a new appearance - because it's unlikely the badge will
|
||||
// change between different appearances for the same item, and the flicker is
|
||||
// annoying!
|
||||
const [delayedUsesHTML5, setDelayedUsesHTML5] = React.useState(null);
|
||||
React.useEffect(() => {
|
||||
if (!isLoading) {
|
||||
setDelayedUsesHTML5(usesHTML5);
|
||||
}
|
||||
}, [usesHTML5, isLoading]);
|
||||
|
||||
if (delayedUsesHTML5 === true) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={false}
|
||||
aria-label="HTML5 supported!"
|
||||
tooltipLabel={
|
||||
tooltipLabel ||
|
||||
"This item is converted to HTML5, and ready to use on Neopets.com!"
|
||||
}
|
||||
>
|
||||
<CheckCircleIcon fontSize="xs" />
|
||||
<Icon
|
||||
viewBox="0 0 36 36"
|
||||
fontSize="xl"
|
||||
// Visual re-balancing, there's too much visual right-padding here!
|
||||
marginRight="-1"
|
||||
>
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else if (delayedUsesHTML5 === false) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={true}
|
||||
aria-label="HTML5 not supported"
|
||||
tooltipLabel={
|
||||
tooltipLabel || (
|
||||
<>
|
||||
This item isn't converted to HTML5 yet, so it might not appear in
|
||||
Neopets.com customization yet. Once it's ready, it could look a
|
||||
bit different than our temporary preview here. It might even be
|
||||
animated!
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<Icon viewBox="0 0 36 36" fontSize="xl">
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
if (delayedUsesHTML5 === true) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={false}
|
||||
aria-label="HTML5 supported!"
|
||||
tooltipLabel={
|
||||
tooltipLabel ||
|
||||
"This item is converted to HTML5, and ready to use on Neopets.com!"
|
||||
}
|
||||
>
|
||||
<CheckCircleIcon fontSize="xs" />
|
||||
<Icon
|
||||
viewBox="0 0 36 36"
|
||||
fontSize="xl"
|
||||
// Visual re-balancing, there's too much visual right-padding here!
|
||||
marginRight="-1"
|
||||
>
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else if (delayedUsesHTML5 === false) {
|
||||
return (
|
||||
<GlitchBadgeLayout
|
||||
hasGlitches={true}
|
||||
aria-label="HTML5 not supported"
|
||||
tooltipLabel={
|
||||
tooltipLabel || (
|
||||
<>
|
||||
This item isn't converted to HTML5 yet, so it might not appear in
|
||||
Neopets.com customization yet. Once it's ready, it could look a
|
||||
bit different than our temporary preview here. It might even be
|
||||
animated!
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
<WarningTwoIcon fontSize="xs" marginRight="1" />
|
||||
<Icon viewBox="0 0 36 36" fontSize="xl">
|
||||
{/* From Twemoji Keycap 5 */}
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"
|
||||
/>
|
||||
|
||||
{/* From Twemoji Not Allowed */}
|
||||
<path
|
||||
fill="#DD2E44"
|
||||
opacity="0.75"
|
||||
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else {
|
||||
// If no `usesHTML5` value has been provided yet, we're empty for now!
|
||||
return null;
|
||||
}
|
||||
{/* From Twemoji Not Allowed */}
|
||||
<path
|
||||
fill="#DD2E44"
|
||||
opacity="0.75"
|
||||
d="M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"
|
||||
/>
|
||||
</Icon>
|
||||
</GlitchBadgeLayout>
|
||||
);
|
||||
} else {
|
||||
// If no `usesHTML5` value has been provided yet, we're empty for now!
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function GlitchBadgeLayout({
|
||||
hasGlitches = true,
|
||||
children,
|
||||
tooltipLabel,
|
||||
...props
|
||||
hasGlitches = true,
|
||||
children,
|
||||
tooltipLabel,
|
||||
...props
|
||||
}) {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [isFocused, setIsFocused] = React.useState(false);
|
||||
|
||||
const greenBackground = useColorModeValue("green.100", "green.900");
|
||||
const greenBorderColor = useColorModeValue("green.600", "green.500");
|
||||
const greenTextColor = useColorModeValue("green.700", "white");
|
||||
const greenBackground = useColorModeValue("green.100", "green.900");
|
||||
const greenBorderColor = useColorModeValue("green.600", "green.500");
|
||||
const greenTextColor = useColorModeValue("green.700", "white");
|
||||
|
||||
const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
|
||||
const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
|
||||
const yellowTextColor = useColorModeValue("yellow.700", "white");
|
||||
const yellowBackground = useColorModeValue("yellow.100", "yellow.900");
|
||||
const yellowBorderColor = useColorModeValue("yellow.600", "yellow.500");
|
||||
const yellowTextColor = useColorModeValue("yellow.700", "white");
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
placement="bottom"
|
||||
label={tooltipLabel}
|
||||
// HACK: Chakra tooltips seem inconsistent about staying open when focus
|
||||
// comes from touch events. But I really want this one to work on
|
||||
// mobile!
|
||||
isOpen={isHovered || isFocused}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
|
||||
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
|
||||
color={hasGlitches ? yellowTextColor : greenTextColor}
|
||||
border="1px solid"
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
paddingX="2"
|
||||
paddingY="1"
|
||||
transition="all 0.2s"
|
||||
tabIndex="0"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
|
||||
minHeight="30px"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
textAlign="center"
|
||||
fontSize="xs"
|
||||
placement="bottom"
|
||||
label={tooltipLabel}
|
||||
// HACK: Chakra tooltips seem inconsistent about staying open when focus
|
||||
// comes from touch events. But I really want this one to work on
|
||||
// mobile!
|
||||
isOpen={isHovered || isFocused}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
backgroundColor={hasGlitches ? yellowBackground : greenBackground}
|
||||
borderColor={hasGlitches ? yellowBorderColor : greenBorderColor}
|
||||
color={hasGlitches ? yellowTextColor : greenTextColor}
|
||||
border="1px solid"
|
||||
borderRadius="md"
|
||||
boxShadow="md"
|
||||
paddingX="2"
|
||||
paddingY="1"
|
||||
transition="all 0.2s"
|
||||
tabIndex="0"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
// For consistency between the HTML5Badge & OutfitKnownGlitchesBadge
|
||||
minHeight="30px"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export function layerUsesHTML5(layer) {
|
||||
return Boolean(
|
||||
layer.svgUrl ||
|
||||
layer.canvasMovieLibraryUrl ||
|
||||
// If this glitch is applied, then `svgUrl` will be null, but there's still
|
||||
// an HTML5 manifest that the official player can render.
|
||||
(layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
|
||||
);
|
||||
return Boolean(
|
||||
layer.svgUrl ||
|
||||
layer.canvasMovieLibraryUrl ||
|
||||
// If this glitch is applied, then `svgUrl` will be null, but there's still
|
||||
// an HTML5 manifest that the official player can render.
|
||||
(layer.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
|
||||
);
|
||||
}
|
||||
|
||||
export default HTML5Badge;
|
||||
|
|
|
@ -4,94 +4,94 @@ import { Box, useColorModeValue } from "@chakra-ui/react";
|
|||
import { createIcon } from "@chakra-ui/icons";
|
||||
|
||||
const HangerIcon = createIcon({
|
||||
displayName: "HangerIcon",
|
||||
displayName: "HangerIcon",
|
||||
|
||||
// https://www.svgrepo.com/svg/108090/clothes-hanger
|
||||
viewBox: "0 0 473 473",
|
||||
path: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
|
||||
/>
|
||||
),
|
||||
// https://www.svgrepo.com/svg/108090/clothes-hanger
|
||||
viewBox: "0 0 473 473",
|
||||
path: (
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
|
||||
/>
|
||||
),
|
||||
});
|
||||
|
||||
function HangerSpinner({ size = "md", ...props }) {
|
||||
const boxSize = { sm: "32px", md: "48px" }[size];
|
||||
const color = useColorModeValue("green.500", "green.300");
|
||||
const boxSize = { sm: "32px", md: "48px" }[size];
|
||||
const color = useColorModeValue("green.500", "green.300");
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
className={css`
|
||||
/*
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
className={css`
|
||||
/*
|
||||
Adapted from animate.css "swing". We spend 75% of the time swinging,
|
||||
then 25% of the time pausing before the next loop.
|
||||
|
||||
We use this animation for folks who are okay with dizzy-ish motion.
|
||||
For reduced motion, we use a pulse-fade instead.
|
||||
*/
|
||||
@keyframes swing {
|
||||
15% {
|
||||
transform: rotate3d(0, 0, 1, 15deg);
|
||||
}
|
||||
@keyframes swing {
|
||||
15% {
|
||||
transform: rotate3d(0, 0, 1, 15deg);
|
||||
}
|
||||
|
||||
30% {
|
||||
transform: rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
30% {
|
||||
transform: rotate3d(0, 0, 1, -10deg);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: rotate3d(0, 0, 1, 5deg);
|
||||
}
|
||||
45% {
|
||||
transform: rotate3d(0, 0, 1, 5deg);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: rotate3d(0, 0, 1, -5deg);
|
||||
}
|
||||
60% {
|
||||
transform: rotate3d(0, 0, 1, -5deg);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
75% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
}
|
||||
100% {
|
||||
transform: rotate3d(0, 0, 1, 0deg);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
/*
|
||||
A homebrew fade-pulse animation. We use this for folks who don't
|
||||
like motion. It's an important accessibility thing!
|
||||
*/
|
||||
@keyframes fade-pulse {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
@keyframes fade-pulse {
|
||||
0% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
100% {
|
||||
opacity: 0.2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: 1.2s infinite swing;
|
||||
transform-origin: top center;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: 1.2s infinite swing;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: 1.6s infinite fade-pulse;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
animation: 1.6s infinite fade-pulse;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<HangerIcon boxSize={boxSize} color={color} transition="color 0.2s" />
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export default HangerSpinner;
|
||||
|
|
|
@ -1,20 +1,20 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
Badge,
|
||||
Box,
|
||||
SimpleGrid,
|
||||
Tooltip,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
EditIcon,
|
||||
NotAllowedIcon,
|
||||
StarIcon,
|
||||
CheckIcon,
|
||||
EditIcon,
|
||||
NotAllowedIcon,
|
||||
StarIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { HiSparkles } from "react-icons/hi";
|
||||
|
||||
|
@ -23,73 +23,73 @@ import { safeImageUrl, useCommonStyles } from "../util";
|
|||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
function ItemCard({ item, badges, variant = "list", ...props }) {
|
||||
const { brightBackground } = useCommonStyles();
|
||||
const { brightBackground } = useCommonStyles();
|
||||
|
||||
switch (variant) {
|
||||
case "grid":
|
||||
return <SquareItemCard item={item} {...props} />;
|
||||
case "list":
|
||||
return (
|
||||
<Box
|
||||
as="a"
|
||||
href={`/items/${item.id}`}
|
||||
display="block"
|
||||
p="2"
|
||||
boxShadow="lg"
|
||||
borderRadius="lg"
|
||||
background={brightBackground}
|
||||
transition="all 0.2s"
|
||||
className="item-card"
|
||||
width="100%"
|
||||
minWidth="0"
|
||||
{...props}
|
||||
>
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={badges}
|
||||
focusSelector=".item-card:hover &, .item-card:focus &"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected ItemCard variant: ${variant}`);
|
||||
}
|
||||
switch (variant) {
|
||||
case "grid":
|
||||
return <SquareItemCard item={item} {...props} />;
|
||||
case "list":
|
||||
return (
|
||||
<Box
|
||||
as="a"
|
||||
href={`/items/${item.id}`}
|
||||
display="block"
|
||||
p="2"
|
||||
boxShadow="lg"
|
||||
borderRadius="lg"
|
||||
background={brightBackground}
|
||||
transition="all 0.2s"
|
||||
className="item-card"
|
||||
width="100%"
|
||||
minWidth="0"
|
||||
{...props}
|
||||
>
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={badges}
|
||||
focusSelector=".item-card:hover &, .item-card:focus &"
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unexpected ItemCard variant: ${variant}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function ItemCardContent({
|
||||
item,
|
||||
badges,
|
||||
isWorn,
|
||||
isDisabled,
|
||||
itemNameId,
|
||||
focusSelector,
|
||||
item,
|
||||
badges,
|
||||
isWorn,
|
||||
isDisabled,
|
||||
itemNameId,
|
||||
focusSelector,
|
||||
}) {
|
||||
return (
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
<Box flex="0 0 auto" marginRight="3">
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
isActive={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flex="1 1 0" minWidth="0" marginTop="1px">
|
||||
<ItemName
|
||||
id={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
>
|
||||
{item.name}
|
||||
</ItemName>
|
||||
return (
|
||||
<Box display="flex">
|
||||
<Box>
|
||||
<Box flex="0 0 auto" marginRight="3">
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
isActive={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box flex="1 1 0" minWidth="0" marginTop="1px">
|
||||
<ItemName
|
||||
id={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDisabled={isDisabled}
|
||||
focusSelector={focusSelector}
|
||||
>
|
||||
{item.name}
|
||||
</ItemName>
|
||||
|
||||
{badges}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
{badges}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -97,88 +97,88 @@ export function ItemCardContent({
|
|||
* hover/focus and worn/unworn states.
|
||||
*/
|
||||
export function ItemThumbnail({
|
||||
item,
|
||||
size = "md",
|
||||
isActive,
|
||||
isDisabled,
|
||||
focusSelector,
|
||||
...props
|
||||
item,
|
||||
size = "md",
|
||||
isActive,
|
||||
isDisabled,
|
||||
focusSelector,
|
||||
...props
|
||||
}) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const theme = useTheme();
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const theme = useTheme();
|
||||
|
||||
const borderColor = useColorModeValue(
|
||||
theme.colors.green["700"],
|
||||
"transparent",
|
||||
);
|
||||
const borderColor = useColorModeValue(
|
||||
theme.colors.green["700"],
|
||||
"transparent",
|
||||
);
|
||||
|
||||
const focusBorderColor = useColorModeValue(
|
||||
theme.colors.green["600"],
|
||||
"transparent",
|
||||
);
|
||||
const focusBorderColor = useColorModeValue(
|
||||
theme.colors.green["600"],
|
||||
"transparent",
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
width={size === "lg" ? "80px" : "50px"}
|
||||
height={size === "lg" ? "80px" : "50px"}
|
||||
transition="all 0.15s"
|
||||
transformOrigin="center"
|
||||
position="relative"
|
||||
className={css([
|
||||
{
|
||||
transform: "scale(0.8)",
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
opacity: "0.9",
|
||||
transform: "scale(0.9)",
|
||||
},
|
||||
},
|
||||
!isDisabled &&
|
||||
isActive && {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
},
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={css([
|
||||
{
|
||||
borderColor: `${borderColor} !important`,
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
borderColor: `${focusBorderColor} !important`,
|
||||
},
|
||||
},
|
||||
])}
|
||||
>
|
||||
{/* If the item is still loading, wait with an empty box. */}
|
||||
{item && (
|
||||
<Box
|
||||
as="img"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
width={size === "lg" ? "80px" : "50px"}
|
||||
height={size === "lg" ? "80px" : "50px"}
|
||||
transition="all 0.15s"
|
||||
transformOrigin="center"
|
||||
position="relative"
|
||||
className={css([
|
||||
{
|
||||
transform: "scale(0.8)",
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
opacity: "0.9",
|
||||
transform: "scale(0.9)",
|
||||
},
|
||||
},
|
||||
!isDisabled &&
|
||||
isActive && {
|
||||
opacity: 1,
|
||||
transform: "none",
|
||||
},
|
||||
])}
|
||||
{...props}
|
||||
>
|
||||
<Box
|
||||
borderRadius="lg"
|
||||
boxShadow="md"
|
||||
border="1px"
|
||||
overflow="hidden"
|
||||
width="100%"
|
||||
height="100%"
|
||||
className={css([
|
||||
{
|
||||
borderColor: `${borderColor} !important`,
|
||||
},
|
||||
!isDisabled &&
|
||||
!isActive && {
|
||||
[focusSelector]: {
|
||||
borderColor: `${focusBorderColor} !important`,
|
||||
},
|
||||
},
|
||||
])}
|
||||
>
|
||||
{/* If the item is still loading, wait with an empty box. */}
|
||||
{item && (
|
||||
<Box
|
||||
as="img"
|
||||
width="100%"
|
||||
height="100%"
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -186,245 +186,245 @@ export function ItemThumbnail({
|
|||
* states.
|
||||
*/
|
||||
function ItemName({ children, isDisabled, focusSelector, ...props }) {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
fontSize="md"
|
||||
transition="all 0.15s"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
className={
|
||||
!isDisabled &&
|
||||
css`
|
||||
${focusSelector} {
|
||||
opacity: 0.9;
|
||||
font-weight: ${theme.fontWeights.medium};
|
||||
}
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
fontSize="md"
|
||||
transition="all 0.15s"
|
||||
overflow="hidden"
|
||||
whiteSpace="nowrap"
|
||||
textOverflow="ellipsis"
|
||||
className={
|
||||
!isDisabled &&
|
||||
css`
|
||||
${focusSelector} {
|
||||
opacity: 0.9;
|
||||
font-weight: ${theme.fontWeights.medium};
|
||||
}
|
||||
|
||||
input:checked + .item-container & {
|
||||
opacity: 1;
|
||||
font-weight: ${theme.fontWeights.bold};
|
||||
}
|
||||
`
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
input:checked + .item-container & {
|
||||
opacity: 1;
|
||||
font-weight: ${theme.fontWeights.bold};
|
||||
}
|
||||
`
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemCardList({ children }) {
|
||||
return (
|
||||
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
|
||||
{children}
|
||||
</SimpleGrid>
|
||||
);
|
||||
return (
|
||||
<SimpleGrid columns={{ sm: 1, md: 2, lg: 3 }} spacing="6">
|
||||
{children}
|
||||
</SimpleGrid>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemBadgeList({ children, ...props }) {
|
||||
return (
|
||||
<Wrap spacing="2" opacity="0.7" {...props}>
|
||||
{React.Children.map(
|
||||
children,
|
||||
(badge) => badge && <WrapItem>{badge}</WrapItem>,
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
return (
|
||||
<Wrap spacing="2" opacity="0.7" {...props}>
|
||||
{React.Children.map(
|
||||
children,
|
||||
(badge) => badge && <WrapItem>{badge}</WrapItem>,
|
||||
)}
|
||||
</Wrap>
|
||||
);
|
||||
}
|
||||
|
||||
export function ItemBadgeTooltip({ label, children }) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={<Box textAlign="center">{label}</Box>}
|
||||
placement="top"
|
||||
openDelay={400}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label={<Box textAlign="center">{label}</Box>}
|
||||
placement="top"
|
||||
openDelay={400}
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export const NcBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neocash">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="purple"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NC
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neocash">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="purple"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NC
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const NpBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neopoints">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NP
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeTooltip label="Neopoints">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
NP
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const PbBadge = React.forwardRef(({ isEditButton, ...props }, ref) => {
|
||||
return (
|
||||
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
PB
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeTooltip label="This item is only obtainable via paintbrush">
|
||||
<Badge
|
||||
ref={ref}
|
||||
as={isEditButton ? "button" : "span"}
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
{...props}
|
||||
>
|
||||
PB
|
||||
{isEditButton && <EditIcon fontSize="0.85em" marginLeft="1" />}
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
});
|
||||
|
||||
export const ItemKindBadge = React.forwardRef(
|
||||
({ isNc, isPb, isEditButton, ...props }, ref) => {
|
||||
if (isNc) {
|
||||
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else if (isPb) {
|
||||
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else {
|
||||
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
}
|
||||
},
|
||||
({ isNc, isPb, isEditButton, ...props }, ref) => {
|
||||
if (isNc) {
|
||||
return <NcBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else if (isPb) {
|
||||
return <PbBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
} else {
|
||||
return <NpBadge ref={ref} isEditButton={isEditButton} {...props} />;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export function YouOwnThisBadge({ variant = "long" }) {
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<CheckIcon aria-label="Check" />
|
||||
{variant === "medium" && <Box marginLeft="1">Own</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="green"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<CheckIcon aria-label="Check" />
|
||||
{variant === "medium" && <Box marginLeft="1">Own</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You own this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You own this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
return badge;
|
||||
}
|
||||
|
||||
export function YouWantThisBadge({ variant = "long" }) {
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<StarIcon aria-label="Star" />
|
||||
{variant === "medium" && <Box marginLeft="1">Want</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
let badge = (
|
||||
<Badge
|
||||
colorScheme="blue"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<StarIcon aria-label="Star" />
|
||||
{variant === "medium" && <Box marginLeft="1">Want</Box>}
|
||||
{variant === "long" && <Box marginLeft="1">You want this!</Box>}
|
||||
</Badge>
|
||||
);
|
||||
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
if (variant === "short" || variant === "medium") {
|
||||
badge = (
|
||||
<ItemBadgeTooltip label="You want this item">{badge}</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return badge;
|
||||
return badge;
|
||||
}
|
||||
|
||||
function ZoneBadge({ variant, zoneLabel }) {
|
||||
// Shorten the label when necessary, to make the badges less bulky
|
||||
const shorthand = zoneLabel
|
||||
.replace("Background Item", "BG Item")
|
||||
.replace("Foreground Item", "FG Item")
|
||||
.replace("Lower-body", "Lower")
|
||||
.replace("Upper-body", "Upper")
|
||||
.replace("Transient", "Trans")
|
||||
.replace("Biology", "Bio");
|
||||
// Shorten the label when necessary, to make the badges less bulky
|
||||
const shorthand = zoneLabel
|
||||
.replace("Background Item", "BG Item")
|
||||
.replace("Foreground Item", "FG Item")
|
||||
.replace("Lower-body", "Lower")
|
||||
.replace("Upper-body", "Upper")
|
||||
.replace("Transient", "Trans")
|
||||
.replace("Biology", "Bio");
|
||||
|
||||
if (variant === "restricts") {
|
||||
return (
|
||||
<ItemBadgeTooltip
|
||||
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
|
||||
>
|
||||
<Badge>
|
||||
<Box display="flex" alignItems="center">
|
||||
{shorthand} <NotAllowedIcon marginLeft="1" />
|
||||
</Box>
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
if (variant === "restricts") {
|
||||
return (
|
||||
<ItemBadgeTooltip
|
||||
label={`Restricted: This item can't be worn with ${zoneLabel} items`}
|
||||
>
|
||||
<Badge>
|
||||
<Box display="flex" alignItems="center">
|
||||
{shorthand} <NotAllowedIcon marginLeft="1" />
|
||||
</Box>
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (shorthand !== zoneLabel) {
|
||||
return (
|
||||
<ItemBadgeTooltip label={zoneLabel}>
|
||||
<Badge>{shorthand}</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
if (shorthand !== zoneLabel) {
|
||||
return (
|
||||
<ItemBadgeTooltip label={zoneLabel}>
|
||||
<Badge>{shorthand}</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return <Badge>{shorthand}</Badge>;
|
||||
return <Badge>{shorthand}</Badge>;
|
||||
}
|
||||
|
||||
export function getZoneBadges(zones, propsForAllBadges) {
|
||||
// Get the sorted zone labels. Sometimes an item occupies multiple zones of
|
||||
// the same name, so it's important to de-duplicate them!
|
||||
let labels = zones.map((z) => z.label);
|
||||
labels = new Set(labels);
|
||||
labels = [...labels].sort();
|
||||
// Get the sorted zone labels. Sometimes an item occupies multiple zones of
|
||||
// the same name, so it's important to de-duplicate them!
|
||||
let labels = zones.map((z) => z.label);
|
||||
labels = new Set(labels);
|
||||
labels = [...labels].sort();
|
||||
|
||||
return labels.map((label) => (
|
||||
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
|
||||
));
|
||||
return labels.map((label) => (
|
||||
<ZoneBadge key={label} zoneLabel={label} {...propsForAllBadges} />
|
||||
));
|
||||
}
|
||||
|
||||
export function MaybeAnimatedBadge() {
|
||||
return (
|
||||
<ItemBadgeTooltip label="Maybe animated? (Support only)">
|
||||
<Badge
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<Box as={HiSparkles} aria-label="Sparkles" />
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeTooltip label="Maybe animated? (Support only)">
|
||||
<Badge
|
||||
colorScheme="orange"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
minHeight="1.5em"
|
||||
>
|
||||
<Box as={HiSparkles} aria-label="Sparkles" />
|
||||
</Badge>
|
||||
</ItemBadgeTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemCard;
|
||||
|
|
|
@ -17,471 +17,471 @@ new Function(easelSource).call(window);
|
|||
new Function(tweenSource).call(window);
|
||||
|
||||
function OutfitMovieLayer({
|
||||
libraryUrl,
|
||||
width,
|
||||
height,
|
||||
placeholderImageUrl = null,
|
||||
isPaused = false,
|
||||
onLoad = null,
|
||||
onError = null,
|
||||
onLowFps = null,
|
||||
canvasProps = {},
|
||||
libraryUrl,
|
||||
width,
|
||||
height,
|
||||
placeholderImageUrl = null,
|
||||
isPaused = false,
|
||||
onLoad = null,
|
||||
onError = null,
|
||||
onLowFps = null,
|
||||
canvasProps = {},
|
||||
}) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const [stage, setStage] = React.useState(null);
|
||||
const [library, setLibrary] = React.useState(null);
|
||||
const [movieClip, setMovieClip] = React.useState(null);
|
||||
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
|
||||
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
|
||||
const canvasRef = React.useRef(null);
|
||||
const hasShownErrorMessageRef = React.useRef(false);
|
||||
const toast = useToast();
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const [stage, setStage] = React.useState(null);
|
||||
const [library, setLibrary] = React.useState(null);
|
||||
const [movieClip, setMovieClip] = React.useState(null);
|
||||
const [unusedHasCalledOnLoad, setHasCalledOnLoad] = React.useState(false);
|
||||
const [movieIsLoaded, setMovieIsLoaded] = React.useState(false);
|
||||
const canvasRef = React.useRef(null);
|
||||
const hasShownErrorMessageRef = React.useRef(false);
|
||||
const toast = useToast();
|
||||
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI like retina. But we'll keep the layout width/height as expected!
|
||||
const internalWidth = width * window.devicePixelRatio;
|
||||
const internalHeight = height * window.devicePixelRatio;
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI like retina. But we'll keep the layout width/height as expected!
|
||||
const internalWidth = width * window.devicePixelRatio;
|
||||
const internalHeight = height * window.devicePixelRatio;
|
||||
|
||||
const callOnLoadIfNotYetCalled = React.useCallback(() => {
|
||||
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
|
||||
if (!alreadyHasCalledOnLoad && onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [onLoad]);
|
||||
const callOnLoadIfNotYetCalled = React.useCallback(() => {
|
||||
setHasCalledOnLoad((alreadyHasCalledOnLoad) => {
|
||||
if (!alreadyHasCalledOnLoad && onLoad) {
|
||||
onLoad();
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [onLoad]);
|
||||
|
||||
const updateStage = React.useCallback(() => {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
const updateStage = React.useCallback(() => {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
stage.update();
|
||||
} catch (e) {
|
||||
// If rendering the frame fails, log it and proceed. If it's an
|
||||
// animation, then maybe the next frame will work? Also alert the user,
|
||||
// just as an FYI. (This is pretty uncommon, so I'm not worried about
|
||||
// being noisy!)
|
||||
if (!hasShownErrorMessageRef.current) {
|
||||
console.error(`Error rendering movie clip ${libraryUrl}`);
|
||||
logAndCapture(e);
|
||||
toast({
|
||||
status: "warning",
|
||||
title:
|
||||
"Hmm, we're maybe having trouble playing one of these animations.",
|
||||
description:
|
||||
"If it looks wrong, try pausing and playing, or reloading the " +
|
||||
"page. Sorry!",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
// We do this via a ref, not state, because I want to guarantee that
|
||||
// future calls see the new value. With state, React's effects might
|
||||
// not happen in the right order for it to work!
|
||||
hasShownErrorMessageRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [stage, toast, libraryUrl]);
|
||||
try {
|
||||
stage.update();
|
||||
} catch (e) {
|
||||
// If rendering the frame fails, log it and proceed. If it's an
|
||||
// animation, then maybe the next frame will work? Also alert the user,
|
||||
// just as an FYI. (This is pretty uncommon, so I'm not worried about
|
||||
// being noisy!)
|
||||
if (!hasShownErrorMessageRef.current) {
|
||||
console.error(`Error rendering movie clip ${libraryUrl}`);
|
||||
logAndCapture(e);
|
||||
toast({
|
||||
status: "warning",
|
||||
title:
|
||||
"Hmm, we're maybe having trouble playing one of these animations.",
|
||||
description:
|
||||
"If it looks wrong, try pausing and playing, or reloading the " +
|
||||
"page. Sorry!",
|
||||
duration: 10000,
|
||||
isClosable: true,
|
||||
});
|
||||
// We do this via a ref, not state, because I want to guarantee that
|
||||
// future calls see the new value. With state, React's effects might
|
||||
// not happen in the right order for it to work!
|
||||
hasShownErrorMessageRef.current = true;
|
||||
}
|
||||
}
|
||||
}, [stage, toast, libraryUrl]);
|
||||
|
||||
// This effect gives us a `stage` corresponding to the canvas element.
|
||||
React.useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
// This effect gives us a `stage` corresponding to the canvas element.
|
||||
React.useLayoutEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
if (!canvas) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
toast({
|
||||
status: "warning",
|
||||
title: "Oops, too many animations!",
|
||||
description:
|
||||
`Your device is out of memory, so we can't show any more ` +
|
||||
`animations. Try removing some items, or using another device.`,
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
toast({
|
||||
status: "warning",
|
||||
title: "Oops, too many animations!",
|
||||
description:
|
||||
`Your device is out of memory, so we can't show any more ` +
|
||||
`animations. Try removing some items, or using another device.`,
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setStage((stage) => {
|
||||
if (stage && stage.canvas === canvas) {
|
||||
return stage;
|
||||
}
|
||||
setStage((stage) => {
|
||||
if (stage && stage.canvas === canvas) {
|
||||
return stage;
|
||||
}
|
||||
|
||||
return new window.createjs.Stage(canvas);
|
||||
});
|
||||
return new window.createjs.Stage(canvas);
|
||||
});
|
||||
|
||||
return () => {
|
||||
setStage(null);
|
||||
return () => {
|
||||
setStage(null);
|
||||
|
||||
if (canvas) {
|
||||
// There's a Safari bug where it doesn't reliably garbage-collect
|
||||
// canvas data. Clean it up ourselves, rather than leaking memory over
|
||||
// time! https://stackoverflow.com/a/52586606/107415
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=195325
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
};
|
||||
}, [libraryUrl, toast]);
|
||||
if (canvas) {
|
||||
// There's a Safari bug where it doesn't reliably garbage-collect
|
||||
// canvas data. Clean it up ourselves, rather than leaking memory over
|
||||
// time! https://stackoverflow.com/a/52586606/107415
|
||||
// https://bugs.webkit.org/show_bug.cgi?id=195325
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
};
|
||||
}, [libraryUrl, toast]);
|
||||
|
||||
// This effect gives us the `library` and `movieClip`, based on the incoming
|
||||
// `libraryUrl`.
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
// This effect gives us the `library` and `movieClip`, based on the incoming
|
||||
// `libraryUrl`.
|
||||
React.useEffect(() => {
|
||||
let canceled = false;
|
||||
|
||||
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
|
||||
movieLibraryPromise
|
||||
.then((library) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
const movieLibraryPromise = loadMovieLibrary(libraryUrl, { preferArchive });
|
||||
movieLibraryPromise
|
||||
.then((library) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLibrary(library);
|
||||
setLibrary(library);
|
||||
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
setMovieClip(movieClip);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
|
||||
if (onError) {
|
||||
onError(e);
|
||||
}
|
||||
});
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
setMovieClip(movieClip);
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Error loading outfit movie layer: ${libraryUrl}`, e);
|
||||
if (onError) {
|
||||
onError(e);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
movieLibraryPromise.cancel();
|
||||
setLibrary(null);
|
||||
setMovieClip(null);
|
||||
};
|
||||
}, [libraryUrl, preferArchive, onError]);
|
||||
return () => {
|
||||
canceled = true;
|
||||
movieLibraryPromise.cancel();
|
||||
setLibrary(null);
|
||||
setMovieClip(null);
|
||||
};
|
||||
}, [libraryUrl, preferArchive, onError]);
|
||||
|
||||
// This effect puts the `movieClip` on the `stage`, when both are ready.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip) {
|
||||
return;
|
||||
}
|
||||
// This effect puts the `movieClip` on the `stage`, when both are ready.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip) {
|
||||
return;
|
||||
}
|
||||
|
||||
stage.addChild(movieClip);
|
||||
stage.addChild(movieClip);
|
||||
|
||||
// Render the movie's first frame. If it's animated and we're not paused,
|
||||
// then another effect will perform subsequent updates.
|
||||
updateStage();
|
||||
// Render the movie's first frame. If it's animated and we're not paused,
|
||||
// then another effect will perform subsequent updates.
|
||||
updateStage();
|
||||
|
||||
// This is when we trigger `onLoad`: once we're actually showing it!
|
||||
callOnLoadIfNotYetCalled();
|
||||
setMovieIsLoaded(true);
|
||||
// This is when we trigger `onLoad`: once we're actually showing it!
|
||||
callOnLoadIfNotYetCalled();
|
||||
setMovieIsLoaded(true);
|
||||
|
||||
return () => stage.removeChild(movieClip);
|
||||
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
|
||||
return () => stage.removeChild(movieClip);
|
||||
}, [stage, updateStage, movieClip, callOnLoadIfNotYetCalled]);
|
||||
|
||||
// This effect updates the `stage` according to the `library`'s framerate,
|
||||
// but only if there's actual animation to do - i.e., there's more than one
|
||||
// frame to show, and we're not paused.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
// This effect updates the `stage` according to the `library`'s framerate,
|
||||
// but only if there's actual animation to do - i.e., there's more than one
|
||||
// frame to show, and we're not paused.
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPaused || !hasAnimations(movieClip)) {
|
||||
return;
|
||||
}
|
||||
if (isPaused || !hasAnimations(movieClip)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetFps = library.properties.fps;
|
||||
const targetFps = library.properties.fps;
|
||||
|
||||
let lastFpsLoggedAtInMs = performance.now();
|
||||
let numFramesSinceLastLogged = 0;
|
||||
const intervalId = setInterval(() => {
|
||||
const now = performance.now();
|
||||
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
|
||||
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
|
||||
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
|
||||
const roundedFps = Math.round(fps * 100) / 100;
|
||||
let lastFpsLoggedAtInMs = performance.now();
|
||||
let numFramesSinceLastLogged = 0;
|
||||
const intervalId = setInterval(() => {
|
||||
const now = performance.now();
|
||||
const timeSinceLastFpsLoggedAtInMs = now - lastFpsLoggedAtInMs;
|
||||
const timeSinceLastFpsLoggedAtInSec = timeSinceLastFpsLoggedAtInMs / 1000;
|
||||
const fps = numFramesSinceLastLogged / timeSinceLastFpsLoggedAtInSec;
|
||||
const roundedFps = Math.round(fps * 100) / 100;
|
||||
|
||||
// If the page is visible, render the next frame, and track that we did.
|
||||
// And if it's been 2 seconds since the last time we logged the FPS,
|
||||
// compute and log the FPS during those two seconds. (Checking the page
|
||||
// visibility is both an optimization to avoid rendering the movie, but
|
||||
// also makes "low FPS" tracking more accurate: browsers already throttle
|
||||
// intervals when the page is hidden, so a low FPS is *expected*, and
|
||||
// wouldn't indicate a performance problem like a low FPS normally would.)
|
||||
if (!document.hidden) {
|
||||
updateStage();
|
||||
numFramesSinceLastLogged++;
|
||||
// If the page is visible, render the next frame, and track that we did.
|
||||
// And if it's been 2 seconds since the last time we logged the FPS,
|
||||
// compute and log the FPS during those two seconds. (Checking the page
|
||||
// visibility is both an optimization to avoid rendering the movie, but
|
||||
// also makes "low FPS" tracking more accurate: browsers already throttle
|
||||
// intervals when the page is hidden, so a low FPS is *expected*, and
|
||||
// wouldn't indicate a performance problem like a low FPS normally would.)
|
||||
if (!document.hidden) {
|
||||
updateStage();
|
||||
numFramesSinceLastLogged++;
|
||||
|
||||
if (timeSinceLastFpsLoggedAtInSec > 2) {
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
|
||||
);
|
||||
if (onLowFps && fps < 2) {
|
||||
onLowFps(fps);
|
||||
}
|
||||
if (timeSinceLastFpsLoggedAtInSec > 2) {
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] FPS: ${roundedFps} (Target: ${targetFps}) (${libraryUrl})`,
|
||||
);
|
||||
if (onLowFps && fps < 2) {
|
||||
onLowFps(fps);
|
||||
}
|
||||
|
||||
lastFpsLoggedAtInMs = now;
|
||||
numFramesSinceLastLogged = 0;
|
||||
}
|
||||
}
|
||||
}, 1000 / targetFps);
|
||||
lastFpsLoggedAtInMs = now;
|
||||
numFramesSinceLastLogged = 0;
|
||||
}
|
||||
}
|
||||
}, 1000 / targetFps);
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
// When the page switches from hidden to visible, reset the FPS counter
|
||||
// state, to start counting from When Visibility Came Back, rather than
|
||||
// from when we last counted, which could be a long time ago.
|
||||
if (!document.hidden) {
|
||||
lastFpsLoggedAtInMs = performance.now();
|
||||
numFramesSinceLastLogged = 0;
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
|
||||
);
|
||||
} else {
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
const onVisibilityChange = () => {
|
||||
// When the page switches from hidden to visible, reset the FPS counter
|
||||
// state, to start counting from When Visibility Came Back, rather than
|
||||
// from when we last counted, which could be a long time ago.
|
||||
if (!document.hidden) {
|
||||
lastFpsLoggedAtInMs = performance.now();
|
||||
numFramesSinceLastLogged = 0;
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] Resuming now that page is visible (${libraryUrl})`,
|
||||
);
|
||||
} else {
|
||||
console.debug(
|
||||
`[OutfitMovieLayer] Pausing while page is hidden (${libraryUrl})`,
|
||||
);
|
||||
}
|
||||
};
|
||||
document.addEventListener("visibilitychange", onVisibilityChange);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
document.removeEventListener("visibilitychange", onVisibilityChange);
|
||||
};
|
||||
}, [libraryUrl, stage, updateStage, movieClip, library, isPaused, onLowFps]);
|
||||
|
||||
// This effect keeps the `movieClip` scaled correctly, based on the canvas
|
||||
// size and the `library`'s natural size declaration. (If the canvas size
|
||||
// changes on window resize, then this will keep us responsive, so long as
|
||||
// the parent updates our width/height props on window resize!)
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
// This effect keeps the `movieClip` scaled correctly, based on the canvas
|
||||
// size and the `library`'s natural size declaration. (If the canvas size
|
||||
// changes on window resize, then this will keep us responsive, so long as
|
||||
// the parent updates our width/height props on window resize!)
|
||||
React.useEffect(() => {
|
||||
if (!stage || !movieClip || !library) {
|
||||
return;
|
||||
}
|
||||
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
movieClip.scaleY = internalHeight / library.properties.height;
|
||||
|
||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
||||
// to `false`, so that we don't advance by a frame. This keeps us
|
||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
||||
// we're playing.
|
||||
stage.tickOnUpdate = false;
|
||||
updateStage();
|
||||
stage.tickOnUpdate = true;
|
||||
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
|
||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
||||
// to `false`, so that we don't advance by a frame. This keeps us
|
||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
||||
// we're playing.
|
||||
stage.tickOnUpdate = false;
|
||||
updateStage();
|
||||
stage.tickOnUpdate = true;
|
||||
}, [stage, updateStage, library, movieClip, internalWidth, internalHeight]);
|
||||
|
||||
return (
|
||||
<Grid templateAreas="single-shared-area">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{
|
||||
width: width,
|
||||
height: height,
|
||||
gridArea: "single-shared-area",
|
||||
}}
|
||||
data-is-loaded={movieIsLoaded}
|
||||
{...canvasProps}
|
||||
/>
|
||||
{/* While the movie is loading, we show our image version as a
|
||||
* placeholder, because it generally loads much faster.
|
||||
* TODO: Show a loading indicator for this partially-loaded state? */}
|
||||
{placeholderImageUrl && (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(placeholderImageUrl)}
|
||||
width={width}
|
||||
height={height}
|
||||
gridArea="single-shared-area"
|
||||
opacity={movieIsLoaded ? 0 : 1}
|
||||
transition="opacity 0.2s"
|
||||
onLoad={callOnLoadIfNotYetCalled}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
return (
|
||||
<Grid templateAreas="single-shared-area">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={internalWidth}
|
||||
height={internalHeight}
|
||||
style={{
|
||||
width: width,
|
||||
height: height,
|
||||
gridArea: "single-shared-area",
|
||||
}}
|
||||
data-is-loaded={movieIsLoaded}
|
||||
{...canvasProps}
|
||||
/>
|
||||
{/* While the movie is loading, we show our image version as a
|
||||
* placeholder, because it generally loads much faster.
|
||||
* TODO: Show a loading indicator for this partially-loaded state? */}
|
||||
{placeholderImageUrl && (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(placeholderImageUrl)}
|
||||
width={width}
|
||||
height={height}
|
||||
gridArea="single-shared-area"
|
||||
opacity={movieIsLoaded ? 0 : 1}
|
||||
transition="opacity 0.2s"
|
||||
onLoad={callOnLoadIfNotYetCalled}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function loadScriptTag(src) {
|
||||
let script;
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
let script;
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
|
||||
const scriptTagPromise = new Promise((resolve, reject) => {
|
||||
script = document.createElement("script");
|
||||
script.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(script);
|
||||
};
|
||||
script.onerror = (e) => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
|
||||
};
|
||||
script.src = src;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
const scriptTagPromise = new Promise((resolve, reject) => {
|
||||
script = document.createElement("script");
|
||||
script.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(script);
|
||||
};
|
||||
script.onerror = (e) => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load script: ${JSON.stringify(src)}`));
|
||||
};
|
||||
script.src = src;
|
||||
document.body.appendChild(script);
|
||||
});
|
||||
|
||||
scriptTagPromise.cancel = () => {
|
||||
if (resolved) return;
|
||||
script.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
scriptTagPromise.cancel = () => {
|
||||
if (resolved) return;
|
||||
script.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
|
||||
return scriptTagPromise;
|
||||
return scriptTagPromise;
|
||||
}
|
||||
|
||||
const MOVIE_LIBRARY_CACHE = new LRU(10);
|
||||
|
||||
export function loadMovieLibrary(librarySrc, { preferArchive = false } = {}) {
|
||||
const cancelableResourcePromises = [];
|
||||
const cancelAllResources = () =>
|
||||
cancelableResourcePromises.forEach((p) => p.cancel());
|
||||
const cancelableResourcePromises = [];
|
||||
const cancelAllResources = () =>
|
||||
cancelableResourcePromises.forEach((p) => p.cancel());
|
||||
|
||||
// Most of the logic for `loadMovieLibrary` is inside this async function.
|
||||
// But we want to attach more fields to the promise before returning it; so
|
||||
// we declare this async function separately, then call it, then edit the
|
||||
// returned promise!
|
||||
const createMovieLibraryPromise = async () => {
|
||||
// First, check the LRU cache. This will enable us to quickly return movie
|
||||
// libraries, without re-loading and re-parsing and re-executing.
|
||||
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
|
||||
if (cachedLibrary) {
|
||||
return cachedLibrary;
|
||||
}
|
||||
// Most of the logic for `loadMovieLibrary` is inside this async function.
|
||||
// But we want to attach more fields to the promise before returning it; so
|
||||
// we declare this async function separately, then call it, then edit the
|
||||
// returned promise!
|
||||
const createMovieLibraryPromise = async () => {
|
||||
// First, check the LRU cache. This will enable us to quickly return movie
|
||||
// libraries, without re-loading and re-parsing and re-executing.
|
||||
const cachedLibrary = MOVIE_LIBRARY_CACHE.get(librarySrc);
|
||||
if (cachedLibrary) {
|
||||
return cachedLibrary;
|
||||
}
|
||||
|
||||
// Then, load the script tag. (Make sure we set it up to be cancelable!)
|
||||
const scriptPromise = loadScriptTag(
|
||||
safeImageUrl(librarySrc, { preferArchive }),
|
||||
);
|
||||
cancelableResourcePromises.push(scriptPromise);
|
||||
await scriptPromise;
|
||||
// Then, load the script tag. (Make sure we set it up to be cancelable!)
|
||||
const scriptPromise = loadScriptTag(
|
||||
safeImageUrl(librarySrc, { preferArchive }),
|
||||
);
|
||||
cancelableResourcePromises.push(scriptPromise);
|
||||
await scriptPromise;
|
||||
|
||||
// These library JS files are interesting in their operation. It seems like
|
||||
// the idea is, it pushes an object to a global array, and you need to snap
|
||||
// it up and see it at the end of the array! And I don't really see a way to
|
||||
// like, get by a name or ID that we know by this point. So, here we go, just
|
||||
// try to grab it once it arrives!
|
||||
//
|
||||
// I'm not _sure_ this method is reliable, but it seems to be stable so far
|
||||
// in Firefox for me. The things I think I'm observing are:
|
||||
// - Script execution order should match insert order,
|
||||
// - Onload execution order should match insert order,
|
||||
// - BUT, script executions might be batched before onloads.
|
||||
// - So, each script grabs the _first_ composition from the list, and
|
||||
// deletes it after grabbing. That way, it serves as a FIFO queue!
|
||||
// I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
|
||||
// the race anymore? But fingers crossed!
|
||||
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
|
||||
throw new Error(
|
||||
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
|
||||
);
|
||||
}
|
||||
const [compositionId, composition] = Object.entries(
|
||||
window.AdobeAn.compositions,
|
||||
)[0];
|
||||
if (Object.keys(window.AdobeAn.compositions).length > 1) {
|
||||
console.warn(
|
||||
`Grabbing composition ${compositionId}, but there are >1 here: `,
|
||||
Object.keys(window.AdobeAn.compositions).length,
|
||||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
const library = composition.getLibrary();
|
||||
// These library JS files are interesting in their operation. It seems like
|
||||
// the idea is, it pushes an object to a global array, and you need to snap
|
||||
// it up and see it at the end of the array! And I don't really see a way to
|
||||
// like, get by a name or ID that we know by this point. So, here we go, just
|
||||
// try to grab it once it arrives!
|
||||
//
|
||||
// I'm not _sure_ this method is reliable, but it seems to be stable so far
|
||||
// in Firefox for me. The things I think I'm observing are:
|
||||
// - Script execution order should match insert order,
|
||||
// - Onload execution order should match insert order,
|
||||
// - BUT, script executions might be batched before onloads.
|
||||
// - So, each script grabs the _first_ composition from the list, and
|
||||
// deletes it after grabbing. That way, it serves as a FIFO queue!
|
||||
// I'm not suuure this is happening as I'm expecting, vs I'm just not seeing
|
||||
// the race anymore? But fingers crossed!
|
||||
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
|
||||
throw new Error(
|
||||
`Movie library ${librarySrc} did not add a composition to window.AdobeAn.compositions.`,
|
||||
);
|
||||
}
|
||||
const [compositionId, composition] = Object.entries(
|
||||
window.AdobeAn.compositions,
|
||||
)[0];
|
||||
if (Object.keys(window.AdobeAn.compositions).length > 1) {
|
||||
console.warn(
|
||||
`Grabbing composition ${compositionId}, but there are >1 here: `,
|
||||
Object.keys(window.AdobeAn.compositions).length,
|
||||
);
|
||||
}
|
||||
delete window.AdobeAn.compositions[compositionId];
|
||||
const library = composition.getLibrary();
|
||||
|
||||
// One more loading step as part of loading this library is loading the
|
||||
// images it uses for sprites.
|
||||
//
|
||||
// TODO: I guess the manifest has these too, so if we could use our DB cache
|
||||
// to get the manifest to us faster, then we could avoid a network RTT
|
||||
// on the critical path by preloading these images before the JS file
|
||||
// even gets to us?
|
||||
const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
|
||||
const manifestImages = new Map(
|
||||
library.properties.manifest.map(({ id, src }) => [
|
||||
id,
|
||||
loadImage(librarySrcDir + "/" + src, {
|
||||
crossOrigin: "anonymous",
|
||||
preferArchive,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
// One more loading step as part of loading this library is loading the
|
||||
// images it uses for sprites.
|
||||
//
|
||||
// TODO: I guess the manifest has these too, so if we could use our DB cache
|
||||
// to get the manifest to us faster, then we could avoid a network RTT
|
||||
// on the critical path by preloading these images before the JS file
|
||||
// even gets to us?
|
||||
const librarySrcDir = librarySrc.split("/").slice(0, -1).join("/");
|
||||
const manifestImages = new Map(
|
||||
library.properties.manifest.map(({ id, src }) => [
|
||||
id,
|
||||
loadImage(librarySrcDir + "/" + src, {
|
||||
crossOrigin: "anonymous",
|
||||
preferArchive,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
// Wait for the images, and make sure they're cancelable while we do.
|
||||
const manifestImagePromises = manifestImages.values();
|
||||
cancelableResourcePromises.push(...manifestImagePromises);
|
||||
await Promise.all(manifestImagePromises);
|
||||
// Wait for the images, and make sure they're cancelable while we do.
|
||||
const manifestImagePromises = manifestImages.values();
|
||||
cancelableResourcePromises.push(...manifestImagePromises);
|
||||
await Promise.all(manifestImagePromises);
|
||||
|
||||
// Finally, once we have the images loaded, the library object expects us to
|
||||
// mutate it (!) to give it the actual image and sprite sheet objects from
|
||||
// the loaded images. That's how the MovieClip's internal JS objects will
|
||||
// access the loaded data!
|
||||
const images = composition.getImages();
|
||||
for (const [id, image] of manifestImages.entries()) {
|
||||
images[id] = await image;
|
||||
}
|
||||
const spriteSheets = composition.getSpriteSheet();
|
||||
for (const { name, frames } of library.ssMetadata) {
|
||||
const image = await manifestImages.get(name);
|
||||
spriteSheets[name] = new window.createjs.SpriteSheet({
|
||||
images: [image],
|
||||
frames,
|
||||
});
|
||||
}
|
||||
// Finally, once we have the images loaded, the library object expects us to
|
||||
// mutate it (!) to give it the actual image and sprite sheet objects from
|
||||
// the loaded images. That's how the MovieClip's internal JS objects will
|
||||
// access the loaded data!
|
||||
const images = composition.getImages();
|
||||
for (const [id, image] of manifestImages.entries()) {
|
||||
images[id] = await image;
|
||||
}
|
||||
const spriteSheets = composition.getSpriteSheet();
|
||||
for (const { name, frames } of library.ssMetadata) {
|
||||
const image = await manifestImages.get(name);
|
||||
spriteSheets[name] = new window.createjs.SpriteSheet({
|
||||
images: [image],
|
||||
frames,
|
||||
});
|
||||
}
|
||||
|
||||
MOVIE_LIBRARY_CACHE.set(librarySrc, library);
|
||||
MOVIE_LIBRARY_CACHE.set(librarySrc, library);
|
||||
|
||||
return library;
|
||||
};
|
||||
return library;
|
||||
};
|
||||
|
||||
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
|
||||
// When any part of the movie library fails, we also cancel the other
|
||||
// resources ourselves, to avoid stray throws for resources that fail after
|
||||
// the parent catches the initial failure. We re-throw the initial failure
|
||||
// for the parent to handle, though!
|
||||
cancelAllResources();
|
||||
throw e;
|
||||
});
|
||||
const movieLibraryPromise = createMovieLibraryPromise().catch((e) => {
|
||||
// When any part of the movie library fails, we also cancel the other
|
||||
// resources ourselves, to avoid stray throws for resources that fail after
|
||||
// the parent catches the initial failure. We re-throw the initial failure
|
||||
// for the parent to handle, though!
|
||||
cancelAllResources();
|
||||
throw e;
|
||||
});
|
||||
|
||||
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
|
||||
// load as part of it. That should effectively halt the async function above
|
||||
// (anything not yet loaded will stop loading), and ensure that stray
|
||||
// failures don't trigger uncaught promise rejection warnings.
|
||||
movieLibraryPromise.cancel = cancelAllResources;
|
||||
// To cancel a `loadMovieLibrary`, cancel all of the resource promises we
|
||||
// load as part of it. That should effectively halt the async function above
|
||||
// (anything not yet loaded will stop loading), and ensure that stray
|
||||
// failures don't trigger uncaught promise rejection warnings.
|
||||
movieLibraryPromise.cancel = cancelAllResources;
|
||||
|
||||
return movieLibraryPromise;
|
||||
return movieLibraryPromise;
|
||||
}
|
||||
|
||||
export function buildMovieClip(library, libraryUrl) {
|
||||
let constructorName;
|
||||
try {
|
||||
const fileName = decodeURI(libraryUrl).split("/").pop();
|
||||
const fileNameWithoutExtension = fileName.split(".")[0];
|
||||
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
|
||||
if (constructorName.match(/^[0-9]/)) {
|
||||
constructorName = "_" + constructorName;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Movie libraryUrl ${JSON.stringify(
|
||||
libraryUrl,
|
||||
)} did not match expected format: ${e.message}`,
|
||||
);
|
||||
}
|
||||
let constructorName;
|
||||
try {
|
||||
const fileName = decodeURI(libraryUrl).split("/").pop();
|
||||
const fileNameWithoutExtension = fileName.split(".")[0];
|
||||
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
|
||||
if (constructorName.match(/^[0-9]/)) {
|
||||
constructorName = "_" + constructorName;
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Movie libraryUrl ${JSON.stringify(
|
||||
libraryUrl,
|
||||
)} did not match expected format: ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const LibraryMovieClipConstructor = library[constructorName];
|
||||
if (!LibraryMovieClipConstructor) {
|
||||
throw new Error(
|
||||
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
|
||||
`named ${constructorName}, but it did not: ${Object.keys(library)}`,
|
||||
);
|
||||
}
|
||||
const movieClip = new LibraryMovieClipConstructor();
|
||||
const LibraryMovieClipConstructor = library[constructorName];
|
||||
if (!LibraryMovieClipConstructor) {
|
||||
throw new Error(
|
||||
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
|
||||
`named ${constructorName}, but it did not: ${Object.keys(library)}`,
|
||||
);
|
||||
}
|
||||
const movieClip = new LibraryMovieClipConstructor();
|
||||
|
||||
return movieClip;
|
||||
return movieClip;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -489,15 +489,15 @@ export function buildMovieClip(library, libraryUrl) {
|
|||
* there are any animated areas.
|
||||
*/
|
||||
export function hasAnimations(createjsNode) {
|
||||
return (
|
||||
// Some nodes have simple animation frames.
|
||||
createjsNode.totalFrames > 1 ||
|
||||
// Tweens are a form of animation that can happen separately from frames.
|
||||
// They expect timer ticks to happen, and they change the scene accordingly.
|
||||
createjsNode?.timeline?.tweens?.length >= 1 ||
|
||||
// And some nodes have _children_ that are animated.
|
||||
(createjsNode.children || []).some(hasAnimations)
|
||||
);
|
||||
return (
|
||||
// Some nodes have simple animation frames.
|
||||
createjsNode.totalFrames > 1 ||
|
||||
// Tweens are a form of animation that can happen separately from frames.
|
||||
// They expect timer ticks to happen, and they change the scene accordingly.
|
||||
createjsNode?.timeline?.tweens?.length >= 1 ||
|
||||
// And some nodes have _children_ that are animated.
|
||||
(createjsNode.children || []).some(hasAnimations)
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitMovieLayer;
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
DarkMode,
|
||||
Flex,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
Box,
|
||||
DarkMode,
|
||||
Flex,
|
||||
Text,
|
||||
useColorModeValue,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
import LRU from "lru-cache";
|
||||
import { WarningIcon } from "@chakra-ui/icons";
|
||||
|
@ -13,9 +13,9 @@ import { ClassNames } from "@emotion/react";
|
|||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
||||
import OutfitMovieLayer, {
|
||||
buildMovieClip,
|
||||
hasAnimations,
|
||||
loadMovieLibrary,
|
||||
buildMovieClip,
|
||||
hasAnimations,
|
||||
loadMovieLibrary,
|
||||
} from "./OutfitMovieLayer";
|
||||
import HangerSpinner from "./HangerSpinner";
|
||||
import { loadImage, safeImageUrl, useLocalStorage } from "../util";
|
||||
|
@ -37,8 +37,8 @@ import usePreferArchive from "./usePreferArchive";
|
|||
* useOutfitState both getting appearance data on first load...
|
||||
*/
|
||||
function OutfitPreview(props) {
|
||||
const { preview } = useOutfitPreview(props);
|
||||
return preview;
|
||||
const { preview } = useOutfitPreview(props);
|
||||
return preview;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -49,110 +49,110 @@ function OutfitPreview(props) {
|
|||
* want to show some additional UI that uses the appearance data we loaded!
|
||||
*/
|
||||
export function useOutfitPreview({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
wornItemIds,
|
||||
appearanceId = null,
|
||||
isLoading = false,
|
||||
placeholder = null,
|
||||
loadingDelayMs,
|
||||
spinnerVariant,
|
||||
onChangeHasAnimations = null,
|
||||
...props
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
wornItemIds,
|
||||
appearanceId = null,
|
||||
isLoading = false,
|
||||
placeholder = null,
|
||||
loadingDelayMs,
|
||||
spinnerVariant,
|
||||
onChangeHasAnimations = null,
|
||||
...props
|
||||
}) {
|
||||
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||
const toast = useToast();
|
||||
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||
const toast = useToast();
|
||||
|
||||
const appearance = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
});
|
||||
const { loading, error, visibleLayers } = appearance;
|
||||
const appearance = useOutfitAppearance({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
altStyleId,
|
||||
appearanceId,
|
||||
wornItemIds,
|
||||
});
|
||||
const { loading, error, visibleLayers } = appearance;
|
||||
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
loadedLayers,
|
||||
layersHaveAnimations,
|
||||
} = usePreloadLayers(visibleLayers);
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
loadedLayers,
|
||||
layersHaveAnimations,
|
||||
} = usePreloadLayers(visibleLayers);
|
||||
|
||||
const onMovieError = React.useCallback(() => {
|
||||
if (!toast.isActive("outfit-preview-on-movie-error")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-movie-error",
|
||||
status: "warning",
|
||||
title: "Oops, we couldn't load one of these animations.",
|
||||
description: "We'll show a static image version instead.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [toast]);
|
||||
const onMovieError = React.useCallback(() => {
|
||||
if (!toast.isActive("outfit-preview-on-movie-error")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-movie-error",
|
||||
status: "warning",
|
||||
title: "Oops, we couldn't load one of these animations.",
|
||||
description: "We'll show a static image version instead.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
const onLowFps = React.useCallback(
|
||||
(fps) => {
|
||||
setIsPaused(true);
|
||||
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
|
||||
const onLowFps = React.useCallback(
|
||||
(fps) => {
|
||||
setIsPaused(true);
|
||||
console.warn(`[OutfitPreview] Pausing due to low FPS: ${fps}`);
|
||||
|
||||
if (!toast.isActive("outfit-preview-on-low-fps")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-low-fps",
|
||||
status: "warning",
|
||||
title: "Sorry, the animation was lagging, so we paused it! 😖",
|
||||
description:
|
||||
"We do this to help make sure your machine doesn't lag too much! " +
|
||||
"You can unpause the preview to try again.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setIsPaused, toast],
|
||||
);
|
||||
if (!toast.isActive("outfit-preview-on-low-fps")) {
|
||||
toast({
|
||||
id: "outfit-preview-on-low-fps",
|
||||
status: "warning",
|
||||
title: "Sorry, the animation was lagging, so we paused it! 😖",
|
||||
description:
|
||||
"We do this to help make sure your machine doesn't lag too much! " +
|
||||
"You can unpause the preview to try again.",
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
[setIsPaused, toast],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (onChangeHasAnimations) {
|
||||
onChangeHasAnimations(layersHaveAnimations);
|
||||
}
|
||||
}, [layersHaveAnimations, onChangeHasAnimations]);
|
||||
React.useEffect(() => {
|
||||
if (onChangeHasAnimations) {
|
||||
onChangeHasAnimations(layersHaveAnimations);
|
||||
}
|
||||
}, [layersHaveAnimations, onChangeHasAnimations]);
|
||||
|
||||
const textColor = useColorModeValue("green.700", "white");
|
||||
const textColor = useColorModeValue("green.700", "white");
|
||||
|
||||
let preview;
|
||||
if (error || error2) {
|
||||
preview = (
|
||||
<FullScreenCenter>
|
||||
<Text color={textColor} d="flex" alignItems="center">
|
||||
<WarningIcon />
|
||||
<Box width={2} />
|
||||
Could not load preview. Try again?
|
||||
</Text>
|
||||
</FullScreenCenter>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<OutfitLayers
|
||||
loading={isLoading || loading || loading2}
|
||||
visibleLayers={loadedLayers}
|
||||
placeholder={placeholder}
|
||||
loadingDelayMs={loadingDelayMs}
|
||||
spinnerVariant={spinnerVariant}
|
||||
onMovieError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
doTransitions
|
||||
isPaused={isPaused}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let preview;
|
||||
if (error || error2) {
|
||||
preview = (
|
||||
<FullScreenCenter>
|
||||
<Text color={textColor} d="flex" alignItems="center">
|
||||
<WarningIcon />
|
||||
<Box width={2} />
|
||||
Could not load preview. Try again?
|
||||
</Text>
|
||||
</FullScreenCenter>
|
||||
);
|
||||
} else {
|
||||
preview = (
|
||||
<OutfitLayers
|
||||
loading={isLoading || loading || loading2}
|
||||
visibleLayers={loadedLayers}
|
||||
placeholder={placeholder}
|
||||
loadingDelayMs={loadingDelayMs}
|
||||
spinnerVariant={spinnerVariant}
|
||||
onMovieError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
doTransitions
|
||||
isPaused={isPaused}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return { appearance, preview };
|
||||
return { appearance, preview };
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -160,218 +160,218 @@ export function useOutfitPreview({
|
|||
* used both in the main outfit preview, and in other minor UIs!
|
||||
*/
|
||||
export function OutfitLayers({
|
||||
loading,
|
||||
visibleLayers,
|
||||
placeholder = null,
|
||||
loadingDelayMs = 500,
|
||||
spinnerVariant = "overlay",
|
||||
doTransitions = false,
|
||||
isPaused = true,
|
||||
onMovieError = null,
|
||||
onLowFps = null,
|
||||
...props
|
||||
loading,
|
||||
visibleLayers,
|
||||
placeholder = null,
|
||||
loadingDelayMs = 500,
|
||||
spinnerVariant = "overlay",
|
||||
doTransitions = false,
|
||||
isPaused = true,
|
||||
onMovieError = null,
|
||||
onLowFps = null,
|
||||
...props
|
||||
}) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
|
||||
const containerRef = React.useRef(null);
|
||||
const [canvasSize, setCanvasSize] = React.useState(0);
|
||||
const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
|
||||
React.useState(false);
|
||||
const containerRef = React.useRef(null);
|
||||
const [canvasSize, setCanvasSize] = React.useState(0);
|
||||
const [loadingDelayHasPassed, setLoadingDelayHasPassed] =
|
||||
React.useState(false);
|
||||
|
||||
// When we start in a loading state, or re-enter a loading state, start the
|
||||
// loading delay timer.
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
setLoadingDelayHasPassed(false);
|
||||
const t = setTimeout(
|
||||
() => setLoadingDelayHasPassed(true),
|
||||
loadingDelayMs,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [loadingDelayMs, loading]);
|
||||
// When we start in a loading state, or re-enter a loading state, start the
|
||||
// loading delay timer.
|
||||
React.useEffect(() => {
|
||||
if (loading) {
|
||||
setLoadingDelayHasPassed(false);
|
||||
const t = setTimeout(
|
||||
() => setLoadingDelayHasPassed(true),
|
||||
loadingDelayMs,
|
||||
);
|
||||
return () => clearTimeout(t);
|
||||
}
|
||||
}, [loadingDelayMs, loading]);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
function computeAndSaveCanvasSize() {
|
||||
setCanvasSize(
|
||||
// Follow an algorithm similar to the <img> sizing: a square that
|
||||
// covers the available space, without exceeding the natural image size
|
||||
// (which is 600px).
|
||||
//
|
||||
// TODO: Once we're entirely off PNGs, we could drop the 600
|
||||
// requirement, and let SVGs and movies scale up as far as they
|
||||
// want...
|
||||
Math.min(
|
||||
containerRef.current.offsetWidth,
|
||||
containerRef.current.offsetHeight,
|
||||
600,
|
||||
),
|
||||
);
|
||||
}
|
||||
React.useLayoutEffect(() => {
|
||||
function computeAndSaveCanvasSize() {
|
||||
setCanvasSize(
|
||||
// Follow an algorithm similar to the <img> sizing: a square that
|
||||
// covers the available space, without exceeding the natural image size
|
||||
// (which is 600px).
|
||||
//
|
||||
// TODO: Once we're entirely off PNGs, we could drop the 600
|
||||
// requirement, and let SVGs and movies scale up as far as they
|
||||
// want...
|
||||
Math.min(
|
||||
containerRef.current.offsetWidth,
|
||||
containerRef.current.offsetHeight,
|
||||
600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
computeAndSaveCanvasSize();
|
||||
window.addEventListener("resize", computeAndSaveCanvasSize);
|
||||
return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
|
||||
}, [setCanvasSize]);
|
||||
computeAndSaveCanvasSize();
|
||||
window.addEventListener("resize", computeAndSaveCanvasSize);
|
||||
return () => window.removeEventListener("resize", computeAndSaveCanvasSize);
|
||||
}, [setCanvasSize]);
|
||||
|
||||
const layersWithAssets = visibleLayers.filter((l) =>
|
||||
layerHasUsableAssets(l, { hiResMode }),
|
||||
);
|
||||
const layersWithAssets = visibleLayers.filter((l) =>
|
||||
layerHasUsableAssets(l, { hiResMode }),
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
pos="relative"
|
||||
height="100%"
|
||||
width="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
// Create a stacking context, so the z-indexed layers don't escape!
|
||||
zIndex="0"
|
||||
ref={containerRef}
|
||||
data-loading={loading ? true : undefined}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<FullScreenCenter>
|
||||
<Box
|
||||
// We show the placeholder until there are visible layers, at which
|
||||
// point we fade it out.
|
||||
opacity={visibleLayers.length === 0 ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
width="100%"
|
||||
height="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
>
|
||||
{placeholder}
|
||||
</Box>
|
||||
</FullScreenCenter>
|
||||
)}
|
||||
<TransitionGroup enter={false} exit={doTransitions}>
|
||||
{layersWithAssets.map((layer) => (
|
||||
<CSSTransition
|
||||
// We manage the fade-in and fade-out separately! The fade-out
|
||||
// happens here, when the layer exits the DOM.
|
||||
key={layer.id}
|
||||
timeout={200}
|
||||
>
|
||||
<FadeInOnLoad
|
||||
as={FullScreenCenter}
|
||||
zIndex={layer.zone.depth}
|
||||
className={css`
|
||||
&.exit {
|
||||
opacity: 1;
|
||||
}
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box
|
||||
pos="relative"
|
||||
height="100%"
|
||||
width="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
// Create a stacking context, so the z-indexed layers don't escape!
|
||||
zIndex="0"
|
||||
ref={containerRef}
|
||||
data-loading={loading ? true : undefined}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && (
|
||||
<FullScreenCenter>
|
||||
<Box
|
||||
// We show the placeholder until there are visible layers, at which
|
||||
// point we fade it out.
|
||||
opacity={visibleLayers.length === 0 ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
width="100%"
|
||||
height="100%"
|
||||
maxWidth="600px"
|
||||
maxHeight="600px"
|
||||
>
|
||||
{placeholder}
|
||||
</Box>
|
||||
</FullScreenCenter>
|
||||
)}
|
||||
<TransitionGroup enter={false} exit={doTransitions}>
|
||||
{layersWithAssets.map((layer) => (
|
||||
<CSSTransition
|
||||
// We manage the fade-in and fade-out separately! The fade-out
|
||||
// happens here, when the layer exits the DOM.
|
||||
key={layer.id}
|
||||
timeout={200}
|
||||
>
|
||||
<FadeInOnLoad
|
||||
as={FullScreenCenter}
|
||||
zIndex={layer.zone.depth}
|
||||
className={css`
|
||||
&.exit {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{layer.canvasMovieLibraryUrl ? (
|
||||
<OutfitMovieLayer
|
||||
libraryUrl={layer.canvasMovieLibraryUrl}
|
||||
placeholderImageUrl={getBestImageUrlForLayer(layer, {
|
||||
hiResMode,
|
||||
})}
|
||||
width={canvasSize}
|
||||
height={canvasSize}
|
||||
isPaused={isPaused}
|
||||
onError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(
|
||||
getBestImageUrlForLayer(layer, { hiResMode }),
|
||||
{ preferArchive },
|
||||
)}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
maxWidth="100%"
|
||||
maxHeight="100%"
|
||||
/>
|
||||
)}
|
||||
</FadeInOnLoad>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
<FullScreenCenter
|
||||
zIndex="9000"
|
||||
// This is similar to our Delay util component, but Delay disappears
|
||||
// immediately on load, whereas we want this to fade out smoothly. We
|
||||
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
|
||||
// fade-out at all. (The timeout was an awkward choice, it was hard to
|
||||
// find a good CSS way to specify this delay well!)
|
||||
opacity={loading && loadingDelayHasPassed ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
{spinnerVariant === "overlay" && (
|
||||
<>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
backgroundColor="gray.900"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* Against the dark overlay, use the Dark Mode spinner. */}
|
||||
<DarkMode>
|
||||
<HangerSpinner />
|
||||
</DarkMode>
|
||||
</>
|
||||
)}
|
||||
{spinnerVariant === "corner" && (
|
||||
<HangerSpinner
|
||||
size="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
/>
|
||||
)}
|
||||
</FullScreenCenter>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
&.exit-active {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{layer.canvasMovieLibraryUrl ? (
|
||||
<OutfitMovieLayer
|
||||
libraryUrl={layer.canvasMovieLibraryUrl}
|
||||
placeholderImageUrl={getBestImageUrlForLayer(layer, {
|
||||
hiResMode,
|
||||
})}
|
||||
width={canvasSize}
|
||||
height={canvasSize}
|
||||
isPaused={isPaused}
|
||||
onError={onMovieError}
|
||||
onLowFps={onLowFps}
|
||||
/>
|
||||
) : (
|
||||
<Box
|
||||
as="img"
|
||||
src={safeImageUrl(
|
||||
getBestImageUrlForLayer(layer, { hiResMode }),
|
||||
{ preferArchive },
|
||||
)}
|
||||
alt=""
|
||||
objectFit="contain"
|
||||
maxWidth="100%"
|
||||
maxHeight="100%"
|
||||
/>
|
||||
)}
|
||||
</FadeInOnLoad>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
<FullScreenCenter
|
||||
zIndex="9000"
|
||||
// This is similar to our Delay util component, but Delay disappears
|
||||
// immediately on load, whereas we want this to fade out smoothly. We
|
||||
// also use a timeout to delay the fade-in by 0.5s, but don't delay the
|
||||
// fade-out at all. (The timeout was an awkward choice, it was hard to
|
||||
// find a good CSS way to specify this delay well!)
|
||||
opacity={loading && loadingDelayHasPassed ? 1 : 0}
|
||||
transition="opacity 0.2s"
|
||||
>
|
||||
{spinnerVariant === "overlay" && (
|
||||
<>
|
||||
<Box
|
||||
position="absolute"
|
||||
top="0"
|
||||
left="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
backgroundColor="gray.900"
|
||||
opacity="0.7"
|
||||
/>
|
||||
{/* Against the dark overlay, use the Dark Mode spinner. */}
|
||||
<DarkMode>
|
||||
<HangerSpinner />
|
||||
</DarkMode>
|
||||
</>
|
||||
)}
|
||||
{spinnerVariant === "corner" && (
|
||||
<HangerSpinner
|
||||
size="sm"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
right="2"
|
||||
/>
|
||||
)}
|
||||
</FullScreenCenter>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
export function FullScreenCenter({ children, ...otherProps }) {
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
top="0"
|
||||
right="0"
|
||||
bottom="0"
|
||||
left="0"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function getBestImageUrlForLayer(layer, { hiResMode = false } = {}) {
|
||||
if (hiResMode && layer.svgUrl) {
|
||||
return layer.svgUrl;
|
||||
} else if (layer.imageUrl) {
|
||||
return layer.imageUrl;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
if (hiResMode && layer.svgUrl) {
|
||||
return layer.svgUrl;
|
||||
} else if (layer.imageUrl) {
|
||||
return layer.imageUrl;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function layerHasUsableAssets(layer, options = {}) {
|
||||
return getBestImageUrlForLayer(layer, options) != null;
|
||||
return getBestImageUrlForLayer(layer, options) != null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -380,116 +380,116 @@ function layerHasUsableAssets(layer, options = {}) {
|
|||
* all the new layers are ready, then show them all at once!
|
||||
*/
|
||||
export function usePreloadLayers(layers) {
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
|
||||
const [preferArchive] = usePreferArchive();
|
||||
|
||||
const [error, setError] = React.useState(null);
|
||||
const [loadedLayers, setLoadedLayers] = React.useState([]);
|
||||
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
|
||||
const [error, setError] = React.useState(null);
|
||||
const [loadedLayers, setLoadedLayers] = React.useState([]);
|
||||
const [layersHaveAnimations, setLayersHaveAnimations] = React.useState(false);
|
||||
|
||||
// NOTE: This condition would need to change if we started loading one at a
|
||||
// time, or if the error case would need to show a partial state!
|
||||
const loading = layers.length > 0 && loadedLayers !== layers;
|
||||
// NOTE: This condition would need to change if we started loading one at a
|
||||
// time, or if the error case would need to show a partial state!
|
||||
const loading = layers.length > 0 && loadedLayers !== layers;
|
||||
|
||||
React.useEffect(() => {
|
||||
// HACK: Don't clear the preview when we have zero layers, because it
|
||||
// usually means the parent is still loading data. I feel like this isn't
|
||||
// the right abstraction, though...
|
||||
if (layers.length === 0) {
|
||||
return;
|
||||
}
|
||||
React.useEffect(() => {
|
||||
// HACK: Don't clear the preview when we have zero layers, because it
|
||||
// usually means the parent is still loading data. I feel like this isn't
|
||||
// the right abstraction, though...
|
||||
if (layers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLayersHaveAnimations(false);
|
||||
let canceled = false;
|
||||
setError(null);
|
||||
setLayersHaveAnimations(false);
|
||||
|
||||
const minimalAssetPromises = [];
|
||||
const imageAssetPromises = [];
|
||||
const movieAssetPromises = [];
|
||||
for (const layer of layers) {
|
||||
const imageUrl = getBestImageUrlForLayer(layer, { hiResMode });
|
||||
const imageAssetPromise =
|
||||
imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null;
|
||||
if (imageAssetPromise != null) {
|
||||
imageAssetPromises.push(imageAssetPromise);
|
||||
}
|
||||
const minimalAssetPromises = [];
|
||||
const imageAssetPromises = [];
|
||||
const movieAssetPromises = [];
|
||||
for (const layer of layers) {
|
||||
const imageUrl = getBestImageUrlForLayer(layer, { hiResMode });
|
||||
const imageAssetPromise =
|
||||
imageUrl != null ? loadImage(imageUrl, { preferArchive }) : null;
|
||||
if (imageAssetPromise != null) {
|
||||
imageAssetPromises.push(imageAssetPromise);
|
||||
}
|
||||
|
||||
if (layer.canvasMovieLibraryUrl) {
|
||||
// Start preloading the movie. But we won't block on it! The blocking
|
||||
// request will still be the image, which we'll show as a
|
||||
// placeholder, which should usually be noticeably faster!
|
||||
const movieLibraryPromise = loadMovieLibrary(
|
||||
layer.canvasMovieLibraryUrl,
|
||||
{ preferArchive },
|
||||
);
|
||||
const movieAssetPromise = movieLibraryPromise.then((library) => ({
|
||||
library,
|
||||
libraryUrl: layer.canvasMovieLibraryUrl,
|
||||
}));
|
||||
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
|
||||
movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
|
||||
movieAssetPromises.push(movieAssetPromise);
|
||||
if (layer.canvasMovieLibraryUrl) {
|
||||
// Start preloading the movie. But we won't block on it! The blocking
|
||||
// request will still be the image, which we'll show as a
|
||||
// placeholder, which should usually be noticeably faster!
|
||||
const movieLibraryPromise = loadMovieLibrary(
|
||||
layer.canvasMovieLibraryUrl,
|
||||
{ preferArchive },
|
||||
);
|
||||
const movieAssetPromise = movieLibraryPromise.then((library) => ({
|
||||
library,
|
||||
libraryUrl: layer.canvasMovieLibraryUrl,
|
||||
}));
|
||||
movieAssetPromise.libraryUrl = layer.canvasMovieLibraryUrl;
|
||||
movieAssetPromise.cancel = () => movieLibraryPromise.cancel();
|
||||
movieAssetPromises.push(movieAssetPromise);
|
||||
|
||||
// The minimal asset for the movie case is *either* the image *or*
|
||||
// the movie, because we can start rendering when either is ready.
|
||||
minimalAssetPromises.push(
|
||||
Promise.any([imageAssetPromise, movieAssetPromise]),
|
||||
);
|
||||
} else if (imageAssetPromise != null) {
|
||||
minimalAssetPromises.push(imageAssetPromise);
|
||||
} else {
|
||||
console.warn(
|
||||
`Skipping preloading layer ${layer.id}: no asset URLs found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// The minimal asset for the movie case is *either* the image *or*
|
||||
// the movie, because we can start rendering when either is ready.
|
||||
minimalAssetPromises.push(
|
||||
Promise.any([imageAssetPromise, movieAssetPromise]),
|
||||
);
|
||||
} else if (imageAssetPromise != null) {
|
||||
minimalAssetPromises.push(imageAssetPromise);
|
||||
} else {
|
||||
console.warn(
|
||||
`Skipping preloading layer ${layer.id}: no asset URLs found`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// When the minimal assets have loaded, we can say the layers have
|
||||
// loaded, and allow the UI to start showing them!
|
||||
Promise.all(minimalAssetPromises)
|
||||
.then(() => {
|
||||
if (canceled) return;
|
||||
setLoadedLayers(layers);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (canceled) return;
|
||||
console.error("Error preloading outfit layers", e);
|
||||
setError(e);
|
||||
// When the minimal assets have loaded, we can say the layers have
|
||||
// loaded, and allow the UI to start showing them!
|
||||
Promise.all(minimalAssetPromises)
|
||||
.then(() => {
|
||||
if (canceled) return;
|
||||
setLoadedLayers(layers);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (canceled) return;
|
||||
console.error("Error preloading outfit layers", e);
|
||||
setError(e);
|
||||
|
||||
// Cancel any remaining promises, if cancelable.
|
||||
imageAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
});
|
||||
// Cancel any remaining promises, if cancelable.
|
||||
imageAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
movieAssetPromises.forEach((p) => p.cancel && p.cancel());
|
||||
});
|
||||
|
||||
// As the movie assets come in, check them for animations, to decide
|
||||
// whether to show the Play/Pause button.
|
||||
const checkHasAnimations = (asset) => {
|
||||
if (canceled) return;
|
||||
let assetHasAnimations;
|
||||
try {
|
||||
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
|
||||
} catch (e) {
|
||||
console.error("Error testing layers for animations", e);
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
// As the movie assets come in, check them for animations, to decide
|
||||
// whether to show the Play/Pause button.
|
||||
const checkHasAnimations = (asset) => {
|
||||
if (canceled) return;
|
||||
let assetHasAnimations;
|
||||
try {
|
||||
assetHasAnimations = getHasAnimationsForMovieAsset(asset);
|
||||
} catch (e) {
|
||||
console.error("Error testing layers for animations", e);
|
||||
setError(e);
|
||||
return;
|
||||
}
|
||||
|
||||
setLayersHaveAnimations(
|
||||
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
|
||||
);
|
||||
};
|
||||
movieAssetPromises.forEach((p) =>
|
||||
p.then(checkHasAnimations).catch((e) => {
|
||||
console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
|
||||
}),
|
||||
);
|
||||
setLayersHaveAnimations(
|
||||
(alreadyHasAnimations) => alreadyHasAnimations || assetHasAnimations,
|
||||
);
|
||||
};
|
||||
movieAssetPromises.forEach((p) =>
|
||||
p.then(checkHasAnimations).catch((e) => {
|
||||
console.error(`Error preloading movie library ${p.libraryUrl}:`, e);
|
||||
}),
|
||||
);
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [layers, hiResMode, preferArchive]);
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [layers, hiResMode, preferArchive]);
|
||||
|
||||
return { loading, error, loadedLayers, layersHaveAnimations };
|
||||
return { loading, error, loadedLayers, layersHaveAnimations };
|
||||
}
|
||||
|
||||
// This cache is large because it's only storing booleans; mostly just capping
|
||||
|
@ -497,26 +497,26 @@ export function usePreloadLayers(layers) {
|
|||
const HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE = new LRU(50);
|
||||
|
||||
function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
|
||||
// This operation can be pretty expensive! We store a cache to only do it
|
||||
// once per layer per session ish, instead of on each outfit change.
|
||||
const cachedHasAnimations =
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
|
||||
if (cachedHasAnimations) {
|
||||
return cachedHasAnimations;
|
||||
}
|
||||
// This operation can be pretty expensive! We store a cache to only do it
|
||||
// once per layer per session ish, instead of on each outfit change.
|
||||
const cachedHasAnimations =
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.get(libraryUrl);
|
||||
if (cachedHasAnimations) {
|
||||
return cachedHasAnimations;
|
||||
}
|
||||
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
const movieClip = buildMovieClip(library, libraryUrl);
|
||||
|
||||
// Some movie clips require you to tick to the first frame of the movie
|
||||
// before the children mount onto the stage. If we detect animations
|
||||
// without doing this, we'll incorrectly say no, because we see no children!
|
||||
// Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
|
||||
movieClip.advance();
|
||||
// Some movie clips require you to tick to the first frame of the movie
|
||||
// before the children mount onto the stage. If we detect animations
|
||||
// without doing this, we'll incorrectly say no, because we see no children!
|
||||
// Example: https://images.neopets.com/cp/items/data/000/000/235/235877_6d273e217c/235877.js
|
||||
movieClip.advance();
|
||||
|
||||
const movieClipHasAnimations = hasAnimations(movieClip);
|
||||
const movieClipHasAnimations = hasAnimations(movieClip);
|
||||
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
|
||||
return movieClipHasAnimations;
|
||||
HAS_ANIMATIONS_FOR_MOVIE_ASSET_CACHE.set(libraryUrl, movieClipHasAnimations);
|
||||
return movieClipHasAnimations;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -524,18 +524,18 @@ function getHasAnimationsForMovieAsset({ library, libraryUrl }) {
|
|||
* the container element once it triggers.
|
||||
*/
|
||||
function FadeInOnLoad({ children, ...props }) {
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
const [isLoaded, setIsLoaded] = React.useState(false);
|
||||
|
||||
const onLoad = React.useCallback(() => setIsLoaded(true), []);
|
||||
const onLoad = React.useCallback(() => setIsLoaded(true), []);
|
||||
|
||||
const child = React.Children.only(children);
|
||||
const wrappedChild = React.cloneElement(child, { onLoad });
|
||||
const child = React.Children.only(children);
|
||||
const wrappedChild = React.cloneElement(child, { onLoad });
|
||||
|
||||
return (
|
||||
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
|
||||
{wrappedChild}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box opacity={isLoaded ? 1 : 0} transition="opacity 0.2s" {...props}>
|
||||
{wrappedChild}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Polyfill Promise.any for older browsers: https://github.com/ungap/promise-any
|
||||
|
@ -543,16 +543,16 @@ function FadeInOnLoad({ children, ...props }) {
|
|||
// range… but it's affected 25 users in the past two months, which is
|
||||
// surprisingly high. And the polyfill is small, so let's do it! (11/2021)
|
||||
Promise.any =
|
||||
Promise.any ||
|
||||
function ($) {
|
||||
return new Promise(function (D, E, A, L) {
|
||||
A = [];
|
||||
L = $.map(function ($, i) {
|
||||
return Promise.resolve($).then(D, function (O) {
|
||||
return ((A[i] = O), --L) || E({ errors: A });
|
||||
});
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
Promise.any ||
|
||||
function ($) {
|
||||
return new Promise(function (D, E, A, L) {
|
||||
A = [];
|
||||
L = $.map(function ($, i) {
|
||||
return Promise.resolve($).then(D, function (O) {
|
||||
return ((A[i] = O), --L) || E({ errors: A });
|
||||
});
|
||||
}).length;
|
||||
});
|
||||
};
|
||||
|
||||
export default OutfitPreview;
|
||||
|
|
|
@ -2,21 +2,21 @@ import React from "react";
|
|||
import { Box } from "@chakra-ui/react";
|
||||
|
||||
function OutfitThumbnail({ outfitId, updatedAt, ...props }) {
|
||||
const versionTimestamp = new Date(updatedAt).getTime();
|
||||
const versionTimestamp = new Date(updatedAt).getTime();
|
||||
|
||||
// NOTE: It'd be more reliable for testing to use a relative path, but
|
||||
// generating these on dev is SO SLOW, that I'd rather just not.
|
||||
const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
|
||||
const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
|
||||
// NOTE: It'd be more reliable for testing to use a relative path, but
|
||||
// generating these on dev is SO SLOW, that I'd rather just not.
|
||||
const thumbnailUrl150 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/150.png`;
|
||||
const thumbnailUrl300 = `https://outfits.openneo-assets.net/outfits/${outfitId}/v/${versionTimestamp}/300.png`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="img"
|
||||
src={thumbnailUrl150}
|
||||
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Box
|
||||
as="img"
|
||||
src={thumbnailUrl150}
|
||||
srcSet={`${thumbnailUrl150} 1x, ${thumbnailUrl300} 2x`}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default OutfitThumbnail;
|
||||
|
|
|
@ -2,111 +2,111 @@ import React from "react";
|
|||
import { Box, Button, Flex, Select } from "@chakra-ui/react";
|
||||
|
||||
function PaginationToolbar({
|
||||
isLoading,
|
||||
numTotalPages,
|
||||
currentPageNumber,
|
||||
goToPageNumber,
|
||||
buildPageUrl,
|
||||
size = "md",
|
||||
...props
|
||||
isLoading,
|
||||
numTotalPages,
|
||||
currentPageNumber,
|
||||
goToPageNumber,
|
||||
buildPageUrl,
|
||||
size = "md",
|
||||
...props
|
||||
}) {
|
||||
const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
|
||||
const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
|
||||
const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
|
||||
const pagesAreLoaded = currentPageNumber != null && numTotalPages != null;
|
||||
const hasPrevPage = pagesAreLoaded && currentPageNumber > 1;
|
||||
const hasNextPage = pagesAreLoaded && currentPageNumber < numTotalPages;
|
||||
|
||||
const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
|
||||
const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
|
||||
const prevPageUrl = hasPrevPage ? buildPageUrl(currentPageNumber - 1) : null;
|
||||
const nextPageUrl = hasNextPage ? buildPageUrl(currentPageNumber + 1) : null;
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" {...props}>
|
||||
<LinkOrButton
|
||||
href={prevPageUrl}
|
||||
onClick={
|
||||
prevPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber - 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasPrevPage}
|
||||
size={size}
|
||||
>
|
||||
← Prev
|
||||
</LinkOrButton>
|
||||
{numTotalPages > 0 && (
|
||||
<Flex align="center" paddingX="4" fontSize={size}>
|
||||
<Box flex="0 0 auto">Page</Box>
|
||||
<Box width="1" />
|
||||
<PageNumberSelect
|
||||
currentPageNumber={currentPageNumber}
|
||||
numTotalPages={numTotalPages}
|
||||
onChange={goToPageNumber}
|
||||
marginBottom="-2px"
|
||||
size={size}
|
||||
/>
|
||||
<Box width="1" />
|
||||
<Box flex="0 0 auto">of {numTotalPages}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<LinkOrButton
|
||||
href={nextPageUrl}
|
||||
onClick={
|
||||
nextPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber + 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasNextPage}
|
||||
size={size}
|
||||
>
|
||||
Next →
|
||||
</LinkOrButton>
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex align="center" justify="space-between" {...props}>
|
||||
<LinkOrButton
|
||||
href={prevPageUrl}
|
||||
onClick={
|
||||
prevPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber - 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasPrevPage}
|
||||
size={size}
|
||||
>
|
||||
← Prev
|
||||
</LinkOrButton>
|
||||
{numTotalPages > 0 && (
|
||||
<Flex align="center" paddingX="4" fontSize={size}>
|
||||
<Box flex="0 0 auto">Page</Box>
|
||||
<Box width="1" />
|
||||
<PageNumberSelect
|
||||
currentPageNumber={currentPageNumber}
|
||||
numTotalPages={numTotalPages}
|
||||
onChange={goToPageNumber}
|
||||
marginBottom="-2px"
|
||||
size={size}
|
||||
/>
|
||||
<Box width="1" />
|
||||
<Box flex="0 0 auto">of {numTotalPages}</Box>
|
||||
</Flex>
|
||||
)}
|
||||
<LinkOrButton
|
||||
href={nextPageUrl}
|
||||
onClick={
|
||||
nextPageUrl == null
|
||||
? () => goToPageNumber(currentPageNumber + 1)
|
||||
: undefined
|
||||
}
|
||||
_disabled={{
|
||||
cursor: isLoading ? "wait" : "not-allowed",
|
||||
opacity: 0.4,
|
||||
}}
|
||||
isDisabled={!hasNextPage}
|
||||
size={size}
|
||||
>
|
||||
Next →
|
||||
</LinkOrButton>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, ...props }) {
|
||||
if (href != null) {
|
||||
return <Button as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
if (href != null) {
|
||||
return <Button as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function PageNumberSelect({
|
||||
currentPageNumber,
|
||||
numTotalPages,
|
||||
onChange,
|
||||
...props
|
||||
currentPageNumber,
|
||||
numTotalPages,
|
||||
onChange,
|
||||
...props
|
||||
}) {
|
||||
const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
|
||||
const allPageNumbers = Array.from({ length: numTotalPages }, (_, i) => i + 1);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
(e) => onChange(Number(e.target.value)),
|
||||
[onChange],
|
||||
);
|
||||
const handleChange = React.useCallback(
|
||||
(e) => onChange(Number(e.target.value)),
|
||||
[onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentPageNumber}
|
||||
onChange={handleChange}
|
||||
width="7ch"
|
||||
variant="flushed"
|
||||
textAlign="center"
|
||||
{...props}
|
||||
>
|
||||
{allPageNumbers.map((pageNumber) => (
|
||||
<option key={pageNumber} value={pageNumber}>
|
||||
{pageNumber}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
value={currentPageNumber}
|
||||
onChange={handleChange}
|
||||
width="7ch"
|
||||
variant="flushed"
|
||||
textAlign="center"
|
||||
{...props}
|
||||
>
|
||||
{allPageNumbers.map((pageNumber) => (
|
||||
<option key={pageNumber} value={pageNumber}>
|
||||
{pageNumber}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export default PaginationToolbar;
|
||||
|
|
|
@ -18,320 +18,320 @@ import { Delay, logAndCapture, useFetch } from "../util";
|
|||
* devices.
|
||||
*/
|
||||
function SpeciesColorPicker({
|
||||
speciesId,
|
||||
colorId,
|
||||
idealPose,
|
||||
showPlaceholders = false,
|
||||
colorPlaceholderText = "",
|
||||
speciesPlaceholderText = "",
|
||||
stateMustAlwaysBeValid = false,
|
||||
isDisabled = false,
|
||||
speciesIsDisabled = false,
|
||||
size = "md",
|
||||
speciesTestId = null,
|
||||
colorTestId = null,
|
||||
onChange,
|
||||
speciesId,
|
||||
colorId,
|
||||
idealPose,
|
||||
showPlaceholders = false,
|
||||
colorPlaceholderText = "",
|
||||
speciesPlaceholderText = "",
|
||||
stateMustAlwaysBeValid = false,
|
||||
isDisabled = false,
|
||||
speciesIsDisabled = false,
|
||||
size = "md",
|
||||
speciesTestId = null,
|
||||
colorTestId = null,
|
||||
onChange,
|
||||
}) {
|
||||
const {
|
||||
loading: loadingMeta,
|
||||
error: errorMeta,
|
||||
data: meta,
|
||||
} = useQuery(gql`
|
||||
query SpeciesColorPicker {
|
||||
allSpecies {
|
||||
id
|
||||
name
|
||||
standardBodyId # Used for keeping items on during standard color changes
|
||||
}
|
||||
const {
|
||||
loading: loadingMeta,
|
||||
error: errorMeta,
|
||||
data: meta,
|
||||
} = useQuery(gql`
|
||||
query SpeciesColorPicker {
|
||||
allSpecies {
|
||||
id
|
||||
name
|
||||
standardBodyId # Used for keeping items on during standard color changes
|
||||
}
|
||||
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard # Used for keeping items on during standard color changes
|
||||
}
|
||||
}
|
||||
`);
|
||||
allColors {
|
||||
id
|
||||
name
|
||||
isStandard # Used for keeping items on during standard color changes
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
||||
const {
|
||||
loading: loadingValids,
|
||||
error: errorValids,
|
||||
valids,
|
||||
} = useAllValidPetPoses();
|
||||
const {
|
||||
loading: loadingValids,
|
||||
error: errorValids,
|
||||
valids,
|
||||
} = useAllValidPetPoses();
|
||||
|
||||
const allColors = (meta && [...meta.allColors]) || [];
|
||||
allColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const allSpecies = (meta && [...meta.allSpecies]) || [];
|
||||
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const allColors = (meta && [...meta.allColors]) || [];
|
||||
allColors.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const allSpecies = (meta && [...meta.allSpecies]) || [];
|
||||
allSpecies.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
|
||||
if ((loadingMeta || loadingValids) && !showPlaceholders) {
|
||||
return (
|
||||
<Delay ms={5000}>
|
||||
<Text color={textColor} textShadow="md">
|
||||
Loading species/color data…
|
||||
</Text>
|
||||
</Delay>
|
||||
);
|
||||
}
|
||||
if ((loadingMeta || loadingValids) && !showPlaceholders) {
|
||||
return (
|
||||
<Delay ms={5000}>
|
||||
<Text color={textColor} textShadow="md">
|
||||
Loading species/color data…
|
||||
</Text>
|
||||
</Delay>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMeta || errorValids) {
|
||||
return (
|
||||
<Text color={textColor} textShadow="md">
|
||||
Error loading species/color data.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (errorMeta || errorValids) {
|
||||
return (
|
||||
<Text color={textColor} textShadow="md">
|
||||
Error loading species/color data.
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
|
||||
// When the color changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeColor = (e) => {
|
||||
const newColorId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeColor`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
colorId,
|
||||
newColorId,
|
||||
});
|
||||
// When the color changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeColor = (e) => {
|
||||
const newColorId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeColor`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
colorId,
|
||||
newColorId,
|
||||
});
|
||||
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newColorId === "SpeciesColorPicker-color-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const species = allSpecies.find((s) => s.id === speciesId);
|
||||
const newColor = allColors.find((c) => c.id === newColorId);
|
||||
const validPoses = getValidPoses(valids, speciesId, newColorId);
|
||||
const isValid = validPoses.size > 0;
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// NOTE: This shouldn't happen, because we should hide invalid colors.
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
|
||||
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
|
||||
`colorId=${newColorId}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(species, newColor, isValid, closestPose);
|
||||
};
|
||||
const species = allSpecies.find((s) => s.id === speciesId);
|
||||
const newColor = allColors.find((c) => c.id === newColorId);
|
||||
const validPoses = getValidPoses(valids, speciesId, newColorId);
|
||||
const isValid = validPoses.size > 0;
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// NOTE: This shouldn't happen, because we should hide invalid colors.
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: Entered an invalid state, ` +
|
||||
`with prop stateMustAlwaysBeValid: speciesId=${speciesId}, ` +
|
||||
`colorId=${newColorId}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(species, newColor, isValid, closestPose);
|
||||
};
|
||||
|
||||
// When the species changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeSpecies = (e) => {
|
||||
const newSpeciesId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeSpecies`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
newSpeciesId,
|
||||
colorId,
|
||||
});
|
||||
// When the species changes, check if the new pair is valid, and update the
|
||||
// outfit if so!
|
||||
const onChangeSpecies = (e) => {
|
||||
const newSpeciesId = e.target.value;
|
||||
console.debug(`SpeciesColorPicker.onChangeSpecies`, {
|
||||
// for IMPRESS-2020-1H
|
||||
speciesId,
|
||||
newSpeciesId,
|
||||
colorId,
|
||||
});
|
||||
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
// Ignore switching to the placeholder option. It shouldn't generally be
|
||||
// doable once real options exist, and it doesn't represent a valid or
|
||||
// meaningful transition in the case where it could happen.
|
||||
if (newSpeciesId === "SpeciesColorPicker-species-loading-placeholder") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
|
||||
if (!newSpecies) {
|
||||
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
|
||||
// ends up coming out of `onChange`!
|
||||
console.debug({ allSpecies, loadingMeta, errorMeta, meta });
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: species not found. ` +
|
||||
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
|
||||
`colorId=${colorId}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const newSpecies = allSpecies.find((s) => s.id === newSpeciesId);
|
||||
if (!newSpecies) {
|
||||
// Trying to isolate Sentry issue IMPRESS-2020-1H, where an empty species
|
||||
// ends up coming out of `onChange`!
|
||||
console.debug({ allSpecies, loadingMeta, errorMeta, meta });
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Assertion error in SpeciesColorPicker: species not found. ` +
|
||||
`speciesId=${speciesId}, newSpeciesId=${newSpeciesId}, ` +
|
||||
`colorId=${colorId}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let color = allColors.find((c) => c.id === colorId);
|
||||
let validPoses = getValidPoses(valids, newSpeciesId, colorId);
|
||||
let isValid = validPoses.size > 0;
|
||||
let color = allColors.find((c) => c.id === colorId);
|
||||
let validPoses = getValidPoses(valids, newSpeciesId, colorId);
|
||||
let isValid = validPoses.size > 0;
|
||||
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// If `stateMustAlwaysBeValid`, but the user switches to a species that
|
||||
// doesn't support this color, that's okay and normal! We'll just switch
|
||||
// to one of the four basic colors instead.
|
||||
const basicColorId = ["8", "34", "61", "84"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
const basicColor = allColors.find((c) => c.id === basicColorId);
|
||||
color = basicColor;
|
||||
validPoses = getValidPoses(valids, newSpeciesId, color.id);
|
||||
isValid = true;
|
||||
}
|
||||
if (stateMustAlwaysBeValid && !isValid) {
|
||||
// If `stateMustAlwaysBeValid`, but the user switches to a species that
|
||||
// doesn't support this color, that's okay and normal! We'll just switch
|
||||
// to one of the four basic colors instead.
|
||||
const basicColorId = ["8", "34", "61", "84"][
|
||||
Math.floor(Math.random() * 4)
|
||||
];
|
||||
const basicColor = allColors.find((c) => c.id === basicColorId);
|
||||
color = basicColor;
|
||||
validPoses = getValidPoses(valids, newSpeciesId, color.id);
|
||||
isValid = true;
|
||||
}
|
||||
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(newSpecies, color, isValid, closestPose);
|
||||
};
|
||||
const closestPose = getClosestPose(validPoses, idealPose);
|
||||
onChange(newSpecies, color, isValid, closestPose);
|
||||
};
|
||||
|
||||
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
|
||||
// species, so the user can't switch. (We handle species differently: if you
|
||||
// switch to a new species and the color is invalid, we reset the color. We
|
||||
// think this matches users' mental hierarchy of species -> color: showing
|
||||
// supported colors for a species makes sense, but the other way around feels
|
||||
// confusing and restrictive.)
|
||||
//
|
||||
// Also, if a color is provided that wouldn't normally be visible, we still
|
||||
// show it. This can happen when someone models a new species/color combo for
|
||||
// the first time - the boxes will still be red as if it were invalid, but
|
||||
// this still smooths out the experience a lot.
|
||||
let visibleColors = allColors;
|
||||
if (stateMustAlwaysBeValid && valids && speciesId) {
|
||||
visibleColors = visibleColors.filter(
|
||||
(c) =>
|
||||
getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId,
|
||||
);
|
||||
}
|
||||
// In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this
|
||||
// species, so the user can't switch. (We handle species differently: if you
|
||||
// switch to a new species and the color is invalid, we reset the color. We
|
||||
// think this matches users' mental hierarchy of species -> color: showing
|
||||
// supported colors for a species makes sense, but the other way around feels
|
||||
// confusing and restrictive.)
|
||||
//
|
||||
// Also, if a color is provided that wouldn't normally be visible, we still
|
||||
// show it. This can happen when someone models a new species/color combo for
|
||||
// the first time - the boxes will still be red as if it were invalid, but
|
||||
// this still smooths out the experience a lot.
|
||||
let visibleColors = allColors;
|
||||
if (stateMustAlwaysBeValid && valids && speciesId) {
|
||||
visibleColors = visibleColors.filter(
|
||||
(c) =>
|
||||
getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId,
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex direction="row">
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet color"
|
||||
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChangeColor}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={colorTestId}
|
||||
>
|
||||
{
|
||||
// If the selected color isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid color ID
|
||||
// like null is intentionally provided while the real value loads.)
|
||||
!visibleColors.some((c) => c.id === colorId) && (
|
||||
<option value="SpeciesColorPicker-color-loading-placeholder">
|
||||
{colorPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
visibleColors.length === 0 && <option>Dimensional</option>
|
||||
}
|
||||
{visibleColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
<Box width={size === "sm" ? 2 : 4} />
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet species"
|
||||
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled || speciesIsDisabled}
|
||||
// Don't fade out in the speciesIsDisabled case; it's more like a
|
||||
// read-only state.
|
||||
_disabled={
|
||||
speciesIsDisabled
|
||||
? { opacity: "1", cursor: "not-allowed" }
|
||||
: undefined
|
||||
}
|
||||
onChange={onChangeSpecies}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={speciesTestId}
|
||||
>
|
||||
{
|
||||
// If the selected species isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid species
|
||||
// ID like null is intentionally provided while the real value
|
||||
// loads.)
|
||||
!allSpecies.some((s) => s.id === speciesId) && (
|
||||
<option value="SpeciesColorPicker-species-loading-placeholder">
|
||||
{speciesPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
allSpecies.length === 0 && <option>Tuskaninny</option>
|
||||
}
|
||||
{allSpecies.map((species) => (
|
||||
<option key={species.id} value={species.id}>
|
||||
{species.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex direction="row">
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet color"
|
||||
value={colorId || "SpeciesColorPicker-color-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
onChange={onChangeColor}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={colorTestId}
|
||||
>
|
||||
{
|
||||
// If the selected color isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid color ID
|
||||
// like null is intentionally provided while the real value loads.)
|
||||
!visibleColors.some((c) => c.id === colorId) && (
|
||||
<option value="SpeciesColorPicker-color-loading-placeholder">
|
||||
{colorPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
visibleColors.length === 0 && <option>Dimensional</option>
|
||||
}
|
||||
{visibleColors.map((color) => (
|
||||
<option key={color.id} value={color.id}>
|
||||
{color.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
<Box width={size === "sm" ? 2 : 4} />
|
||||
<SpeciesColorSelect
|
||||
aria-label="Pet species"
|
||||
value={speciesId || "SpeciesColorPicker-species-loading-placeholder"}
|
||||
// We also wait for the valid pairs before enabling, so users can't
|
||||
// trigger change events we're not ready for. Also, if the caller
|
||||
// hasn't provided species and color yet, assume it's still loading.
|
||||
isLoading={
|
||||
allColors.length === 0 || loadingValids || !speciesId || !colorId
|
||||
}
|
||||
isDisabled={isDisabled || speciesIsDisabled}
|
||||
// Don't fade out in the speciesIsDisabled case; it's more like a
|
||||
// read-only state.
|
||||
_disabled={
|
||||
speciesIsDisabled
|
||||
? { opacity: "1", cursor: "not-allowed" }
|
||||
: undefined
|
||||
}
|
||||
onChange={onChangeSpecies}
|
||||
size={size}
|
||||
valids={valids}
|
||||
speciesId={speciesId}
|
||||
colorId={colorId}
|
||||
data-test-id={speciesTestId}
|
||||
>
|
||||
{
|
||||
// If the selected species isn't in the set we have here, show the
|
||||
// placeholder. (Can happen during loading, or if an invalid species
|
||||
// ID like null is intentionally provided while the real value
|
||||
// loads.)
|
||||
!allSpecies.some((s) => s.id === speciesId) && (
|
||||
<option value="SpeciesColorPicker-species-loading-placeholder">
|
||||
{speciesPlaceholderText}
|
||||
</option>
|
||||
)
|
||||
}
|
||||
{
|
||||
// A long name for sizing! Should appear below the placeholder, out
|
||||
// of view.
|
||||
allSpecies.length === 0 && <option>Tuskaninny</option>
|
||||
}
|
||||
{allSpecies.map((species) => (
|
||||
<option key={species.id} value={species.id}>
|
||||
{species.name}
|
||||
</option>
|
||||
))}
|
||||
</SpeciesColorSelect>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const SpeciesColorSelect = ({
|
||||
size,
|
||||
valids,
|
||||
speciesId,
|
||||
colorId,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
...props
|
||||
size,
|
||||
valids,
|
||||
speciesId,
|
||||
colorId,
|
||||
isDisabled,
|
||||
isLoading,
|
||||
...props
|
||||
}) => {
|
||||
const backgroundColor = useColorModeValue("white", "gray.600");
|
||||
const borderColor = useColorModeValue("green.600", "transparent");
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
const backgroundColor = useColorModeValue("white", "gray.600");
|
||||
const borderColor = useColorModeValue("green.600", "transparent");
|
||||
const textColor = useColorModeValue("inherit", "green.50");
|
||||
|
||||
const loadingProps = isLoading
|
||||
? {
|
||||
// Visually the disabled state is the same as the normal state, but
|
||||
// with a wait cursor. We don't expect this to take long, and the flash
|
||||
// of content is rough!
|
||||
opacity: "1 !important",
|
||||
cursor: "wait !important",
|
||||
}
|
||||
: {};
|
||||
const loadingProps = isLoading
|
||||
? {
|
||||
// Visually the disabled state is the same as the normal state, but
|
||||
// with a wait cursor. We don't expect this to take long, and the flash
|
||||
// of content is rough!
|
||||
opacity: "1 !important",
|
||||
cursor: "wait !important",
|
||||
}
|
||||
: {};
|
||||
|
||||
return (
|
||||
<Select
|
||||
backgroundColor={backgroundColor}
|
||||
color={textColor}
|
||||
size={size}
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="md"
|
||||
width="auto"
|
||||
transition="all 0.25s"
|
||||
_hover={{
|
||||
borderColor: "green.400",
|
||||
}}
|
||||
isInvalid={
|
||||
valids &&
|
||||
speciesId &&
|
||||
colorId &&
|
||||
!pairIsValid(valids, speciesId, colorId)
|
||||
}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
errorBorderColor="red.300"
|
||||
{...props}
|
||||
{...loadingProps}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Select
|
||||
backgroundColor={backgroundColor}
|
||||
color={textColor}
|
||||
size={size}
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
boxShadow="md"
|
||||
width="auto"
|
||||
transition="all 0.25s"
|
||||
_hover={{
|
||||
borderColor: "green.400",
|
||||
}}
|
||||
isInvalid={
|
||||
valids &&
|
||||
speciesId &&
|
||||
colorId &&
|
||||
!pairIsValid(valids, speciesId, colorId)
|
||||
}
|
||||
isDisabled={isDisabled || isLoading}
|
||||
errorBorderColor="red.300"
|
||||
{...props}
|
||||
{...loadingProps}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
let cachedResponseForAllValidPetPoses = null;
|
||||
|
@ -346,79 +346,76 @@ let cachedResponseForAllValidPetPoses = null;
|
|||
* data from GraphQL serves on the first render, without a loading state.
|
||||
*/
|
||||
export function useAllValidPetPoses() {
|
||||
const networkResponse = useFetch(
|
||||
buildImpress2020Url("/api/validPetPoses"),
|
||||
{
|
||||
responseType: "arrayBuffer",
|
||||
// If we already have globally-cached valids, skip the request.
|
||||
skip: cachedResponseForAllValidPetPoses != null,
|
||||
},
|
||||
);
|
||||
const networkResponse = useFetch(buildImpress2020Url("/api/validPetPoses"), {
|
||||
responseType: "arrayBuffer",
|
||||
// If we already have globally-cached valids, skip the request.
|
||||
skip: cachedResponseForAllValidPetPoses != null,
|
||||
});
|
||||
|
||||
// Use the globally-cached response if we have one, or await the network
|
||||
// response if not.
|
||||
const response = cachedResponseForAllValidPetPoses || networkResponse;
|
||||
const { loading, error, data: validsBuffer } = response;
|
||||
// Use the globally-cached response if we have one, or await the network
|
||||
// response if not.
|
||||
const response = cachedResponseForAllValidPetPoses || networkResponse;
|
||||
const { loading, error, data: validsBuffer } = response;
|
||||
|
||||
const valids = React.useMemo(
|
||||
() => validsBuffer && new DataView(validsBuffer),
|
||||
[validsBuffer],
|
||||
);
|
||||
const valids = React.useMemo(
|
||||
() => validsBuffer && new DataView(validsBuffer),
|
||||
[validsBuffer],
|
||||
);
|
||||
|
||||
// Once a network response comes in, save it as the globally-cached response.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
networkResponse &&
|
||||
!networkResponse.loading &&
|
||||
!cachedResponseForAllValidPetPoses
|
||||
) {
|
||||
cachedResponseForAllValidPetPoses = networkResponse;
|
||||
}
|
||||
}, [networkResponse]);
|
||||
// Once a network response comes in, save it as the globally-cached response.
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
networkResponse &&
|
||||
!networkResponse.loading &&
|
||||
!cachedResponseForAllValidPetPoses
|
||||
) {
|
||||
cachedResponseForAllValidPetPoses = networkResponse;
|
||||
}
|
||||
}, [networkResponse]);
|
||||
|
||||
return { loading, error, valids };
|
||||
return { loading, error, valids };
|
||||
}
|
||||
|
||||
function getPairByte(valids, speciesId, colorId) {
|
||||
// Reading a bit table, owo!
|
||||
const speciesIndex = speciesId - 1;
|
||||
const colorIndex = colorId - 1;
|
||||
const numColors = valids.getUint8(1);
|
||||
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
|
||||
try {
|
||||
return valids.getUint8(pairByteIndex);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`,
|
||||
),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
// Reading a bit table, owo!
|
||||
const speciesIndex = speciesId - 1;
|
||||
const colorIndex = colorId - 1;
|
||||
const numColors = valids.getUint8(1);
|
||||
const pairByteIndex = speciesIndex * numColors + colorIndex + 2;
|
||||
try {
|
||||
return valids.getUint8(pairByteIndex);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Error loading valid poses for species=${speciesId}, color=${colorId}: ${e.message}`,
|
||||
),
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function pairIsValid(valids, speciesId, colorId) {
|
||||
return getPairByte(valids, speciesId, colorId) !== 0;
|
||||
return getPairByte(valids, speciesId, colorId) !== 0;
|
||||
}
|
||||
|
||||
export function getValidPoses(valids, speciesId, colorId) {
|
||||
const pairByte = getPairByte(valids, speciesId, colorId);
|
||||
const pairByte = getPairByte(valids, speciesId, colorId);
|
||||
|
||||
const validPoses = new Set();
|
||||
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
|
||||
if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
|
||||
if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
|
||||
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
|
||||
if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
|
||||
if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
|
||||
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
|
||||
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
|
||||
const validPoses = new Set();
|
||||
if (pairByte & 0b00000001) validPoses.add("HAPPY_MASC");
|
||||
if (pairByte & 0b00000010) validPoses.add("SAD_MASC");
|
||||
if (pairByte & 0b00000100) validPoses.add("SICK_MASC");
|
||||
if (pairByte & 0b00001000) validPoses.add("HAPPY_FEM");
|
||||
if (pairByte & 0b00010000) validPoses.add("SAD_FEM");
|
||||
if (pairByte & 0b00100000) validPoses.add("SICK_FEM");
|
||||
if (pairByte & 0b01000000) validPoses.add("UNCONVERTED");
|
||||
if (pairByte & 0b10000000) validPoses.add("UNKNOWN");
|
||||
|
||||
return validPoses;
|
||||
return validPoses;
|
||||
}
|
||||
|
||||
export function getClosestPose(validPoses, idealPose) {
|
||||
return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
|
||||
return closestPosesInOrder[idealPose].find((p) => validPoses.has(p)) || null;
|
||||
}
|
||||
|
||||
// For each pose, in what order do we prefer to match other poses?
|
||||
|
@ -431,86 +428,86 @@ export function getClosestPose(validPoses, idealPose) {
|
|||
// - Unconverted vs converted is the biggest possible difference.
|
||||
// - Unknown is the pose of last resort - even coming from another unknown.
|
||||
const closestPosesInOrder = {
|
||||
HAPPY_MASC: [
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
HAPPY_FEM: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_MASC: [
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_FEM: [
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_MASC: [
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_FEM: [
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNCONVERTED: [
|
||||
"UNCONVERTED",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNKNOWN: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
HAPPY_MASC: [
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
HAPPY_FEM: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_MASC: [
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SAD_FEM: [
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_MASC: [
|
||||
"SICK_MASC",
|
||||
"SICK_FEM",
|
||||
"SAD_MASC",
|
||||
"SAD_FEM",
|
||||
"HAPPY_MASC",
|
||||
"HAPPY_FEM",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
SICK_FEM: [
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNCONVERTED: [
|
||||
"UNCONVERTED",
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNKNOWN",
|
||||
],
|
||||
UNKNOWN: [
|
||||
"HAPPY_FEM",
|
||||
"HAPPY_MASC",
|
||||
"SAD_FEM",
|
||||
"SAD_MASC",
|
||||
"SICK_FEM",
|
||||
"SICK_MASC",
|
||||
"UNCONVERTED",
|
||||
"UNKNOWN",
|
||||
],
|
||||
};
|
||||
|
||||
export default React.memo(SpeciesColorPicker);
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
useToken,
|
||||
Box,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
useToken,
|
||||
} from "@chakra-ui/react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
|
||||
|
@ -14,440 +14,440 @@ import { CheckIcon, CloseIcon, StarIcon } from "@chakra-ui/icons";
|
|||
import usePreferArchive from "./usePreferArchive";
|
||||
|
||||
function SquareItemCard({
|
||||
item,
|
||||
showRemoveButton = false,
|
||||
onRemove = () => {},
|
||||
tradeMatchingMode = null,
|
||||
footer = null,
|
||||
...props
|
||||
item,
|
||||
showRemoveButton = false,
|
||||
onRemove = () => {},
|
||||
tradeMatchingMode = null,
|
||||
footer = null,
|
||||
...props
|
||||
}) {
|
||||
const outlineShadowValue = useToken("shadows", "outline");
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
const outlineShadowValue = useToken("shadows", "outline");
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
|
||||
const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
|
||||
const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
|
||||
const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] =
|
||||
useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
|
||||
const tradeMatchOwnShadowColor = useColorModeValue("green.500", "green.200");
|
||||
const tradeMatchWantShadowColor = useColorModeValue("blue.400", "blue.200");
|
||||
const [tradeMatchOwnShadowColorValue, tradeMatchWantShadowColorValue] =
|
||||
useToken("colors", [tradeMatchOwnShadowColor, tradeMatchWantShadowColor]);
|
||||
|
||||
// When this is a trade match, give it an extra colorful shadow highlight so
|
||||
// it stands out! (They'll generally be sorted to the front anyway, but this
|
||||
// make it easier to scan a user's lists page, and to learn how the sorting
|
||||
// works!)
|
||||
let tradeMatchShadow;
|
||||
if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
|
||||
} else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
|
||||
} else {
|
||||
tradeMatchShadow = null;
|
||||
}
|
||||
// When this is a trade match, give it an extra colorful shadow highlight so
|
||||
// it stands out! (They'll generally be sorted to the front anyway, but this
|
||||
// make it easier to scan a user's lists page, and to learn how the sorting
|
||||
// works!)
|
||||
let tradeMatchShadow;
|
||||
if (tradeMatchingMode === "offering" && item.currentUserWantsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchWantShadowColorValue}`;
|
||||
} else if (tradeMatchingMode === "seeking" && item.currentUserOwnsThis) {
|
||||
tradeMatchShadow = `0 0 6px ${tradeMatchOwnShadowColorValue}`;
|
||||
} else {
|
||||
tradeMatchShadow = null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big
|
||||
// perf win by using Emotion directly instead of Chakra's styled-system
|
||||
// Box.
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
`}
|
||||
role="group"
|
||||
>
|
||||
<Box
|
||||
as="a"
|
||||
href={`/items/${item.id}`}
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
transition: all 0.2s;
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: ${outlineShadowValue};
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<SquareItemCardLayout
|
||||
name={item.name}
|
||||
thumbnailImage={
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
tradeMatchingMode={tradeMatchingMode}
|
||||
/>
|
||||
}
|
||||
removeButton={
|
||||
showRemoveButton ? (
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
) : null
|
||||
}
|
||||
boxShadow={tradeMatchShadow}
|
||||
footer={footer}
|
||||
/>
|
||||
</Box>
|
||||
{showRemoveButton && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: 1;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big
|
||||
// perf win by using Emotion directly instead of Chakra's styled-system
|
||||
// Box.
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
display: flex;
|
||||
`}
|
||||
role="group"
|
||||
>
|
||||
<Box
|
||||
as="a"
|
||||
href={`/items/${item.id}`}
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
transition: all 0.2s;
|
||||
&:hover,
|
||||
&:focus {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: ${outlineShadowValue};
|
||||
outline: none;
|
||||
}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<SquareItemCardLayout
|
||||
name={item.name}
|
||||
thumbnailImage={
|
||||
<ItemThumbnail
|
||||
item={item}
|
||||
tradeMatchingMode={tradeMatchingMode}
|
||||
/>
|
||||
}
|
||||
removeButton={
|
||||
showRemoveButton ? (
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
) : null
|
||||
}
|
||||
boxShadow={tradeMatchShadow}
|
||||
footer={footer}
|
||||
/>
|
||||
</Box>
|
||||
{showRemoveButton && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
z-index: 1;
|
||||
|
||||
/* Apply some padding, so accidental clicks around the button
|
||||
/* Apply some padding, so accidental clicks around the button
|
||||
* don't click the link instead, or vice-versa! */
|
||||
padding: 0.75em;
|
||||
padding: 0.75em;
|
||||
|
||||
opacity: 0;
|
||||
[role="group"]:hover &,
|
||||
[role="group"]:focus-within &,
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
opacity: 0;
|
||||
[role="group"]:hover &,
|
||||
[role="group"]:focus-within &,
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<SquareItemCardRemoveButton onClick={onRemove} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareItemCardLayout({
|
||||
name,
|
||||
thumbnailImage,
|
||||
footer,
|
||||
minHeightNumLines = 2,
|
||||
boxShadow = null,
|
||||
name,
|
||||
thumbnailImage,
|
||||
footer,
|
||||
minHeightNumLines = 2,
|
||||
boxShadow = null,
|
||||
}) {
|
||||
const { brightBackground } = useCommonStyles();
|
||||
const brightBackgroundValue = useToken("colors", brightBackground);
|
||||
const theme = useTheme();
|
||||
const { brightBackground } = useCommonStyles();
|
||||
const brightBackgroundValue = useToken("colors", brightBackground);
|
||||
const theme = useTheme();
|
||||
|
||||
return (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big perf
|
||||
// win by using Emotion directly instead of Chakra's styled-system Box.
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow: ${boxShadow || theme.shadows.md};
|
||||
border-radius: ${theme.radii.md};
|
||||
padding: ${theme.space["3"]};
|
||||
width: calc(80px + 2em);
|
||||
background: ${brightBackgroundValue};
|
||||
`}
|
||||
>
|
||||
{thumbnailImage}
|
||||
<div
|
||||
className={css`
|
||||
margin-top: ${theme.space["1"]};
|
||||
font-size: ${theme.fontSizes.sm};
|
||||
/* Set min height to match a 2-line item name, so the cards
|
||||
return (
|
||||
// SquareItemCard renders in large lists of 1k+ items, so we get a big perf
|
||||
// win by using Emotion directly instead of Chakra's styled-system Box.
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
box-shadow: ${boxShadow || theme.shadows.md};
|
||||
border-radius: ${theme.radii.md};
|
||||
padding: ${theme.space["3"]};
|
||||
width: calc(80px + 2em);
|
||||
background: ${brightBackgroundValue};
|
||||
`}
|
||||
>
|
||||
{thumbnailImage}
|
||||
<div
|
||||
className={css`
|
||||
margin-top: ${theme.space["1"]};
|
||||
font-size: ${theme.fontSizes.sm};
|
||||
/* Set min height to match a 2-line item name, so the cards
|
||||
* in a row aren't toooo differently sized... */
|
||||
min-height: ${minHeightNumLines * 1.5 + "em"};
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
`}
|
||||
// HACK: Emotion turns this into -webkit-display: -webkit-box?
|
||||
style={{ display: "-webkit-box" }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{footer && (
|
||||
<Box marginTop="2" width="100%">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
min-height: ${minHeightNumLines * 1.5 + "em"};
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
`}
|
||||
// HACK: Emotion turns this into -webkit-display: -webkit-box?
|
||||
style={{ display: "-webkit-box" }}
|
||||
>
|
||||
{name}
|
||||
</div>
|
||||
{footer && (
|
||||
<Box marginTop="2" width="100%">
|
||||
{footer}
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemThumbnail({ item, tradeMatchingMode }) {
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
|
||||
const [preferArchive] = usePreferArchive();
|
||||
const kindColorScheme = item.isNc ? "purple" : item.isPb ? "orange" : "gray";
|
||||
|
||||
const thumbnailShadowColor = useColorModeValue(
|
||||
`${kindColorScheme}.200`,
|
||||
`${kindColorScheme}.600`,
|
||||
);
|
||||
const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
const thumbnailShadowColor = useColorModeValue(
|
||||
`${kindColorScheme}.200`,
|
||||
`${kindColorScheme}.600`,
|
||||
);
|
||||
const thumbnailShadowColorValue = useToken("colors", thumbnailShadowColor);
|
||||
const mdRadiusValue = useToken("radii", "md");
|
||||
|
||||
// Normally, we just show the owns/wants badges depending on whether the
|
||||
// current user owns/wants it. But, in a trade list, we use trade-matching
|
||||
// mode instead: only show the badge if it represents a viable trade, and add
|
||||
// some extra flair to it, too!
|
||||
let showOwnsBadge;
|
||||
let showWantsBadge;
|
||||
let showTradeMatchFlair;
|
||||
if (tradeMatchingMode == null) {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = false;
|
||||
} else if (tradeMatchingMode === "offering") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "seeking") {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "hide-all") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = false;
|
||||
} else {
|
||||
throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
|
||||
}
|
||||
// Normally, we just show the owns/wants badges depending on whether the
|
||||
// current user owns/wants it. But, in a trade list, we use trade-matching
|
||||
// mode instead: only show the badge if it represents a viable trade, and add
|
||||
// some extra flair to it, too!
|
||||
let showOwnsBadge;
|
||||
let showWantsBadge;
|
||||
let showTradeMatchFlair;
|
||||
if (tradeMatchingMode == null) {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = false;
|
||||
} else if (tradeMatchingMode === "offering") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = item.currentUserWantsThis;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "seeking") {
|
||||
showOwnsBadge = item.currentUserOwnsThis;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = true;
|
||||
} else if (tradeMatchingMode === "hide-all") {
|
||||
showOwnsBadge = false;
|
||||
showWantsBadge = false;
|
||||
showTradeMatchFlair = false;
|
||||
} else {
|
||||
throw new Error(`unexpected tradeMatchingMode ${tradeMatchingMode}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
<img
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
width={80}
|
||||
height={80}
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
<img
|
||||
src={safeImageUrl(item.thumbnailUrl, { preferArchive })}
|
||||
alt={`Thumbnail art for ${item.name}`}
|
||||
width={80}
|
||||
height={80}
|
||||
className={css`
|
||||
border-radius: ${mdRadiusValue};
|
||||
box-shadow: 0 0 4px ${thumbnailShadowColorValue};
|
||||
|
||||
/* Don't let alt text flash in while loading */
|
||||
&:-moz-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`}
|
||||
>
|
||||
{showOwnsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="green"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You own this, and they want it!"
|
||||
: "You own this"
|
||||
}
|
||||
>
|
||||
<CheckIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
{showWantsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="blue"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You want this, and they own it!"
|
||||
: "You want this"
|
||||
}
|
||||
>
|
||||
<StarIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
</div>
|
||||
{item.isNc != null && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: -3px;
|
||||
`}
|
||||
>
|
||||
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
|
||||
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
|
||||
</ItemThumbnailKindBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
/* Don't let alt text flash in while loading */
|
||||
&:-moz-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
`}
|
||||
loading="lazy"
|
||||
/>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
left: -6px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
`}
|
||||
>
|
||||
{showOwnsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="green"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You own this, and they want it!"
|
||||
: "You own this"
|
||||
}
|
||||
>
|
||||
<CheckIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
{showWantsBadge && (
|
||||
<ItemOwnsWantsBadge
|
||||
colorScheme="blue"
|
||||
label={
|
||||
showTradeMatchFlair
|
||||
? "You want this, and they own it!"
|
||||
: "You want this"
|
||||
}
|
||||
>
|
||||
<StarIcon />
|
||||
{showTradeMatchFlair && (
|
||||
<div
|
||||
className={css`
|
||||
margin-left: 0.25em;
|
||||
margin-right: 0.125rem;
|
||||
`}
|
||||
>
|
||||
Match
|
||||
</div>
|
||||
)}
|
||||
</ItemOwnsWantsBadge>
|
||||
)}
|
||||
</div>
|
||||
{item.isNc != null && (
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
bottom: -6px;
|
||||
right: -3px;
|
||||
`}
|
||||
>
|
||||
<ItemThumbnailKindBadge colorScheme={kindColorScheme}>
|
||||
{item.isNc ? "NC" : item.isPb ? "PB" : "NP"}
|
||||
</ItemThumbnailKindBadge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemOwnsWantsBadge({ colorScheme, children, label }) {
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`,
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`,
|
||||
);
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`,
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`,
|
||||
);
|
||||
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={css`
|
||||
border-radius: 999px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 2px ${badgeBackgroundValue};
|
||||
/* Decrease the padding: I don't want to hit the edges, but I want
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
aria-label={label}
|
||||
title={label}
|
||||
className={css`
|
||||
border-radius: 999px;
|
||||
height: 16px;
|
||||
min-width: 16px;
|
||||
font-size: 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0 2px ${badgeBackgroundValue};
|
||||
/* Decrease the padding: I don't want to hit the edges, but I want
|
||||
* to be a circle when possible! */
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
padding-left: 0.125rem;
|
||||
padding-right: 0.125rem;
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemThumbnailKindBadge({ colorScheme, children }) {
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`,
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`,
|
||||
);
|
||||
const badgeBackground = useColorModeValue(
|
||||
`${colorScheme}.100`,
|
||||
`${colorScheme}.500`,
|
||||
);
|
||||
const badgeColor = useColorModeValue(
|
||||
`${colorScheme}.500`,
|
||||
`${colorScheme}.100`,
|
||||
);
|
||||
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
const [badgeBackgroundValue, badgeColorValue] = useToken("colors", [
|
||||
badgeBackground,
|
||||
badgeColor,
|
||||
]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
/* Copied from Chakra <Badge> */
|
||||
white-space: nowrap;
|
||||
vertical-align: middle;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.65rem;
|
||||
border-radius: 0.125rem;
|
||||
font-weight: 700;
|
||||
background: ${badgeBackgroundValue};
|
||||
color: ${badgeColorValue};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function SquareItemCardRemoveButton({ onClick }) {
|
||||
const backgroundColor = useColorModeValue("gray.200", "gray.500");
|
||||
const backgroundColor = useColorModeValue("gray.200", "gray.500");
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
title="Remove"
|
||||
icon={<CloseIcon />}
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
backgroundColor={backgroundColor}
|
||||
onClick={onClick}
|
||||
_hover={{
|
||||
// Override night mode's fade-out on hover
|
||||
opacity: 1,
|
||||
transform: "scale(1.15, 1.15)",
|
||||
}}
|
||||
_focus={{
|
||||
transform: "scale(1.15, 1.15)",
|
||||
boxShadow: "outline",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<IconButton
|
||||
aria-label="Remove"
|
||||
title="Remove"
|
||||
icon={<CloseIcon />}
|
||||
size="xs"
|
||||
borderRadius="full"
|
||||
boxShadow="lg"
|
||||
backgroundColor={backgroundColor}
|
||||
onClick={onClick}
|
||||
_hover={{
|
||||
// Override night mode's fade-out on hover
|
||||
opacity: 1,
|
||||
transform: "scale(1.15, 1.15)",
|
||||
}}
|
||||
_focus={{
|
||||
transform: "scale(1.15, 1.15)",
|
||||
boxShadow: "outline",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function SquareItemCardSkeleton({ minHeightNumLines, footer = null }) {
|
||||
return (
|
||||
<SquareItemCardLayout
|
||||
name={
|
||||
<>
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
{minHeightNumLines >= 3 && (
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
thumbnailImage={<Skeleton width="80px" height="80px" />}
|
||||
minHeightNumLines={minHeightNumLines}
|
||||
footer={footer}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<SquareItemCardLayout
|
||||
name={
|
||||
<>
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
{minHeightNumLines >= 3 && (
|
||||
<Skeleton width="100%" height="1em" marginTop="2" />
|
||||
)}
|
||||
</>
|
||||
}
|
||||
thumbnailImage={<Skeleton width="80px" height="80px" />}
|
||||
minHeightNumLines={minHeightNumLines}
|
||||
footer={footer}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default SquareItemCard;
|
||||
|
|
|
@ -1,131 +1,131 @@
|
|||
import gql from "graphql-tag";
|
||||
|
||||
function getVisibleLayers(petAppearance, itemAppearances) {
|
||||
if (!petAppearance) {
|
||||
return [];
|
||||
}
|
||||
if (!petAppearance) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const validItemAppearances = itemAppearances.filter((a) => a);
|
||||
const validItemAppearances = itemAppearances.filter((a) => a);
|
||||
|
||||
const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
|
||||
const petLayers = petAppearance.layers.map((l) => ({ ...l, source: "pet" }));
|
||||
|
||||
const itemLayers = validItemAppearances
|
||||
.map((a) => a.layers)
|
||||
.flat()
|
||||
.map((l) => ({ ...l, source: "item" }));
|
||||
const itemLayers = validItemAppearances
|
||||
.map((a) => a.layers)
|
||||
.flat()
|
||||
.map((l) => ({ ...l, source: "item" }));
|
||||
|
||||
let allLayers = [...petLayers, ...itemLayers];
|
||||
let allLayers = [...petLayers, ...itemLayers];
|
||||
|
||||
const itemRestrictedZoneIds = new Set(
|
||||
validItemAppearances
|
||||
.map((a) => a.restrictedZones)
|
||||
.flat()
|
||||
.map((z) => z.id),
|
||||
);
|
||||
const petRestrictedZoneIds = new Set(
|
||||
petAppearance.restrictedZones.map((z) => z.id),
|
||||
);
|
||||
const itemRestrictedZoneIds = new Set(
|
||||
validItemAppearances
|
||||
.map((a) => a.restrictedZones)
|
||||
.flat()
|
||||
.map((z) => z.id),
|
||||
);
|
||||
const petRestrictedZoneIds = new Set(
|
||||
petAppearance.restrictedZones.map((z) => z.id),
|
||||
);
|
||||
|
||||
const visibleLayers = allLayers.filter((layer) => {
|
||||
// When an item restricts a zone, it hides pet layers of the same zone.
|
||||
// We use this to e.g. make a hat hide a hair ruff.
|
||||
//
|
||||
// NOTE: Items' restricted layers also affect what items you can wear at
|
||||
// the same time. We don't enforce anything about that here, and
|
||||
// instead assume that the input by this point is valid!
|
||||
if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
const visibleLayers = allLayers.filter((layer) => {
|
||||
// When an item restricts a zone, it hides pet layers of the same zone.
|
||||
// We use this to e.g. make a hat hide a hair ruff.
|
||||
//
|
||||
// NOTE: Items' restricted layers also affect what items you can wear at
|
||||
// the same time. We don't enforce anything about that here, and
|
||||
// instead assume that the input by this point is valid!
|
||||
if (layer.source === "pet" && itemRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||
// it makes body-specific items incompatible. We use this to disallow UCs
|
||||
// from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
// still allowing non-body-specific items in those zones! (I think this
|
||||
// happens for some Invisible pet stuff, too?)
|
||||
//
|
||||
// TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
// should be doing this way earlier, to prevent the item from even
|
||||
// showing up even in search results!
|
||||
//
|
||||
// NOTE: This can result in both pet layers and items occupying the same
|
||||
// zone, like Static, so long as the item isn't body-specific! That's
|
||||
// correct, and the item layer should be on top! (Here, we implement
|
||||
// it by placing item layers second in the list, and rely on JS sort
|
||||
// stability, and *then* rely on the UI to respect that ordering when
|
||||
// rendering them by depth. Not great! 😅)
|
||||
//
|
||||
// NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||
// this condition, not just the restricted zones, as a sensible
|
||||
// defensive default, even though we weren't aware of any relevant
|
||||
// items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
// occupies the real Mouth zone, and still should be visible and
|
||||
// above pet layers! So, we now only check *restricted* zones.
|
||||
//
|
||||
// NOTE: UCs used to implement their restrictions by listing specific
|
||||
// zones, but it seems that the logic has changed to just be about
|
||||
// UC-ness and body-specific-ness, and not necessarily involve the
|
||||
// set of restricted zones at all. (This matters because e.g. UCs
|
||||
// shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
||||
// don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
||||
// zone restriction case running too, because I don't think it
|
||||
// _hurts_ anything, and I'm not confident enough in this conclusion.
|
||||
//
|
||||
// TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
||||
// use zone restrictions?
|
||||
if (
|
||||
layer.source === "item" &&
|
||||
layer.bodyId !== "0" &&
|
||||
(petAppearance.pose === "UNCONVERTED" ||
|
||||
petRestrictedZoneIds.has(layer.zone.id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
// When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||
// it makes body-specific items incompatible. We use this to disallow UCs
|
||||
// from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||
// still allowing non-body-specific items in those zones! (I think this
|
||||
// happens for some Invisible pet stuff, too?)
|
||||
//
|
||||
// TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||
// should be doing this way earlier, to prevent the item from even
|
||||
// showing up even in search results!
|
||||
//
|
||||
// NOTE: This can result in both pet layers and items occupying the same
|
||||
// zone, like Static, so long as the item isn't body-specific! That's
|
||||
// correct, and the item layer should be on top! (Here, we implement
|
||||
// it by placing item layers second in the list, and rely on JS sort
|
||||
// stability, and *then* rely on the UI to respect that ordering when
|
||||
// rendering them by depth. Not great! 😅)
|
||||
//
|
||||
// NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||
// this condition, not just the restricted zones, as a sensible
|
||||
// defensive default, even though we weren't aware of any relevant
|
||||
// items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||
// occupies the real Mouth zone, and still should be visible and
|
||||
// above pet layers! So, we now only check *restricted* zones.
|
||||
//
|
||||
// NOTE: UCs used to implement their restrictions by listing specific
|
||||
// zones, but it seems that the logic has changed to just be about
|
||||
// UC-ness and body-specific-ness, and not necessarily involve the
|
||||
// set of restricted zones at all. (This matters because e.g. UCs
|
||||
// shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
||||
// don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
||||
// zone restriction case running too, because I don't think it
|
||||
// _hurts_ anything, and I'm not confident enough in this conclusion.
|
||||
//
|
||||
// TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
||||
// use zone restrictions?
|
||||
if (
|
||||
layer.source === "item" &&
|
||||
layer.bodyId !== "0" &&
|
||||
(petAppearance.pose === "UNCONVERTED" ||
|
||||
petRestrictedZoneIds.has(layer.zone.id))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
// interesting example: it has a horn, but its zone restrictions hide it!
|
||||
if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
// A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||
// interesting example: it has a horn, but its zone restrictions hide it!
|
||||
if (layer.source === "pet" && petRestrictedZoneIds.has(layer.zone.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
return true;
|
||||
});
|
||||
visibleLayers.sort((a, b) => a.zone.depth - b.zone.depth);
|
||||
|
||||
return visibleLayers;
|
||||
return visibleLayers;
|
||||
}
|
||||
|
||||
export const itemAppearanceFragmentForGetVisibleLayers = gql`
|
||||
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
bodyId
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
fragment ItemAppearanceForGetVisibleLayers on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
bodyId
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const petAppearanceFragmentForGetVisibleLayers = gql`
|
||||
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
|
||||
id
|
||||
pose
|
||||
layers {
|
||||
id
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
fragment PetAppearanceForGetVisibleLayers on PetAppearance {
|
||||
id
|
||||
pose
|
||||
layers {
|
||||
id
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
}
|
||||
}
|
||||
restrictedZones {
|
||||
id
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default getVisibleLayers;
|
||||
|
|
|
@ -2,34 +2,34 @@
|
|||
const currentUserId = readCurrentUserId();
|
||||
|
||||
function useCurrentUser() {
|
||||
if (currentUserId == null) {
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
id: null,
|
||||
};
|
||||
}
|
||||
if (currentUserId == null) {
|
||||
return {
|
||||
isLoggedIn: false,
|
||||
id: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isLoggedIn: true,
|
||||
id: currentUserId,
|
||||
};
|
||||
return {
|
||||
isLoggedIn: true,
|
||||
id: currentUserId,
|
||||
};
|
||||
}
|
||||
|
||||
function readCurrentUserId() {
|
||||
try {
|
||||
const element = document.querySelector("meta[name=dti-current-user-id]");
|
||||
const value = element.getAttribute("content");
|
||||
if (value === "null") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[readCurrentUserId] Couldn't read user ID, using null instead`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const element = document.querySelector("meta[name=dti-current-user-id]");
|
||||
const value = element.getAttribute("content");
|
||||
if (value === "null") {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[readCurrentUserId] Couldn't read user ID, using null instead`,
|
||||
error,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default useCurrentUser;
|
||||
|
|
|
@ -3,8 +3,8 @@ import gql from "graphql-tag";
|
|||
import { useQuery } from "@apollo/client";
|
||||
|
||||
import getVisibleLayers, {
|
||||
itemAppearanceFragmentForGetVisibleLayers,
|
||||
petAppearanceFragmentForGetVisibleLayers,
|
||||
itemAppearanceFragmentForGetVisibleLayers,
|
||||
petAppearanceFragmentForGetVisibleLayers,
|
||||
} from "./getVisibleLayers";
|
||||
import { useAltStyle } from "../loaders/alt-styles";
|
||||
|
||||
|
@ -13,198 +13,198 @@ import { useAltStyle } from "../loaders/alt-styles";
|
|||
* visibleLayers for rendering.
|
||||
*/
|
||||
export default function useOutfitAppearance(outfitState) {
|
||||
const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
|
||||
outfitState;
|
||||
const { wornItemIds, speciesId, colorId, pose, altStyleId, appearanceId } =
|
||||
outfitState;
|
||||
|
||||
// We split this query out from the other one, so that we can HTTP cache it.
|
||||
//
|
||||
// While Apollo gives us fine-grained caching during the page session, we can
|
||||
// only HTTP a full query at a time.
|
||||
//
|
||||
// This is a minor optimization with respect to keeping the user's cache
|
||||
// populated with their favorite species/color combinations. Once we start
|
||||
// caching the items by body instead of species/color, this could make color
|
||||
// changes really snappy!
|
||||
//
|
||||
// The larger optimization is that this enables the CDN to edge-cache the
|
||||
// most popular species/color combinations, for very fast previews on the
|
||||
// HomePage. At time of writing, Vercel isn't actually edge-caching these, I
|
||||
// assume because our traffic isn't enough - so let's keep an eye on this!
|
||||
const {
|
||||
loading: loading1,
|
||||
error: error1,
|
||||
data: data1,
|
||||
} = useQuery(
|
||||
appearanceId == null
|
||||
? gql`
|
||||
query OutfitPetAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$pose: Pose!
|
||||
) {
|
||||
petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: $pose
|
||||
) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`
|
||||
: gql`
|
||||
query OutfitPetAppearanceById($appearanceId: ID!) {
|
||||
petAppearance: petAppearanceById(id: $appearanceId) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
},
|
||||
skip:
|
||||
speciesId == null ||
|
||||
colorId == null ||
|
||||
(pose == null && appearanceId == null),
|
||||
},
|
||||
);
|
||||
// We split this query out from the other one, so that we can HTTP cache it.
|
||||
//
|
||||
// While Apollo gives us fine-grained caching during the page session, we can
|
||||
// only HTTP a full query at a time.
|
||||
//
|
||||
// This is a minor optimization with respect to keeping the user's cache
|
||||
// populated with their favorite species/color combinations. Once we start
|
||||
// caching the items by body instead of species/color, this could make color
|
||||
// changes really snappy!
|
||||
//
|
||||
// The larger optimization is that this enables the CDN to edge-cache the
|
||||
// most popular species/color combinations, for very fast previews on the
|
||||
// HomePage. At time of writing, Vercel isn't actually edge-caching these, I
|
||||
// assume because our traffic isn't enough - so let's keep an eye on this!
|
||||
const {
|
||||
loading: loading1,
|
||||
error: error1,
|
||||
data: data1,
|
||||
} = useQuery(
|
||||
appearanceId == null
|
||||
? gql`
|
||||
query OutfitPetAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$pose: Pose!
|
||||
) {
|
||||
petAppearance(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
pose: $pose
|
||||
) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`
|
||||
: gql`
|
||||
query OutfitPetAppearanceById($appearanceId: ID!) {
|
||||
petAppearance: petAppearanceById(id: $appearanceId) {
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
${petAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
appearanceId,
|
||||
},
|
||||
skip:
|
||||
speciesId == null ||
|
||||
colorId == null ||
|
||||
(pose == null && appearanceId == null),
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
data: data2,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query OutfitItemsAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$altStyleId: ID
|
||||
$wornItemIds: [ID!]!
|
||||
) {
|
||||
items(ids: $wornItemIds) {
|
||||
id
|
||||
name # HACK: This is for HTML5 detection UI in OutfitControls!
|
||||
appearance: appearanceOn(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
altStyleId: $altStyleId
|
||||
) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
altStyleId,
|
||||
wornItemIds,
|
||||
},
|
||||
skip: speciesId == null || colorId == null || wornItemIds.length === 0,
|
||||
},
|
||||
);
|
||||
const {
|
||||
loading: loading2,
|
||||
error: error2,
|
||||
data: data2,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query OutfitItemsAppearance(
|
||||
$speciesId: ID!
|
||||
$colorId: ID!
|
||||
$altStyleId: ID
|
||||
$wornItemIds: [ID!]!
|
||||
) {
|
||||
items(ids: $wornItemIds) {
|
||||
id
|
||||
name # HACK: This is for HTML5 detection UI in OutfitControls!
|
||||
appearance: appearanceOn(
|
||||
speciesId: $speciesId
|
||||
colorId: $colorId
|
||||
altStyleId: $altStyleId
|
||||
) {
|
||||
...ItemAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
${itemAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
speciesId,
|
||||
colorId,
|
||||
altStyleId,
|
||||
wornItemIds,
|
||||
},
|
||||
skip: speciesId == null || colorId == null || wornItemIds.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
isLoading: loading3,
|
||||
error: error3,
|
||||
data: altStyle,
|
||||
} = useAltStyle(altStyleId, speciesId);
|
||||
const {
|
||||
isLoading: loading3,
|
||||
error: error3,
|
||||
data: altStyle,
|
||||
} = useAltStyle(altStyleId, speciesId);
|
||||
|
||||
const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
|
||||
const items = data2?.items;
|
||||
const itemAppearances = React.useMemo(
|
||||
() => (items || []).map((i) => i.appearance),
|
||||
[items],
|
||||
);
|
||||
const visibleLayers = React.useMemo(
|
||||
() => getVisibleLayers(petAppearance, itemAppearances),
|
||||
[petAppearance, itemAppearances],
|
||||
);
|
||||
const petAppearance = altStyle?.appearance ?? data1?.petAppearance;
|
||||
const items = data2?.items;
|
||||
const itemAppearances = React.useMemo(
|
||||
() => (items || []).map((i) => i.appearance),
|
||||
[items],
|
||||
);
|
||||
const visibleLayers = React.useMemo(
|
||||
() => getVisibleLayers(petAppearance, itemAppearances),
|
||||
[petAppearance, itemAppearances],
|
||||
);
|
||||
|
||||
const bodyId = petAppearance?.bodyId;
|
||||
const bodyId = petAppearance?.bodyId;
|
||||
|
||||
return {
|
||||
loading: loading1 || loading2 || loading3,
|
||||
error: error1 || error2 || error3,
|
||||
petAppearance,
|
||||
items: items || [],
|
||||
itemAppearances,
|
||||
visibleLayers,
|
||||
bodyId,
|
||||
};
|
||||
return {
|
||||
loading: loading1 || loading2 || loading3,
|
||||
error: error1 || error2 || error3,
|
||||
petAppearance,
|
||||
items: items || [],
|
||||
itemAppearances,
|
||||
visibleLayers,
|
||||
bodyId,
|
||||
};
|
||||
}
|
||||
|
||||
export const appearanceLayerFragment = gql`
|
||||
fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
|
||||
id
|
||||
svgUrl
|
||||
canvasMovieLibraryUrl
|
||||
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
||||
bodyId
|
||||
knownGlitches # For HTML5 & Known Glitches UI
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
label
|
||||
}
|
||||
swfUrl # For the layer info modal
|
||||
}
|
||||
fragment AppearanceLayerForOutfitPreview on AppearanceLayer {
|
||||
id
|
||||
svgUrl
|
||||
canvasMovieLibraryUrl
|
||||
imageUrl: imageUrlV2(idealSize: SIZE_600)
|
||||
bodyId
|
||||
knownGlitches # For HTML5 & Known Glitches UI
|
||||
zone {
|
||||
id
|
||||
depth
|
||||
label
|
||||
}
|
||||
swfUrl # For the layer info modal
|
||||
}
|
||||
`;
|
||||
|
||||
export const appearanceLayerFragmentForSupport = gql`
|
||||
fragment AppearanceLayerForSupport on AppearanceLayer {
|
||||
id
|
||||
remoteId # HACK: This is for Support tools, but other views don't need it
|
||||
swfUrl # HACK: This is for Support tools, but other views don't need it
|
||||
zone {
|
||||
id
|
||||
label # HACK: This is for Support tools, but other views don't need it
|
||||
}
|
||||
}
|
||||
fragment AppearanceLayerForSupport on AppearanceLayer {
|
||||
id
|
||||
remoteId # HACK: This is for Support tools, but other views don't need it
|
||||
swfUrl # HACK: This is for Support tools, but other views don't need it
|
||||
zone {
|
||||
id
|
||||
label # HACK: This is for Support tools, but other views don't need it
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const itemAppearanceFragment = gql`
|
||||
fragment ItemAppearanceForOutfitPreview on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
...AppearanceLayerForSupport # HACK: Most users don't need this!
|
||||
}
|
||||
...ItemAppearanceForGetVisibleLayers
|
||||
}
|
||||
fragment ItemAppearanceForOutfitPreview on ItemAppearance {
|
||||
id
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
...AppearanceLayerForSupport # HACK: Most users don't need this!
|
||||
}
|
||||
...ItemAppearanceForGetVisibleLayers
|
||||
}
|
||||
|
||||
${appearanceLayerFragment}
|
||||
${appearanceLayerFragmentForSupport}
|
||||
${itemAppearanceFragmentForGetVisibleLayers}
|
||||
${appearanceLayerFragment}
|
||||
${appearanceLayerFragmentForSupport}
|
||||
${itemAppearanceFragmentForGetVisibleLayers}
|
||||
`;
|
||||
|
||||
export const petAppearanceFragment = gql`
|
||||
fragment PetAppearanceForOutfitPreview on PetAppearance {
|
||||
id
|
||||
bodyId
|
||||
pose # For Known Glitches UI
|
||||
isGlitched # For Known Glitches UI
|
||||
species {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
color {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
}
|
||||
...PetAppearanceForGetVisibleLayers
|
||||
}
|
||||
fragment PetAppearanceForOutfitPreview on PetAppearance {
|
||||
id
|
||||
bodyId
|
||||
pose # For Known Glitches UI
|
||||
isGlitched # For Known Glitches UI
|
||||
species {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
color {
|
||||
id # For Known Glitches UI
|
||||
}
|
||||
layers {
|
||||
id
|
||||
...AppearanceLayerForOutfitPreview
|
||||
}
|
||||
...PetAppearanceForGetVisibleLayers
|
||||
}
|
||||
|
||||
${appearanceLayerFragment}
|
||||
${petAppearanceFragmentForGetVisibleLayers}
|
||||
${appearanceLayerFragment}
|
||||
${petAppearanceFragmentForGetVisibleLayers}
|
||||
`;
|
||||
|
|
|
@ -5,18 +5,18 @@ import { useLocalStorage } from "../util";
|
|||
* using images.neopets.com, when images.neopets.com is being slow and bleh!
|
||||
*/
|
||||
function usePreferArchive() {
|
||||
const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
|
||||
"DTIPreferArchive",
|
||||
null,
|
||||
);
|
||||
const [preferArchiveSavedValue, setPreferArchive] = useLocalStorage(
|
||||
"DTIPreferArchive",
|
||||
null,
|
||||
);
|
||||
|
||||
// Oct 13 2022: I might default this back to on again if the lag gets
|
||||
// miserable again, but it's okaaay right now? ish? Bad enough that I want to
|
||||
// offer this option, but decent enough that I don't want to turn it on by
|
||||
// default and break new items yet!
|
||||
const preferArchive = preferArchiveSavedValue ?? false;
|
||||
// Oct 13 2022: I might default this back to on again if the lag gets
|
||||
// miserable again, but it's okaaay right now? ish? Bad enough that I want to
|
||||
// offer this option, but decent enough that I don't want to turn it on by
|
||||
// default and break new items yet!
|
||||
const preferArchive = preferArchiveSavedValue ?? false;
|
||||
|
||||
return [preferArchive, setPreferArchive];
|
||||
return [preferArchive, setPreferArchive];
|
||||
}
|
||||
|
||||
export default usePreferArchive;
|
||||
|
|
|
@ -11,7 +11,7 @@ export function getSupportSecret() {
|
|||
|
||||
function readOrigin() {
|
||||
const node = document.querySelector("meta[name=impress-2020-origin]");
|
||||
return node?.content || "https://impress-2020.openneo.net"
|
||||
return node?.content || "https://impress-2020.openneo.net";
|
||||
}
|
||||
|
||||
function readSupportSecret() {
|
||||
|
|
|
@ -13,9 +13,7 @@ export function useItemAppearances(id, options = {}) {
|
|||
}
|
||||
|
||||
async function loadItemAppearancesData(id) {
|
||||
const res = await fetch(
|
||||
`/items/${encodeURIComponent(id)}/appearances.json`,
|
||||
);
|
||||
const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
|
|
|
@ -44,9 +44,7 @@ async function loadSavedOutfit(id) {
|
|||
const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`loading outfit failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json().then(normalizeOutfit);
|
||||
|
@ -99,9 +97,7 @@ async function saveOutfit({
|
|||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`saving outfit failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
|
||||
return res.json().then(normalizeOutfit);
|
||||
|
@ -116,9 +112,7 @@ async function deleteOutfit(id) {
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`deleting outfit failed: ${res.status} ${res.statusText}`,
|
||||
);
|
||||
throw new Error(`deleting outfit failed: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -132,9 +126,7 @@ function normalizeOutfit(outfit) {
|
|||
appearanceId: String(outfit.pet_state_id),
|
||||
altStyleId: outfit.alt_style_id ? String(outfit.alt_style_id) : null,
|
||||
wornItemIds: (outfit.item_ids?.worn || []).map((id) => String(id)),
|
||||
closetedItemIds: (outfit.item_ids?.closeted || []).map((id) =>
|
||||
String(id),
|
||||
),
|
||||
closetedItemIds: (outfit.item_ids?.closeted || []).map((id) => String(id)),
|
||||
creator: outfit.user ? { id: String(outfit.user.id) } : null,
|
||||
createdAt: outfit.created_at,
|
||||
updatedAt: outfit.updated_at,
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
Heading,
|
||||
Link,
|
||||
useColorModeValue,
|
||||
Box,
|
||||
Flex,
|
||||
Grid,
|
||||
Heading,
|
||||
Link,
|
||||
useColorModeValue,
|
||||
} from "@chakra-ui/react";
|
||||
import loadableLibrary from "@loadable/component";
|
||||
import * as Sentry from "@sentry/react";
|
||||
|
@ -28,18 +28,18 @@ import ErrorGrundoImg2x from "./images/error-grundo@2x.png";
|
|||
* https://developers.google.com/web/fundamentals/performance/rail
|
||||
*/
|
||||
export function Delay({ children, ms = 300 }) {
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
const [isVisible, setIsVisible] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const id = setTimeout(() => setIsVisible(true), ms);
|
||||
return () => clearTimeout(id);
|
||||
}, [ms, setIsVisible]);
|
||||
React.useEffect(() => {
|
||||
const id = setTimeout(() => setIsVisible(true), ms);
|
||||
return () => clearTimeout(id);
|
||||
}, [ms, setIsVisible]);
|
||||
|
||||
return (
|
||||
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box opacity={isVisible ? 1 : 0} transition="opacity 0.5s">
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -47,17 +47,17 @@ export function Delay({ children, ms = 300 }) {
|
|||
* font and some special typographical styles!
|
||||
*/
|
||||
export function Heading1({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h1"
|
||||
size="2xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
return (
|
||||
<Heading
|
||||
as="h1"
|
||||
size="2xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="800"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -65,17 +65,17 @@ export function Heading1({ children, ...props }) {
|
|||
* special typographical styles!!
|
||||
*/
|
||||
export function Heading2({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h2"
|
||||
size="xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
return (
|
||||
<Heading
|
||||
as="h2"
|
||||
size="xl"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -83,111 +83,111 @@ export function Heading2({ children, ...props }) {
|
|||
* special typographical styles!!
|
||||
*/
|
||||
export function Heading3({ children, ...props }) {
|
||||
return (
|
||||
<Heading
|
||||
as="h3"
|
||||
size="lg"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
return (
|
||||
<Heading
|
||||
as="h3"
|
||||
size="lg"
|
||||
fontFamily="Delicious, sans-serif"
|
||||
fontWeight="700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Heading>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ErrorMessage is a simple error message for simple errors!
|
||||
*/
|
||||
export function ErrorMessage({ children, ...props }) {
|
||||
return (
|
||||
<Box color="red.400" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box color="red.400" {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export function useCommonStyles() {
|
||||
return {
|
||||
brightBackground: useColorModeValue("white", "gray.700"),
|
||||
bodyBackground: useColorModeValue("gray.50", "gray.800"),
|
||||
};
|
||||
return {
|
||||
brightBackground: useColorModeValue("white", "gray.700"),
|
||||
bodyBackground: useColorModeValue("gray.50", "gray.800"),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* safeImageUrl returns an HTTPS-safe image URL for Neopets assets!
|
||||
*/
|
||||
export function safeImageUrl(
|
||||
urlString,
|
||||
{ crossOrigin = null, preferArchive = false } = {},
|
||||
urlString,
|
||||
{ crossOrigin = null, preferArchive = false } = {},
|
||||
) {
|
||||
if (urlString == null) {
|
||||
return urlString;
|
||||
}
|
||||
if (urlString == null) {
|
||||
return urlString;
|
||||
}
|
||||
|
||||
let url;
|
||||
try {
|
||||
url = new URL(
|
||||
urlString,
|
||||
// A few item thumbnail images incorrectly start with "/". When that
|
||||
// happens, the correct URL is at images.neopets.com.
|
||||
//
|
||||
// So, we provide "http://images.neopets.com" as the base URL when
|
||||
// parsing. Most URLs are absolute and will ignore it, but relative URLs
|
||||
// will resolve relative to that base.
|
||||
"http://images.neopets.com",
|
||||
);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
|
||||
),
|
||||
);
|
||||
return buildImpress2020Url("/__error__URL-was-not-parseable__");
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = new URL(
|
||||
urlString,
|
||||
// A few item thumbnail images incorrectly start with "/". When that
|
||||
// happens, the correct URL is at images.neopets.com.
|
||||
//
|
||||
// So, we provide "http://images.neopets.com" as the base URL when
|
||||
// parsing. Most URLs are absolute and will ignore it, but relative URLs
|
||||
// will resolve relative to that base.
|
||||
"http://images.neopets.com",
|
||||
);
|
||||
} catch (e) {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl could not parse URL: ${urlString}. Returning a placeholder.`,
|
||||
),
|
||||
);
|
||||
return buildImpress2020Url("/__error__URL-was-not-parseable__");
|
||||
}
|
||||
|
||||
// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
|
||||
// proxy if we need CORS headers.
|
||||
if (
|
||||
url.origin === "http://images.neopets.com" ||
|
||||
url.origin === "https://images.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (preferArchive) {
|
||||
const archiveUrl = new URL(
|
||||
`/api/readFromArchive`,
|
||||
window.location.origin,
|
||||
);
|
||||
archiveUrl.search = new URLSearchParams({ url: url.toString() });
|
||||
url = archiveUrl;
|
||||
} else if (crossOrigin) {
|
||||
// NOTE: Previously we would rewrite this to our proxy that adds an
|
||||
// `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
|
||||
// openneo.net), but images.neopets.com now includes this header for us!
|
||||
//
|
||||
// So, do nothing!
|
||||
}
|
||||
} else if (
|
||||
url.origin === "http://pets.neopets.com" ||
|
||||
url.origin === "https://pets.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (crossOrigin) {
|
||||
url.host = "pets.neopets-asset-proxy.openneo.net";
|
||||
}
|
||||
}
|
||||
// Rewrite Neopets URLs to their HTTPS equivalents, and additionally to our
|
||||
// proxy if we need CORS headers.
|
||||
if (
|
||||
url.origin === "http://images.neopets.com" ||
|
||||
url.origin === "https://images.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (preferArchive) {
|
||||
const archiveUrl = new URL(
|
||||
`/api/readFromArchive`,
|
||||
window.location.origin,
|
||||
);
|
||||
archiveUrl.search = new URLSearchParams({ url: url.toString() });
|
||||
url = archiveUrl;
|
||||
} else if (crossOrigin) {
|
||||
// NOTE: Previously we would rewrite this to our proxy that adds an
|
||||
// `Access-Control-Allow-Origin` header (images.neopets-asset-proxy.
|
||||
// openneo.net), but images.neopets.com now includes this header for us!
|
||||
//
|
||||
// So, do nothing!
|
||||
}
|
||||
} else if (
|
||||
url.origin === "http://pets.neopets.com" ||
|
||||
url.origin === "https://pets.neopets.com"
|
||||
) {
|
||||
url.protocol = "https:";
|
||||
if (crossOrigin) {
|
||||
url.host = "pets.neopets-asset-proxy.openneo.net";
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost") {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl was provided an unsafe URL, but we don't know how to ` +
|
||||
`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
|
||||
),
|
||||
);
|
||||
return buildImpress2020Url("/__error__URL-was-not-HTTPS__");
|
||||
}
|
||||
if (url.protocol !== "https:" && url.hostname !== "localhost") {
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`safeImageUrl was provided an unsafe URL, but we don't know how to ` +
|
||||
`upgrade it to HTTPS: ${urlString}. Returning a placeholder.`,
|
||||
),
|
||||
);
|
||||
return buildImpress2020Url("/__error__URL-was-not-HTTPS__");
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -201,43 +201,43 @@ export function safeImageUrl(
|
|||
* Adapted from https://usehooks.com/useDebounce/
|
||||
*/
|
||||
export function useDebounce(
|
||||
value,
|
||||
delay,
|
||||
{ waitForFirstPause = false, initialValue = null, forceReset = null } = {},
|
||||
value,
|
||||
delay,
|
||||
{ waitForFirstPause = false, initialValue = null, forceReset = null } = {},
|
||||
) {
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(
|
||||
waitForFirstPause ? initialValue : value,
|
||||
);
|
||||
// State and setters for debounced value
|
||||
const [debouncedValue, setDebouncedValue] = React.useState(
|
||||
waitForFirstPause ? initialValue : value,
|
||||
);
|
||||
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
React.useEffect(
|
||||
() => {
|
||||
// Update debounced value after delay
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value);
|
||||
}, delay);
|
||||
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay], // Only re-call effect if value or delay changes
|
||||
);
|
||||
// Cancel the timeout if value changes (also on delay change or unmount)
|
||||
// This is how we prevent debounced value from updating if value is changed ...
|
||||
// .. within the delay period. Timeout gets cleared and restarted.
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
},
|
||||
[value, delay], // Only re-call effect if value or delay changes
|
||||
);
|
||||
|
||||
// The `forceReset` option helps us decide whether to set the value
|
||||
// immediately! We'll update it in an effect for consistency and clarity, but
|
||||
// also return it immediately rather than wait a tick.
|
||||
const shouldForceReset = forceReset && forceReset(debouncedValue, value);
|
||||
React.useEffect(() => {
|
||||
if (shouldForceReset) {
|
||||
setDebouncedValue(value);
|
||||
}
|
||||
}, [shouldForceReset, value]);
|
||||
// The `forceReset` option helps us decide whether to set the value
|
||||
// immediately! We'll update it in an effect for consistency and clarity, but
|
||||
// also return it immediately rather than wait a tick.
|
||||
const shouldForceReset = forceReset && forceReset(debouncedValue, value);
|
||||
React.useEffect(() => {
|
||||
if (shouldForceReset) {
|
||||
setDebouncedValue(value);
|
||||
}
|
||||
}, [shouldForceReset, value]);
|
||||
|
||||
return shouldForceReset ? value : debouncedValue;
|
||||
return shouldForceReset ? value : debouncedValue;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -246,53 +246,53 @@ export function useDebounce(
|
|||
* Our limited API is designed to match the `use-http` library!
|
||||
*/
|
||||
export function useFetch(url, { responseType, skip, ...fetchOptions }) {
|
||||
// Just trying to be clear about what you'll get back ^_^` If we want to
|
||||
// fetch non-binary data later, extend this and get something else from res!
|
||||
if (responseType !== "arrayBuffer") {
|
||||
throw new Error(`unsupported responseType ${responseType}`);
|
||||
}
|
||||
// Just trying to be clear about what you'll get back ^_^` If we want to
|
||||
// fetch non-binary data later, extend this and get something else from res!
|
||||
if (responseType !== "arrayBuffer") {
|
||||
throw new Error(`unsupported responseType ${responseType}`);
|
||||
}
|
||||
|
||||
const [response, setResponse] = React.useState({
|
||||
loading: skip ? false : true,
|
||||
error: null,
|
||||
data: null,
|
||||
});
|
||||
const [response, setResponse] = React.useState({
|
||||
loading: skip ? false : true,
|
||||
error: null,
|
||||
data: null,
|
||||
});
|
||||
|
||||
// We expect this to be a simple object, so this helps us only re-send the
|
||||
// fetch when the options have actually changed, rather than e.g. a new copy
|
||||
// of an identical object!
|
||||
const fetchOptionsAsJson = JSON.stringify(fetchOptions);
|
||||
// We expect this to be a simple object, so this helps us only re-send the
|
||||
// fetch when the options have actually changed, rather than e.g. a new copy
|
||||
// of an identical object!
|
||||
const fetchOptionsAsJson = JSON.stringify(fetchOptions);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
React.useEffect(() => {
|
||||
if (skip) {
|
||||
return;
|
||||
}
|
||||
|
||||
let canceled = false;
|
||||
let canceled = false;
|
||||
|
||||
fetch(url, JSON.parse(fetchOptionsAsJson))
|
||||
.then(async (res) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
fetch(url, JSON.parse(fetchOptionsAsJson))
|
||||
.then(async (res) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
setResponse({ loading: false, error: null, data: arrayBuffer });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
setResponse({ loading: false, error: null, data: arrayBuffer });
|
||||
})
|
||||
.catch((error) => {
|
||||
if (canceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setResponse({ loading: false, error, data: null });
|
||||
});
|
||||
setResponse({ loading: false, error, data: null });
|
||||
});
|
||||
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [skip, url, fetchOptionsAsJson]);
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [skip, url, fetchOptionsAsJson]);
|
||||
|
||||
return response;
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -303,96 +303,96 @@ export function useFetch(url, { responseType, skip, ...fetchOptions }) {
|
|||
*/
|
||||
let storageListeners = [];
|
||||
export function useLocalStorage(key, initialValue) {
|
||||
const loadValue = React.useCallback(() => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
const loadValue = React.useCallback(() => {
|
||||
if (typeof localStorage === "undefined") {
|
||||
return initialValue;
|
||||
}
|
||||
try {
|
||||
const item = localStorage.getItem(key);
|
||||
return item ? JSON.parse(item) : initialValue;
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return initialValue;
|
||||
}
|
||||
}, [key, initialValue]);
|
||||
|
||||
const [storedValue, setStoredValue] = React.useState(loadValue);
|
||||
const [storedValue, setStoredValue] = React.useState(loadValue);
|
||||
|
||||
const setValue = React.useCallback(
|
||||
(value) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
storageListeners.forEach((l) => l());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
const setValue = React.useCallback(
|
||||
(value) => {
|
||||
try {
|
||||
setStoredValue(value);
|
||||
window.localStorage.setItem(key, JSON.stringify(value));
|
||||
storageListeners.forEach((l) => l());
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const reloadValue = React.useCallback(() => {
|
||||
setStoredValue(loadValue());
|
||||
}, [loadValue, setStoredValue]);
|
||||
const reloadValue = React.useCallback(() => {
|
||||
setStoredValue(loadValue());
|
||||
}, [loadValue, setStoredValue]);
|
||||
|
||||
// Listen for changes elsewhere on the page, and update here too!
|
||||
React.useEffect(() => {
|
||||
storageListeners.push(reloadValue);
|
||||
return () => {
|
||||
storageListeners = storageListeners.filter((l) => l !== reloadValue);
|
||||
};
|
||||
}, [reloadValue]);
|
||||
// Listen for changes elsewhere on the page, and update here too!
|
||||
React.useEffect(() => {
|
||||
storageListeners.push(reloadValue);
|
||||
return () => {
|
||||
storageListeners = storageListeners.filter((l) => l !== reloadValue);
|
||||
};
|
||||
}, [reloadValue]);
|
||||
|
||||
// Listen for changes in other tabs, and update here too! (This does not
|
||||
// catch same-page updates!)
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("storage", reloadValue);
|
||||
return () => window.removeEventListener("storage", reloadValue);
|
||||
}, [reloadValue]);
|
||||
// Listen for changes in other tabs, and update here too! (This does not
|
||||
// catch same-page updates!)
|
||||
React.useEffect(() => {
|
||||
window.addEventListener("storage", reloadValue);
|
||||
return () => window.removeEventListener("storage", reloadValue);
|
||||
}, [reloadValue]);
|
||||
|
||||
return [storedValue, setValue];
|
||||
return [storedValue, setValue];
|
||||
}
|
||||
|
||||
export function loadImage(
|
||||
rawSrc,
|
||||
{ crossOrigin = null, preferArchive = false } = {},
|
||||
rawSrc,
|
||||
{ crossOrigin = null, preferArchive = false } = {},
|
||||
) {
|
||||
const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
|
||||
const image = new Image();
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
const src = safeImageUrl(rawSrc, { crossOrigin, preferArchive });
|
||||
const image = new Image();
|
||||
let canceled = false;
|
||||
let resolved = false;
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
|
||||
};
|
||||
if (crossOrigin) {
|
||||
image.crossOrigin = crossOrigin;
|
||||
}
|
||||
image.src = src;
|
||||
});
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
image.onload = () => {
|
||||
if (canceled) return;
|
||||
resolved = true;
|
||||
resolve(image);
|
||||
};
|
||||
image.onerror = () => {
|
||||
if (canceled) return;
|
||||
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
|
||||
};
|
||||
if (crossOrigin) {
|
||||
image.crossOrigin = crossOrigin;
|
||||
}
|
||||
image.src = src;
|
||||
});
|
||||
|
||||
promise.cancel = () => {
|
||||
// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
|
||||
// resolved images. That's because our approach to cancelation
|
||||
// mutates the Image object we already returned, which could be
|
||||
// surprising if the caller is using the Image and expected the
|
||||
// `cancel` call to only cancel any in-flight network requests.
|
||||
// (e.g. we cancel a DTI movie when it unloads from the page, but
|
||||
// it might stick around in the movie cache, and we want those images
|
||||
// to still work!)
|
||||
if (resolved) return;
|
||||
image.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
promise.cancel = () => {
|
||||
// NOTE: To keep `cancel` a safe and unsurprising call, we don't cancel
|
||||
// resolved images. That's because our approach to cancelation
|
||||
// mutates the Image object we already returned, which could be
|
||||
// surprising if the caller is using the Image and expected the
|
||||
// `cancel` call to only cancel any in-flight network requests.
|
||||
// (e.g. we cancel a DTI movie when it unloads from the page, but
|
||||
// it might stick around in the movie cache, and we want those images
|
||||
// to still work!)
|
||||
if (resolved) return;
|
||||
image.src = "";
|
||||
canceled = true;
|
||||
};
|
||||
|
||||
return promise;
|
||||
return promise;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -401,16 +401,16 @@ export function loadImage(
|
|||
* because Vercel doesn't keep old JS chunks on the CDN. Recover by reloading!
|
||||
*/
|
||||
export function loadable(load, options) {
|
||||
return loadableLibrary(
|
||||
() =>
|
||||
load().catch((e) => {
|
||||
console.error("Error loading page, reloading:", e);
|
||||
window.location.reload();
|
||||
// Return a component that renders nothing, while we reload!
|
||||
return () => null;
|
||||
}),
|
||||
options,
|
||||
);
|
||||
return loadableLibrary(
|
||||
() =>
|
||||
load().catch((e) => {
|
||||
console.error("Error loading page, reloading:", e);
|
||||
window.location.reload();
|
||||
// Return a component that renders nothing, while we reload!
|
||||
return () => null;
|
||||
}),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -420,113 +420,113 @@ export function loadable(load, options) {
|
|||
* genuinely unexpected error worth logging.
|
||||
*/
|
||||
export function logAndCapture(e) {
|
||||
console.error(e);
|
||||
Sentry.captureException(e);
|
||||
console.error(e);
|
||||
Sentry.captureException(e);
|
||||
}
|
||||
|
||||
export function getGraphQLErrorMessage(error) {
|
||||
// If this is a GraphQL Bad Request error, show the message of the first
|
||||
// error the server returned. Otherwise, just use the normal error message!
|
||||
return (
|
||||
error?.networkError?.result?.errors?.[0]?.message || error?.message || null
|
||||
);
|
||||
// If this is a GraphQL Bad Request error, show the message of the first
|
||||
// error the server returned. Otherwise, just use the normal error message!
|
||||
return (
|
||||
error?.networkError?.result?.errors?.[0]?.message || error?.message || null
|
||||
);
|
||||
}
|
||||
|
||||
export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
|
||||
// Log the detailed error to the console, so we can have a good debug
|
||||
// experience without the parent worrying about it!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
// Log the detailed error to the console, so we can have a good debug
|
||||
// experience without the parent worrying about it!
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<Flex justify="center" marginTop="8">
|
||||
<Grid
|
||||
templateAreas='"icon title" "icon description" "icon details"'
|
||||
templateColumns="auto minmax(0, 1fr)"
|
||||
maxWidth="500px"
|
||||
marginX="8"
|
||||
columnGap="4"
|
||||
>
|
||||
<Box gridArea="icon" marginTop="2">
|
||||
<Box
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
overflow="hidden"
|
||||
width="100px"
|
||||
height="100px"
|
||||
>
|
||||
<img
|
||||
src={ErrorGrundoImg}
|
||||
srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
|
||||
alt="Distressed Grundo programmer"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gridArea="title" fontSize="lg" marginBottom="1">
|
||||
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
|
||||
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
|
||||
{variant === "not-found" && <>Oops, page not found 😖</>}
|
||||
</Box>
|
||||
<Box gridArea="description" marginBottom="2">
|
||||
{variant === "unexpected" && (
|
||||
<>
|
||||
There was an error displaying this page. I'll get info about it
|
||||
automatically, but you can tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "network" && (
|
||||
<>
|
||||
There was an error displaying this page. Check your internet
|
||||
connection and try again—and if you keep having trouble, please
|
||||
tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "not-found" && (
|
||||
<>
|
||||
We couldn't find this page. Maybe it's been deleted? Check the URL
|
||||
and try again—and if you keep having trouble, please tell me more
|
||||
at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{error && (
|
||||
<Box gridArea="details" fontSize="xs" opacity="0.8">
|
||||
<WarningIcon
|
||||
marginRight="1.5"
|
||||
marginTop="-2px"
|
||||
aria-label="Error message"
|
||||
/>
|
||||
"{getGraphQLErrorMessage(error)}"
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex justify="center" marginTop="8">
|
||||
<Grid
|
||||
templateAreas='"icon title" "icon description" "icon details"'
|
||||
templateColumns="auto minmax(0, 1fr)"
|
||||
maxWidth="500px"
|
||||
marginX="8"
|
||||
columnGap="4"
|
||||
>
|
||||
<Box gridArea="icon" marginTop="2">
|
||||
<Box
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
overflow="hidden"
|
||||
width="100px"
|
||||
height="100px"
|
||||
>
|
||||
<img
|
||||
src={ErrorGrundoImg}
|
||||
srcSet={`${ErrorGrundoImg}, ${ErrorGrundoImg2x} 2x`}
|
||||
alt="Distressed Grundo programmer"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box gridArea="title" fontSize="lg" marginBottom="1">
|
||||
{variant === "unexpected" && <>Ah dang, I broke it 😖</>}
|
||||
{variant === "network" && <>Oops, it didn't work, sorry 😖</>}
|
||||
{variant === "not-found" && <>Oops, page not found 😖</>}
|
||||
</Box>
|
||||
<Box gridArea="description" marginBottom="2">
|
||||
{variant === "unexpected" && (
|
||||
<>
|
||||
There was an error displaying this page. I'll get info about it
|
||||
automatically, but you can tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "network" && (
|
||||
<>
|
||||
There was an error displaying this page. Check your internet
|
||||
connection and try again—and if you keep having trouble, please
|
||||
tell me more at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
{variant === "not-found" && (
|
||||
<>
|
||||
We couldn't find this page. Maybe it's been deleted? Check the URL
|
||||
and try again—and if you keep having trouble, please tell me more
|
||||
at{" "}
|
||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||
matchu@openneo.net
|
||||
</Link>
|
||||
!
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
{error && (
|
||||
<Box gridArea="details" fontSize="xs" opacity="0.8">
|
||||
<WarningIcon
|
||||
marginRight="1.5"
|
||||
marginTop="-2px"
|
||||
aria-label="Error message"
|
||||
/>
|
||||
"{getGraphQLErrorMessage(error)}"
|
||||
</Box>
|
||||
)}
|
||||
</Grid>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestErrorSender() {
|
||||
React.useEffect(() => {
|
||||
if (window.location.href.includes("send-test-error-for-sentry")) {
|
||||
throw new Error("Test error for Sentry");
|
||||
}
|
||||
});
|
||||
React.useEffect(() => {
|
||||
if (window.location.href.includes("send-test-error-for-sentry")) {
|
||||
throw new Error("Test error for Sentry");
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
|
106
package.json
106
package.json
|
@ -1,53 +1,57 @@
|
|||
{
|
||||
"name": "impress",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@chakra-ui/icons": "^1.0.4",
|
||||
"@chakra-ui/react": "^1.6.0",
|
||||
"@emotion/react": "^11.1.4",
|
||||
"@emotion/styled": "^11.0.0",
|
||||
"@hotwired/turbo-rails": "^8.0.4",
|
||||
"@loadable/component": "^5.12.0",
|
||||
"@sentry/react": "^5.30.0",
|
||||
"@sentry/tracing": "^5.30.0",
|
||||
"@tanstack/react-query": "^5.4.3",
|
||||
"apollo-link-persisted-queries": "^0.2.2",
|
||||
"easeljs": "^1.0.2",
|
||||
"esbuild": "^0.19.0",
|
||||
"framer-motion": "^4.1.11",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"immer": "^9.0.6",
|
||||
"lru-cache": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"tweenjs": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"express": "^4.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"oauth2-mock-server": "^7.1.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --asset-names='[name]-[hash].digested' --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text",
|
||||
"build:dev": "yarn build --public-path=/dev-assets",
|
||||
"dev": "yarn build:dev --watch",
|
||||
"lint": "eslint app/javascript",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"packageManager": "yarn@4.4.1"
|
||||
"name": "impress",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@apollo/client": "^3.6.9",
|
||||
"@chakra-ui/icons": "^1.0.4",
|
||||
"@chakra-ui/react": "^1.6.0",
|
||||
"@emotion/react": "^11.1.4",
|
||||
"@emotion/styled": "^11.0.0",
|
||||
"@hotwired/turbo-rails": "^8.0.4",
|
||||
"@loadable/component": "^5.12.0",
|
||||
"@sentry/react": "^5.30.0",
|
||||
"@sentry/tracing": "^5.30.0",
|
||||
"@tanstack/react-query": "^5.4.3",
|
||||
"apollo-link-persisted-queries": "^0.2.2",
|
||||
"easeljs": "^1.0.2",
|
||||
"esbuild": "^0.19.0",
|
||||
"framer-motion": "^4.1.11",
|
||||
"graphql": "^15.5.0",
|
||||
"graphql-tag": "^2.12.6",
|
||||
"immer": "^9.0.6",
|
||||
"lru-cache": "^6.0.0",
|
||||
"react": "^18.2.0",
|
||||
"react-autosuggest": "^10.0.2",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.2.0",
|
||||
"react-router-dom": "^6.15.0",
|
||||
"react-transition-group": "^4.3.0",
|
||||
"tweenjs": "^1.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "^7.8.0",
|
||||
"@typescript-eslint/parser": "^7.8.0",
|
||||
"eslint": "^8.52.0",
|
||||
"eslint-plugin-jsx-a11y": "^6.8.0",
|
||||
"eslint-plugin-react": "^7.33.2",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"express": "^4.18.3",
|
||||
"husky": "^8.0.3",
|
||||
"node-fetch": "^3.3.2",
|
||||
"oauth2-mock-server": "^7.1.1",
|
||||
"prettier": "^3.0.3",
|
||||
"typescript": "^5.2.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "esbuild app/javascript/*.* --bundle --sourcemap --outdir=app/assets/builds --public-path=/assets --asset-names='[name]-[hash].digested' --loader:.js=jsx --loader:.png=file --loader:.svg=file --loader:.min.js=text",
|
||||
"build:dev": "yarn build --public-path=/dev-assets",
|
||||
"dev": "yarn build:dev --watch",
|
||||
"format": "prettier -w app/javascript app/assets/javascripts",
|
||||
"lint": "eslint app/javascript",
|
||||
"prepare": "husky install"
|
||||
},
|
||||
"prettier": {
|
||||
"useTabs": true
|
||||
},
|
||||
"packageManager": "yarn@4.4.1"
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue