Compare commits

..

No commits in common. "main" and "simpler-item-previews" have entirely different histories.

334 changed files with 15993 additions and 18072 deletions

2
.gitignore vendored
View file

@ -4,8 +4,6 @@ log/*.log
tmp/**/*
.env
.env.*
/spec/examples.txt
/.yardoc
/app/assets/builds/*
!/app/assets/builds/.keep

View file

@ -1,5 +1,4 @@
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
# Run the linter, and all our tests.
yarn lint --max-warnings=0 --fix && bin/rake test spec
yarn lint --max-warnings=0 --fix

1
.rspec
View file

@ -1 +0,0 @@
--require spec_helper

View file

@ -1 +1 @@
3.3.5
3.3.4

32
Gemfile
View file

@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.3.5'
ruby '3.3.4'
gem 'rails', '~> 7.2', '>= 7.2.1'
gem 'rails', '~> 7.1', '>= 7.1.3.4'
# The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.0'
gem 'falcon', '~> 0.43.0'
# Our database is MySQL, in both development and production.
gem 'mysql2', '~> 0.5.5'
@ -19,7 +19,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.3'
gem 'jsbundling-rails', '~> 1.1'
gem 'turbo-rails', '~> 2.0'
# For authentication.
@ -61,15 +61,15 @@ gem "httparty", "~> 0.22.0"
gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests.
gem "async", "~> 2.17", require: false
gem "async-http", "~> 0.75.0", require: false
gem "async", "~> 2.6", require: false
gem "async-http", "~> 0.61.0", require: false
gem "thread-local", "~> 1.1", require: false
# For debugging.
group :development do
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2'
end
gem 'web-console', '~> 4.2', group: :development
# TODO: Review our use of content_tag_for etc and uninstall this!
gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false
@ -87,13 +87,5 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1"
# For workspace autocomplete.
group :development do
gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
gem "webmock", "~> 3.24", group: :test
end
gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph-rails", "~> 1.1", group: :development

View file

@ -81,30 +81,29 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.2)
async (2.17.0)
async (2.16.1)
console (~> 1.26)
fiber-annotation
io-event (~> 1.6, >= 1.6.5)
async-container (0.18.3)
async (~> 2.10)
async-http (0.75.0)
async (>= 2.10.2)
async-pool (~> 0.7)
io-endpoint (~> 0.11)
io-stream (~> 0.4)
protocol-http (~> 0.30)
protocol-http1 (~> 0.20)
protocol-http2 (~> 0.18)
traces (>= 0.10)
async-container (0.16.13)
async
async-io
async-http (0.61.0)
async (>= 1.25)
async-io (>= 1.28)
async-pool (>= 0.2)
protocol-http (~> 0.25.0)
protocol-http1 (~> 0.16.0)
protocol-http2 (~> 0.15.0)
traces (>= 0.10.0)
async-http-cache (0.4.4)
async-http (~> 0.56)
async-io (1.43.2)
async
async-pool (0.8.1)
async (>= 1.25)
metrics
traces
async-service (0.12.0)
async
async-container (~> 0.16)
attr_required (1.0.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
@ -119,6 +118,7 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
build-environment (1.13.0)
builder (3.3.0)
childprocess (5.1.0)
logger (~> 1.5)
@ -128,15 +128,9 @@ GEM
fiber-annotation
fiber-local (~> 1.1)
json
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
csv (3.3.0)
date (3.3.4)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@ -156,22 +150,21 @@ GEM
activemodel
erubi (1.13.0)
execjs (2.9.1)
falcon (0.48.2)
falcon (0.43.0)
async
async-container (~> 0.18)
async-http (~> 0.75)
async-http-cache (~> 0.4)
async-service (~> 0.10)
async-container (~> 0.16.0)
async-http (~> 0.57)
async-http-cache (~> 0.4.0)
async-io (~> 1.22)
build-environment (~> 1.13)
bundler
localhost (~> 1.1)
openssl (~> 3.0)
process-metrics (~> 0.2)
protocol-http (~> 0.31)
protocol-rack (~> 0.7)
samovar (~> 2.3)
faraday (2.12.0)
process-metrics (~> 0.2.0)
protocol-rack (~> 0.1)
samovar (~> 2.1)
faraday (2.11.0)
faraday-net_http (>= 2.0, < 3.4)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
@ -188,20 +181,17 @@ GEM
temple (>= 0.8.2)
thor
tilt
hashdiff (1.1.2)
hashie (5.0.0)
http_accept_language (2.1.1)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.6)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
io-console (0.7.2)
io-endpoint (0.13.1)
io-event (1.6.5)
io-stream (0.4.1)
irb (1.14.1)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jaro_winkler (1.6.0)
@ -226,7 +216,7 @@ GEM
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
localhost (1.3.1)
logger (1.6.1)
logger (1.6.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
@ -237,7 +227,7 @@ GEM
net-smtp
mapping (1.1.1)
marcel (1.0.4)
memory_profiler (1.1.0)
memory_profiler (1.0.2)
metrics (0.10.2)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
@ -248,7 +238,7 @@ GEM
mysql2 (0.5.6)
net-http (0.4.1)
uri
net-imap (0.4.16)
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
@ -287,22 +277,21 @@ GEM
openssl (3.2.0)
orm_adapter (0.5.0)
parallel (1.26.3)
parser (3.3.5.0)
parser (3.3.4.2)
ast (~> 2.4.1)
racc
process-metrics (0.3.0)
process-metrics (0.2.1)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.5.1)
protocol-http (0.37.0)
protocol-http1 (0.27.0)
protocol-hpack (1.5.0)
protocol-http (0.25.0)
protocol-http1 (0.16.1)
protocol-http (~> 0.22)
protocol-http2 (0.19.1)
protocol-http2 (0.15.1)
protocol-hpack (~> 1.4)
protocol-http (~> 0.18)
protocol-rack (0.10.0)
protocol-http (~> 0.37)
protocol-rack (0.6.0)
protocol-http (~> 0.23)
rack (>= 1.0)
psych (5.1.2)
stringio
@ -374,43 +363,30 @@ GEM
execjs
railties (>= 3.2)
tilt
record_tag_helper (1.0.1)
actionview (>= 5)
regexp_parser (2.9.2)
reline (0.5.10)
reline (0.5.9)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.7)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.0.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
rubocop (1.66.1)
rexml (3.3.6)
strscan
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.4, < 3.0)
rubocop-ast (>= 1.32.2, < 2.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.3)
rubocop-ast (1.32.1)
parser (>= 3.3.1.0)
ruby-progressbar (1.13.0)
samovar (2.3.0)
@ -467,6 +443,7 @@ GEM
sprockets (>= 3.0.0)
stackprof (0.2.26)
stringio (3.1.1)
strscan (3.1.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
@ -476,17 +453,18 @@ GEM
temple (0.10.3)
terser (1.2.3)
execjs (>= 0.3.0, < 3)
thor (1.3.2)
thor (1.3.1)
thread-local (1.1.0)
tilt (2.4.0)
timeout (0.4.1)
traces (0.13.1)
turbo-rails (2.0.10)
turbo-rails (2.0.6)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.6.0)
unicode-display_width (2.5.0)
uri (0.13.1)
useragent (0.16.10)
validate_url (1.0.15)
@ -503,17 +481,13 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
webrick (1.8.2)
webrick (1.8.1)
websocket-driver (0.7.6)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.1)
yard (0.9.37)
zeitwerk (2.6.18)
yard (0.9.36)
zeitwerk (2.6.17)
PLATFORMS
ruby
@ -521,18 +495,17 @@ PLATFORMS
DEPENDENCIES
RocketAMF!
addressable (~> 2.8)
async (~> 2.17)
async-http (~> 0.75.0)
async (~> 2.6)
async-http (~> 0.61.0)
bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0)
falcon (~> 0.43.0)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.22.0)
jsbundling-rails (~> 1.3)
jsbundling-rails (~> 1.1)
letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0)
mysql2 (~> 0.5.5)
@ -543,11 +516,11 @@ DEPENDENCIES
parallel (~> 1.23)
rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1)
rails (~> 7.2, >= 7.2.1)
rails (~> 7.1, >= 7.1.3.4)
rails-i18n (~> 7.0, >= 7.0.7)
rdiscount (~> 2.2, >= 2.2.7.1)
react-rails (~> 2.7, >= 2.7.1)
rspec-rails (~> 7.0)
record_tag_helper (~> 1.0, >= 1.0.1)
sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0)
sentry-rails (~> 5.12)
@ -561,11 +534,10 @@ DEPENDENCIES
thread-local (~> 1.1)
turbo-rails (~> 2.0)
web-console (~> 4.2)
webmock (~> 3.24)
will_paginate (~> 4.0)
RUBY VERSION
ruby 3.3.5p100
ruby 3.3.4p94
BUNDLED WITH
2.5.18

View file

@ -1,6 +1,5 @@
//= link_tree ../images
//= link_tree ../javascripts .js
//= link_tree ../../../vendor/javascript .js
//= link_tree ../stylesheets .css
//= link_directory ../fonts .otf
//= link_tree ../builds

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

BIN
app/assets/images/grid.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

@ -0,0 +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;
}
$.ajaxSetup({
beforeSend: CSRFProtection,
});
})();

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,8 @@
(function () {
function setChecked() {
var el = $(this);
el.closest("li").toggleClass("checked", el.is(":checked"));
}
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
})();

View file

@ -1,5 +1,7 @@
document.addEventListener("change", ({ target }) => {
if (target.matches('select[name="closet_list[visibility]"]')) {
target.closest("form").setAttribute("data-list-visibility", target.value);
target
.closest("form")
.setAttribute("data-list-visibility", target.value);
}
});

View file

@ -1,6 +1,6 @@
(function () {
$("span.choose-outfit select").change(function (e) {
var select = $(this);
select.closest("li").find("input[type=text]").val(select.val());
});
(function() {
$('span.choose-outfit select').change(function(e) {
var select = $(this);
select.closest('li').find('input[type=text]').val(select.val());
});
})();

View file

@ -1,115 +1,76 @@
// 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);
}
});
// If the preview frame fails to load, try a full pageload.
document.addEventListener("turbo:frame-missing", (e) => {
if (!e.target.matches("#item-preview")) return;
e.detail.visit(e.detail.response.url);
e.preventDefault();
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);
}
});
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");
}
}
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
class MeasuredContainer extends HTMLElement {
static observedAttributes = ["style"];
connectedCallback() {
setTimeout(() => this.#measure(), 0);
}
attributeChangedCallback() {
// When `--natural-width` gets morphed away by Turbo, measure it again!
if (this.style.getPropertyValue("--natural-width") === "") {
this.#measure();
}
}
#measure() {
// Find our `<measured-content>` child, and set our natural width as
// `var(--natural-width)` in the context of our CSS styles.
const content = this.querySelector("measured-content");
if (content == null) {
throw new Error(`<measured-container> must contain a <measured-content>`);
}
this.style.setProperty("--natural-width", content.offsetWidth + "px");
}
#activate() {
this.removeAttribute("inert");
this.removeAttribute("aria-hidden");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

@ -1,33 +0,0 @@
class MagicMagnifier extends HTMLElement {
connectedCallback() {
setTimeout(() => this.#attachLens(), 0);
this.addEventListener("mousemove", this.#onMouseMove);
}
#attachLens() {
const lens = document.createElement("magic-magnifier-lens");
lens.inert = true;
lens.useContent(this.children);
this.appendChild(lens);
}
#onMouseMove(e) {
const lens = this.querySelector("magic-magnifier-lens");
const rect = this.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
this.style.setProperty("--magic-magnifier-x", x + "px");
this.style.setProperty("--magic-magnifier-y", y + "px");
}
}
class MagicMagnifierLens extends HTMLElement {
useContent(contentNodes) {
for (const contentNode of contentNodes) {
this.appendChild(contentNode.cloneNode(true));
}
}
}
customElements.define("magic-magnifier", MagicMagnifier);
customElements.define("magic-magnifier-lens", MagicMagnifierLens);

View file

@ -104,9 +104,13 @@ 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);
throw new Error(
`<outfit-layer> must contain an <img> or <iframe> tag`,
);
}
}
@ -131,7 +135,8 @@ class OutfitLayer extends HTMLElement {
}
} else {
throw new Error(
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
`<outfit-layer> got unexpected message: ` +
JSON.stringify(data),
);
}
}

View file

@ -1,253 +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;
for (const [key, value] of new URLSearchParams(query_string).entries()) {
PetQuery[key] = value;
}
$.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) {
var image_url = petImage("cpn/" + PetQuery.name, 1);
if (PetQuery.name.startsWith("@")) {
image_url = petImage("cp/" + PetQuery.name.substr(1), 1);
}
$("#pet-query-notice-template")
.tmpl({
pet_name: PetQuery.name,
pet_image_url: image_url,
})
.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 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();
}
}
});
}
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();
}
}
loadFeature();
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();
}
});
}
Preview.Job = function (key, base) {
var job = this,
quality = 2;
job.loading = false;
loadFeature();
function getImageSrc() {
if (base === "cp" || base === "cpn") {
return petImage(base + "/" + key, quality);
} else if (base === "url") {
return key;
} else {
throw new Error("unrecognized image base " + base);
}
}
Preview.Job = function (key, base) {
var job = this,
quality = 2;
job.loading = false;
function load() {
job.loading = true;
img_el.attr("src", getImageSrc());
}
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);
}
}
this.increaseQualityIfPossible = function () {
if (quality == 2) {
quality = 4;
load();
}
};
function load() {
job.loading = true;
img_el.attr("src", getImageSrc());
}
this.setAsCurrent = function () {
Preview.Job.current = job;
load();
};
this.increaseQualityIfPossible = function () {
if (quality == 2) {
quality = 4;
load();
}
};
this.notFound = function () {
Preview.notFound("pet-not-found");
};
};
this.setAsCurrent = function () {
Preview.Job.current = job;
load();
};
Preview.Job.Name = function (name) {
this.name = name;
if (name.startsWith("@")) {
// This is an image hash "pet name".
Preview.Job.apply(this, [name.substr(1), "cp"]);
} else {
// This is a normal pet name.
Preview.Job.apply(this, [name, "cpn"]);
}
this.notFound = function () {
Preview.notFound("pet-not-found");
};
};
this.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();
};
};
Preview.Job.Name = function (name) {
this.name = name;
Preview.Job.apply(this, [name, "cpn"]);
Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]);
this.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();
};
};
this.visit = function () {
window.location =
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]);
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 =
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
this.visit = function () {
window.location = "/donate";
};
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.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.visit = function () {
window.location = "/donate";
};
$(function () {
var previewWithNameTimeout;
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();
};
};
var name_el = $(".main-pet-name");
name_el.val(PetQuery.name);
Preview.updateWithName(name_el);
$(function () {
var previewWithNameTimeout;
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);
});
var name_el = $(".main-pet-name");
name_el.val(PetQuery.name);
Preview.updateWithName(name_el);
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();
}
});
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);
});
$(".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,
});
}
},
});
});
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();
}
});
$(".load-pet-to-wardrobe").submit(function (e) {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
e.preventDefault();
Preview.Job.current.visit();
}
});
});
$(".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,
});
}
},
});
});
$("#latest-contribution-created-at").timeago();
$(".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();
})();

View file

@ -1,46 +0,0 @@
class SupportOutfitViewer extends HTMLElement {
#internals = this.attachInternals();
connectedCallback() {
this.addEventListener("mouseenter", this.#onMouseEnter, { capture: true });
this.addEventListener("mouseleave", this.#onMouseLeave, { capture: true });
this.addEventListener("click", this.#onClick);
this.#internals.states.add("ready");
}
// When a row is hovered, highlight its corresponding outfit viewer layer.
#onMouseEnter(e) {
if (!e.target.matches("tr")) return;
const id = e.target.querySelector("[data-field=id]").innerText;
const layers = this.querySelectorAll(
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
);
for (const layer of layers) {
layer.setAttribute("highlighted", "");
}
}
// When a row is unhovered, unhighlight its corresponding outfit viewer layer.
#onMouseLeave(e) {
if (!e.target.matches("tr")) return;
const id = e.target.querySelector("[data-field=id]").innerText;
const layers = this.querySelectorAll(
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
);
for (const layer of layers) {
layer.removeAttribute("highlighted");
}
}
// When clicking a row, redirect the click to the first link.
#onClick(e) {
const row = e.target.closest("tr");
if (row == null) return;
row.querySelector("[data-field=links] a").click();
}
}
customElements.define("support-outfit-viewer", SupportOutfitViewer);

View file

@ -1,110 +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";
}
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 current_request = { abort: function () {} };
function sendRequest(options) {
current_request = $.ajax(options);
}
function cancelRequest() {
if (DEBUG) console.log("Canceling request", current_request);
current_request.abort();
}
/* Pet */
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();
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");
}
function petComplete() {
UI.form.removeClass("loading-pet");
}
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);
}
/* Items */
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);
}
UI.pet_items.empty();
UI.item_template.tmpl(items).appendTo(UI.pet_items);
UI.form.removeClass("loading-items").addClass("loaded");
}
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);
});
})();
/* 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");
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";
}
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;
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el);
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;
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,
});
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el);
recently_sent_count++;
setTimeout(function () {
recently_sent_count--;
loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
this.load = function () {
el.removeClass("waiting").addClass("loading");
var response_el = el.find("span.response");
pets.shift();
loading = true;
$.ajax({
beforeSend: (xhr) => {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
},
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.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();
}
};
recently_sent_count++;
setTimeout(function () {
recently_sent_count--;
loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load();
}
}
})();
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();
}
};
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] : "";
});
function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load();
}
}
})();
add_el.click(function () {
bulk_load_queue.add(names_el.val());
names_el.val("");
});
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("");
});
clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove();
});
clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove();
});
})();

View file

@ -0,0 +1,30 @@
@import "partials/campaign-progress"
body.items-index, body.items-show, body.items-needed, body.item_trades
+campaign-progress
text-align: center
.item-search-form
display: flex
gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1
margin-bottom: 1em
img
height: 80px
margin-bottom: -0.5em
width: 80px
a
text-decoration: none
span
text-decoration: underline
&:hover span
text-decoration: none

View file

@ -1,6 +1,10 @@
@import "partials/icon"
@import "partials/clean/constants"
@import "partials/clean/mixins"
@import fonts
@import url("https://fonts.googleapis.com/css?family=Droid+Sans:400,700")
@import url("https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic")
@import url("https://fonts.googleapis.com/css?family=Calligraffitti")
/* Reset
@ -32,6 +36,9 @@ body
a[href]
color: $link-color
p
font-family: $text-font
input, button, select
font:
family: inherit
@ -74,7 +81,7 @@ $container_width: 800px
input, button, select, label
cursor: pointer
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
border-radius: 3px
background: #fff
border: 1px solid $input-border-color
@ -83,15 +90,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
&:focus, &:active
color: inherit
select:has(option[value='']:checked)
color: #666
option[value='']
color: #666
option:not([value=''])
color: $text-color
textarea
font: inherit
@ -252,3 +250,23 @@ dd
margin: 0 .5em
.current
font-weight: bold
/* Fonts
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
@font-face
font-family: Delicious
src: local("Delicious"), font-url("Delicious-Roman.otf")
@font-face
font-family: Delicious
font-weight: bold
src: local("Delicious"), font-url("Delicious-Bold.otf")
@font-face
font-family: Delicious
font-style: italic
src: local("Delicious"), font-url("Delicious-Italic.otf")

View file

@ -3,20 +3,10 @@ body.use-responsive-design
max-width: 100%
padding-inline: 1rem
box-sizing: border-box
padding-top: 0
#main-nav
display: flex
flex-wrap: wrap
#home-link, #userbar
position: static
#home-link
padding-inline: .5rem
margin-inline: -.5rem
margin-right: auto
margin-left: 1rem
padding-inline: 0
#userbar
margin-left: auto
text-align: right
margin-right: 1rem

View file

@ -0,0 +1,18 @@
body.alt_styles-index
.alt-styles-header
margin-top: 1em
margin-bottom: .5em
.alt-styles-list
list-style: none
display: flex
flex-wrap: wrap
gap: 1.5em
.alt-style
text-align: center
width: 80px
.alt-style-thumbnail
width: 80px
height: 80px

View file

@ -1,4 +0,0 @@
.alt-style-preview
width: 300px
height: 300px
margin: 0 auto

View file

@ -1,3 +0,0 @@
.rainbow-pool-list
.name span
display: inline-block

View file

@ -8,10 +8,16 @@
@import partials/jquery.jgrowl
@import alt_styles/index
@import closet_hangers/index
@import closet_hangers/petpage
@import closet_lists/form
@import neopets_page_import_tasks/new
@import contributions/index
@import items
@import items/index
@import items/show
@import item_trades/index
@import outfits/index
@import outfits/new
@import pets/bulk

View file

@ -1,23 +0,0 @@
#title:has(+ .breadcrumbs)
margin-bottom: .125em
.breadcrumbs
list-style-type: none
display: flex
flex-direction: row
margin-block: .5em
font-size: .85em
li
display: flex
li:not(:first-child)
&::before
margin-inline: .35em
content: ""
&[data-relation-to-prev=sibling]::before
content: "+"
&[data-relation-to-prev=menu]::before
content: "-"

View file

@ -1,47 +0,0 @@
magic-magnifier
position: relative
// Only show the lens when we are hovering, and the magnifier's X and Y
// coordinates are set. (This ensures the component is running, and has
// received a mousemove event, instead of defaulting to (0, 0).)
magic-magnifier-lens
display: none
&:hover
@container style(--magic-magnifier-x) and style(--magic-magnifier-y)
magic-magnifier-lens
display: block
magic-magnifier-lens
width: var(--magic-magnifier-lens-width, 100px)
height: var(--magic-magnifier-lens-height, 100px)
overflow: hidden
border-radius: 100%
background: white
border: 2px solid black
box-shadow: 3px 3px 3px rgba(0, 0, 0, .5)
position: absolute
left: var(--magic-magnifier-x, 0px)
top: var(--magic-magnifier-y, 0px)
> *
// Translations are applied in the opposite of the order they're specified.
// So, here's what we're doing:
//
// 1. Translate the content left by --magic-magnifier-x and up by
// --magic-magnifier-y, to align the target location with the lens's
// top-right corner.
// 2. Zoom in by --magic-magnifier-scale.
// 3. Translate the content right by half of --magic-magnifier-lens-width,
// and down by half of --magic-magnifier-lens-height, to align the
// target location with the lens's center.
//
// Note that it *is* possible to specify transforms relative to the center,
// rather than the top-left cornerthis is in fact the default!but that
// gets confusing fast with scale in play. I think this is easier to reason
// about with the top-left corner in terms of math, and center it after the
// fact.
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
transform-origin: left top

View file

@ -1,125 +0,0 @@
@import "../partials/clean/constants"
// When loading, fade in the loading spinner after a brief delay. We only apply
// the delay here, not on the base styles, because fading *out* on load should
// be instant.
//
// This is implemented as a mixin, so that the item page can leverage the same
// loading state when loading a new preview altogether. Once CSS container
// style queries gain wider support, maybe use that instead.
=outfit-viewer-loading
cursor: wait
.loading-indicator
opacity: 1
transition-delay: 2s
// If the outfit *starts* in loading state, still delay the fade-in.
@starting-style
opacity: 0
outfit-viewer
display: block
position: relative
overflow: hidden
// These are default widths, expected to often be overridden.
width: 300px
height: 300px
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
opacity: 0
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
&:has(outfit-layer:state(loading))
+outfit-viewer-loading
// If a layer has the `[highlighted]` attribute, it's brought to the front,
// and other layers are grayed out and blurred. We use this in the support
// outfit viewer, when you hover over a layer.
&:has(outfit-layer[highlighted])
outfit-layer[highlighted]
z-index: 999
// Filter everything behind the bottom-most highlighted layer, using a
// backdrop filter. This gives us the best visual consistency by applying
// effects to the entire backdrop, instead of each layer and then
// re-compositing them.
backdrop-filter: grayscale(1) brightness(2) blur(1px)
& ~ outfit-layer[highlighted]
backdrop-filter: none

View file

@ -1,74 +0,0 @@
@import "../partials/clean/constants"
.rainbow-pool-filters
margin-block: .5em
fieldset
display: flex
flex-direction: row
align-items: center
justify-content: center
gap: .5em
legend
display: contents
font-weight: bold
select
width: 16ch
.rainbow-pool-list
list-style-type: none
display: flex
flex-wrap: wrap
justify-content: center
gap: .5em
--preview-base-width: 150px
> li
width: var(--preview-base-width)
max-width: calc(50% - .25em)
min-width: 150px
box-sizing: border-box
text-align: center
a
display: block
border-radius: 1em
padding: .5em
text-decoration: none
background: white
&:hover
outline: 1px solid $module-border-color
background: $module-bg-color
.preview
width: 100%
height: auto
aspect-ratio: 1 / 1
margin-bottom: -1em
.name
background: inherit
padding: .25em .5em
border-radius: .5em
margin: 0 auto
position: relative
z-index: 1
.info
font-size: .85em
p
margin-block: .25em
.rainbow-pool-pagination
margin-block: .5em
display: flex
justify-content: center
gap: 1em
.rainbow-pool-no-results
margin-block: 1em
text-align: center
font-style: italic

View file

@ -1,102 +0,0 @@
@import "../partials/clean/constants"
.support-form
display: flex
flex-direction: column
gap: 1em
align-items: flex-start
.fields
list-style-type: none
display: flex
flex-direction: column
gap: .75em
width: 100%
> li
display: flex
flex-direction: column
gap: .25em
max-width: 60ch
> label, > .field_with_errors label
display: block
font-weight: bold
.field_with_errors
> label
color: $error-color
input[type=text], input[type=url]
border-color: $error-border-color
color: $error-color
&[data-type=radio]
ul
list-style-type: none
&[data-type=radio-grid] // Set the `--num-columns` property to configure!
max-width: none
ul
list-style-type: none
display: grid
grid-template-columns: repeat(var(--num-columns, 1), 1fr)
gap: .25em
li
display: flex
align-items: stretch // Give the bubbles equal heights!
label
display: flex
align-items: center
gap: .5em
padding: .5em 1em
border: 1px solid $soft-border-color
border-radius: 1em
flex: 1 1 auto
input
margin: 0
&:has(:checked)
background: $module-bg-color
border-color: $module-border-color
input[type=text], input[type=url]
width: 100%
min-width: 10ch
box-sizing: border-box
.thumbnail-input
display: flex
align-items: center
gap: .25em
img
width: 40px
height: 40px
fieldset
display: flex
flex-direction: column
gap: .25em
legend
font-weight: bold
.field_with_errors
display: contents
.actions
display: flex
align-items: center
gap: 1em
.go-to-next
display: flex
align-items: center
gap: .25em
font-size: .85em
font-style: italic

View file

@ -0,0 +1,58 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/secondary_nav"
body.closet_hangers-petpage
+secondary-nav
#intro
clear: both
#petpage-closet-lists
+clearfix
border-radius: 10px
border: 1px solid $soft-border-color
margin-bottom: 1.5em
padding: .5em 1.5em
> div
margin: .25em 0
h4
display: inline-block
vertical-align: middle
&::after
content: ":"
ul
list-style: none
margin: 0
padding: 0
li
display: inline-block
font-size: 85%
margin: .25em .5em
padding: 1px
label
padding: .25em .75em .25em .25em
&.checked
background: $module-bg-color
border-radius: 3px
border: 1px solid $module-border-color
padding: 0
&.unlisted
font-style: italic
input[type=submit]
float: right
#petpage-output
display: block
height: 30em
margin: 0 auto
width: 50%

View file

@ -1,57 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/secondary_nav"
+secondary-nav
#intro
clear: both
#petpage-closet-lists
+clearfix
border-radius: 10px
border: 1px solid $soft-border-color
margin-bottom: 1.5em
padding: .5em 1.5em
> div
margin: .25em 0
h4
display: inline-block
vertical-align: middle
&::after
content: ":"
ul
list-style: none
margin: 0
padding: 0
li
display: inline-block
font-size: 85%
margin: .25em .5em
padding: 1px
label
padding: .25em .75em .25em .25em
&:has(:checked)
background: $module-bg-color
border-radius: 3px
border: 1px solid $module-border-color
padding: 0
&.unlisted
font-style: italic
input[type=submit]
float: right
#petpage-output
display: block
height: 30em
margin: 0 auto
width: 50%

View file

@ -1,17 +0,0 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
@font-face {
font-family: Delicious;
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
}
@font-face {
font-family: Delicious;
font-weight: bold;
src: local("Delicious"), url("<%= font_path "Delicious-Bold.otf" %>");
}
@font-face {
font-family: Delicious;
font-style: italic;
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
}

View file

@ -0,0 +1,14 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
@font-face
font-family: Delicious
src: local("Delicious"), font-url("Delicious-Roman.otf")
@font-face
font-family: Delicious
font-weight: bold
src: local("Delicious"), font-url("Delicious-Bold.otf")
@font-face
font-family: Delicious
font-style: italic
src: local("Delicious"), font-url("Delicious-Italic.otf")

View file

@ -0,0 +1,29 @@
@import "../partials/item_header"
body.item_trades-index
.item-header
+item-header
.item-subpage-title
text-align: left
margin-bottom: .5em
.trades-table
text-align: left
width: 100%
table-layout: fixed
th, td
&:nth-child(1), &:nth-child(2)
width: 15ch
overflow: hidden
text-overflow: ellipsis
.trade-list-names
list-style: none
li
display: inline
&:not(:last-child)::after
content: ", "

View file

@ -1,28 +0,0 @@
@import "../partials/item_header"
.item-header
+item-header
.item-subpage-title
text-align: left
margin-bottom: .5em
.trades-table
text-align: left
width: 100%
table-layout: fixed
th, td
&:nth-child(1), &:nth-child(2)
width: 15ch
overflow: hidden
text-overflow: ellipsis
.trade-list-names
list-style: none
li
display: inline
&:not(:last-child)::after
content: ", "

View file

@ -0,0 +1,25 @@
=main_unit
float: left
width: 49%
h2
font-size: 125%
body.items-index
form
margin-bottom: 2em
#search-info
+main_unit
padding-right: 1%
dl
text-align: left
dd
margin-bottom: 1em
#species-search-links
+main_unit
padding-left: 1%
img
height: 80px
width: 80px

View file

@ -0,0 +1,350 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/item_header"
body.items-show
.item-header
+item-header
#item-contributors
+subtle-banner
clear: both
margin:
bottom: 0
top: 2em
header
display: inline
font-weight: bold
margin-right: .25em
footer
display: inline
ul
display: inline
list-style: none
li
display: inline
&::after
content: ", "
&:last-child::after
content: "."
.nc-icon
height: 16px
width: 16px
outfit-viewer
display: block
position: relative
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
overflow: hidden
margin: 0 auto
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
opacity: 0
transition: opacity .5s
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
.error-indicator
font-size: 85%
color: $error-color
margin-top: .25em
margin-bottom: .5em
display: none
// When loading, fade in the loading spinner after a brief delay. (We only
// apply the delay here, because fading *out* on load should be instant.)
// We are loading when the <turbo-frame> is busy, or when at least one layer
// is loading.
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
cursor: wait
.loading-indicator
opacity: 1
transition-delay: 2s
#item-preview:has(outfit-layer:state(error))
outfit-viewer
border: 2px solid red
.error-indicator
display: block
species-color-picker
.error-icon
cursor: help
margin-right: .25em
form[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color
// If JS is enabled, but auto-loading isn't ready yet (script loading or
// failed?), hide the submit button for .75sec, to give it time to load.
@media (scripting: enabled)
input[type=submit]
position: absolute
margin-left: .5em
opacity: 0
animation: fade-in .25s forwards
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
input[type=submit]
display: none
species-face-picker
display: block
position: relative
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
padding: 10px // leave enough room for the zoomed-in selected face
margin-top: -10px
overflow: auto
species-face-picker-options
display: flex
justify-content: center
flex-wrap: wrap
img
width: 50px
height: 50px
transition: all 0.2s
// Calm down the default color, just a smidge! There's a lot of color
// on this page already, y'know?
opacity: .9
filter: saturate(90%)
label
display: flex
overflow: hidden
transition: all 0.2s
position: relative
line-height: 1
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
// Chakra UI's styling system to generate them! (The colors are from their
// color palette, too.)
&:has(input:checked)
border-radius: 6px
z-index: 1
background: #9AE6B4
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
transform: scale(1.1)
&:has(input:focus)
background: #BEE3F8
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
transform: scale(1.2)
input[type=radio]
position: absolute
left: -10000px
top: auto
width: 1px
height: 1px
overflow: hidden
&:checked + img
opacity: 1
filter: saturate(110%)
&:disabled + img
opacity: .6
filter: saturate(0%)
label:has(input[type=radio]:disabled)
cursor: not-allowed
noscript
position: absolute
inset: 0
background: rgba(white, .75)
z-index: 1
cursor: auto
display: flex
align-items: center
justify-content: center
text-align: center
&:has(species-face-picker-options[inert])
cursor: wait
.item-preview-meta-info
display: grid
grid-template-columns: 1fr auto
gap: .5em
align-items: center
.item-zones-info
h3
display: inline
font: inherit
font-weight: bold
&:after
content: ": "
ul
list-style-type: none
display: inline
li
display: inline
&:not(:last-of-type):after
content: ", "
.no-zones
font-style: italic
opacity: .85
.zone-species-info
font-style: italic
text-decoration: underline dotted
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
.item-html5-info
display: flex
align-items: center
border: 1px solid
border-radius: .375em
padding: 4px 8px
min-height: 30px
box-sizing: border-box
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
&[data-status=converted]
background: $module-bg-color
color: $text-color
svg:nth-of-type(2)
margin-right: -4px // spacing hacks!
&[data-status=unconverted]
background: $warning-bg-color
color: #975A16
gap: .25em // spacing hacks!
svg:first-of-type
width: 12px
height: 12px
svg:nth-of-type(2)
width: 20px
height: 20px
#item-preview
display: flex
flex-direction: column
gap: .75em
@media (min-width: 600px)
display: grid
grid-template-areas: "viewer faces" "picker meta"
gap: .5em
outfit-viewer
grid-area: viewer
width: 350px
height: 350px
species-color-picker
grid-area: picker
species-face-picker
grid-area: faces
max-height: 350px
margin: -10px
.item-preview-meta-info
grid-area: meta
@keyframes fade-in
from
opacity: 0
to
opacity: 1

View file

@ -1,23 +0,0 @@
=main_unit
float: left
width: 49%
h2
font-size: 125%
form
margin-bottom: 2em
#search-info
+main_unit
padding-right: 1%
dl
text-align: left
dd
margin-bottom: 1em
#species-search-links
+main_unit
padding-left: 1%
img
height: 80px
width: 80px

View file

@ -1,309 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/item_header"
@import "../application/outfit-viewer"
#container
width: 900px // A bit more generous to the preview area!
.item-header
+item-header
#item-contributors
+subtle-banner
clear: both
margin:
bottom: 0
top: 2em
header
display: inline
font-weight: bold
margin-right: .25em
footer
display: inline
ul
display: inline
list-style: none
li
display: inline
&::after
content: ", "
&:last-child::after
content: "."
.nc-icon
height: 16px
width: 16px
.preview-area
margin: 0 auto
position: relative
.customize-more
position: absolute
top: 1em
right: 1em
display: flex
align-items: center
text-decoration: none
background: #EDF2F7
padding-inline: .75em
border-radius: .375em
min-height: 2rem
min-width: 2rem
box-sizing: border-box
.customize-more-label
width: 0
overflow: hidden
transition: width .25s
white-space: nowrap
--natural-width: auto
measured-content
padding-right: .5em
&:hover, &:focus
// Expand the label to its natural width. If the JS ran to tell us
// what it is in px, we can use that for a smooth transition. If not,
// okay, we just pop out to `auto`, which CSS can't make smooth.
.customize-more-label
width: var(--natural-width)
outfit-viewer
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
.error-indicator
font-size: 85%
color: $error-color
margin-top: .25em
margin-bottom: .5em
display: none
// When loading, fade in the loading spinner after a brief delay. We are
// loading when the <turbo-frame> is busy, or when at least one layer
// is loading.
//
// We only apply the delay here, not on the base styles, because fading
// *out* on load should be instant.
#item-preview[busy] outfit-viewer
+outfit-viewer-loading
#item-preview:has(outfit-layer:state(error))
outfit-viewer
border: 2px solid red
.error-indicator
display: block
species-color-picker
.error-icon
cursor: help
margin-right: .25em
form[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color
// If JS is enabled, but auto-loading isn't ready yet (script loading or
// failed?), hide the submit button for .75sec, to give it time to load.
@media (scripting: enabled)
input[type=submit]
position: absolute
margin-left: .5em
opacity: 0
animation: fade-in .25s forwards
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
input[type=submit]
display: none
species-face-picker
display: block
position: relative
margin-top: -10px
species-face-picker-options
display: flex
justify-content: center
flex-wrap: wrap
isolation: isolate // avoid z-index conflicts between pets and noscript
overflow: auto
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
padding: 10px // leave enough room for the zoomed-in selected face
img
width: 54px
height: 54px
transition: all 0.2s
// Calm down the default color, just a smidge! There's a lot of color
// on this page already, y'know?
opacity: .9
filter: saturate(90%)
label
display: flex
overflow: hidden
transition: all 0.2s
position: relative
line-height: 1
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
// Chakra UI's styling system to generate them! (The colors are from their
// color palette, too.)
&:has(input:checked)
border-radius: 6px
z-index: 1
background: #9AE6B4
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
transform: scale(1.1)
&:has(input:focus)
background: #BEE3F8
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
transform: scale(1.2)
input[type=radio]
position: absolute
left: -10000px
top: auto
width: 1px
height: 1px
overflow: hidden
&:checked + img
opacity: 1
filter: saturate(110%)
&:disabled + img
opacity: .6
filter: saturate(0%)
label:has(input[type=radio]:disabled)
cursor: not-allowed
noscript
position: absolute
inset: 0
padding: 1em
background: rgba(white, .8)
z-index: 1
cursor: auto
display: flex
align-items: center
justify-content: center
text-align: center
&:has(species-face-picker-options[inert])
cursor: wait
.item-preview-meta-info
display: grid
grid-template-columns: 1fr auto
gap: .5em
align-items: center
.item-zones-info
h3
display: inline
font: inherit
font-weight: bold
&:after
content: ": "
ul
list-style-type: none
display: inline
li
display: inline
&:not(:last-of-type):after
content: ", "
.no-zones
font-style: italic
opacity: .85
.zone-species-info
font-style: italic
text-decoration: underline dotted
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
.item-html5-info
display: flex
align-items: center
border: 1px solid
border-radius: .375em
padding: 4px 8px
min-height: 30px
box-sizing: border-box
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
&[data-status=converted]
background: $module-bg-color
color: $text-color
svg:nth-of-type(2)
margin-right: -4px // spacing hacks!
&[data-status=unconverted]
background: $warning-bg-color
color: #975A16
gap: .25em // spacing hacks!
svg:first-of-type
width: 12px
height: 12px
svg:nth-of-type(2)
width: 20px
height: 20px
#item-preview
display: flex
flex-direction: column
gap: .75em
@media (min-width: 700px)
display: grid
grid-template-areas: "viewer faces" "picker meta"
gap: .5em
.preview-area
grid-area: viewer
outfit-viewer
width: 380px
height: 380px
species-color-picker
grid-area: picker
species-face-picker
grid-area: faces
species-face-picker-options
max-height: 380px
.item-preview-meta-info
grid-area: meta
@keyframes fade-in
from
opacity: 0
to
opacity: 1

View file

@ -1,28 +0,0 @@
@import "partials/campaign-progress"
body
+campaign-progress
text-align: center
.item-search-form
display: flex
gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1
margin-bottom: 1em
img
height: 80px
margin-bottom: -0.5em
width: 80px
a
text-decoration: none
span
text-decoration: underline
&:hover span
text-decoration: none

View file

@ -7,8 +7,9 @@ body.outfits-new
#pet-not-found
display: none
.announcement
border: 1px solid $module-border-color
.neopass-announcement
border: 1px solid #cd8400
color: #764a00
padding: .5em
display: grid
grid-template-areas: "thumbnail content"
@ -23,6 +24,9 @@ body.outfits-new
p:last-of-type
margin-bottom: 0
a
color: #be7a00
#outfit-forms
+clearfix
+module
@ -78,57 +82,85 @@ body.outfits-new
font-size: 175%
select
font-size: 120%
#description, #top-contributors
float: left
#description
margin-right: 2%
width: 64%
#top-contributors
border: 1px solid $input-border-color
margin-top: 1em
padding: 1%
width: 30%
ol
margin-left: 2em
padding-left: 1em
> a
font-size: 80%
display: block
text-align: right
#how-can-i-help, #i-found-something
+module
float: left
padding: 1%
width: 46%
h2
font-style: italic
input, button
font-size: 115%
input[type=text]
border-color: $module-border-color
width: 12em
#how-can-i-help
margin-right: 1%
#i-found-something
margin-left: 1%
a
float: right
font-size: 87.5%
margin-top: 1em
$section-count: 3
$section-border-width: 1px
$section-padding: 0.5em
$section-width: 100% / $section-count
// (A - (B-1)*C) / B
#sections
display: grid
grid-template-columns: 1fr 1fr 1fr
+clearfix
display: table
list-style: none
margin-top: 1em
li
display: grid
grid-template-areas: "header image" "info image" "form form"
grid-template-rows: auto auto auto
row-gap: .5em
padding: 0.5em
&:not(:first-child)
border-left: 1px solid $module-border-color
h3
grid-area: header
margin-bottom: 0
margin-bottom: .25em
li
border-left:
color: $module-border-color
style: solid
width: $section-border-width
display: table-cell
padding: $section-padding
position: relative
width: $section-width
&:first-child
border-left: 0
div
grid-area: info
color: $soft-text-color
font-size: 75%
margin-left: 1em
z-index: 2
strong
h4, input
font-size: 116%
a:has(img)
grid-area: image
h4, input[type=text]
color: inherit
h4 a
background: #ffffc0
img
opacity: 0.75
+opacity(0.75)
float: right
margin-left: .5em
&:hover
opacity: 1
+opacity(1)
p
line-height: 1.5
min-height: 4.5em
margin-bottom: 0
form
grid-area: form
display: flex
align-items: center
gap: .5em
font-size: .85em
margin-left: 1em
margin-right: .5em
input[type=text], input[type=search]
// TODO: It doesn't make sense to me that this is the right style? I
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
// which I would have *thought* is the same as this, but isn't! Idk!
width: 100%
#whats-new
margin-bottom: 1em
@ -297,3 +329,4 @@ body.outfits-new
#latest-contribution-created-at
color: $soft-text-color
margin-left: .5em

View file

@ -0,0 +1,29 @@
// Used internally:
$background_color: #0b61a4
$module_border_color: #033e6b
$module_background_color: #66a3d2
$input_hover_border_color: #ff9200
$input_focus_border_color: #fff
$loud_button_background_color: #ff9200
$loud_button_border_color: #ffad40
$loud_button_color: #a65f00
$loud_button_focus_border_color: #000
// Used by Blueprint:
$font_color: #fff
$header_color: inherit
$link_color: inherit
$link_hover_color: inherit
$link_focus_color: inherit
$link_active_color: inherit
$link_visited_color: inherit
$error_color: inherit
$error_bg_color: #e14f1c
$error_border_color: #cd0a0a

View file

@ -1,7 +1,6 @@
@import "clean/mixins"
=context-button
+awesome-button
+awesome-button-color(#aaaaaa)
+opacity(0.9)
font-size: 80%

View file

@ -67,21 +67,14 @@
background: #FEEBC8
color: #7B341E
.support-form
grid-area: support
font-size: 85%
text-align: left
.user-lists-info
grid-area: lists
font-size: 85%
text-align: left
display: flex
gap: 1em
a::after
content: " "
.user-lists-form-opener
&::after
content: " "
.user-lists-form
background: $background-color

View file

@ -18,8 +18,9 @@ $error-color: #8a1f11
$error-bg-color: #fbe3e4
$error-border-color: #fbc2c4
$header-font: Delicious, system-ui, sans-serif
$main-font: system-ui, sans-serif
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
$main-font: "Droid Sans", Helvetica, Arial, Verdana, sans-serif
$text-font: "Droid Serif", Georgia, "Times New Roman", Times, serif
$object-img-size: 80px
$object-width: 100px

View file

@ -1,15 +0,0 @@
support-outfit-viewer
margin-block: 1em
.fields li[data-type=radio-grid]
--num-columns: 3
.reference-link
display: flex
align-items: center
gap: .5em
padding-inline: .5em
img
height: 2em
width: auto

View file

@ -1,78 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/context_button"
support-outfit-viewer
display: flex
gap: 2em
flex-wrap: wrap
justify-content: center
outfit-viewer
flex: 0 0 auto
border: 1px solid $module-border-color
border-radius: 1em
.outfit-viewer-controls
margin-block: .5em
isolation: isolate // Avoid z-index weirdness with our buttons vs the lens
display: flex
align-items: center
justify-content: center
gap: .5em
font-size: .85em
fieldset
display: contents
legend
font-weight: bold
&::after
content: ":"
label
display: flex
align-items: center
gap: .25em
input[type=radio]
margin: 0
[type=submit]
+context-button
> table
flex: 0 0 auto
border-collapse: collapse
table-layout: fixed
border-radius: .5em
th, td
border: 1px solid $module-border-color
font-size: .85em
padding: .25em .5em
text-align: left
> tbody
[data-field=links]
ul
list-style-type: none
display: flex
gap: .5em
// Once the component is ready, add some hints about potential interactions.
&:state(ready)
> table
> tbody > tr
cursor: zoom-in
&:hover
background: $module-bg-color
magic-magnifier
--magic-magnifier-lens-width: 100px
--magic-magnifier-lens-height: 100px
--magic-magnifier-scale: 2.5
magic-magnifier-lens
z-index: 2 // Be above things by default, but not by much!

View file

@ -1,8 +0,0 @@
@import "../partials/clean/constants"
.rainbow-pool-list
--preview-base-width: 200px
margin-bottom: 2em
.glitched
cursor: help

View file

@ -2,8 +2,70 @@
@import "../partials/clean/mixins"
body.pets-bulk
#bulk-pets-form
#needed-items-form, #bulk-pets-form
text-align: center
#needed-items-form
#needed-items-pet
border-top: 1px solid $soft-border-color
display: none
margin-top: 1em
padding-top: 1em
h4
font-size: 150%
margin-bottom: .5em
#needed-items-reload
+inline-block
font-size: 12px
margin-left: 1em
vertical-align: middle
#needed-items-alert
display: none
margin-top: .5em
#needed-items-pet-thumbnail
height: 50px
width: 50px
#needed-items-pet-items
li.owned
background: $module-bg-color
border: 1px solid $module-border-color
.object-owned
color: $soft-text-color
display: block
font-size: 75%
font-style: italic
padding-bottom: .25em
&.loading-pet, &.loading-items
#needed-items-pet-name-field
background:
image: image-url("loading.gif")
position: center right
repeat: no-repeat
#needed-items-pet-items
+opacity(.50)
&.loading-pet
#needed-items-pet h4
+opacity(.50)
&.loaded
#needed-items-pet
display: block
&.failed
#needed-items-alert
display: block
#bulk-pets-form
border-top: 1px solid $module-border-color
margin-top: 12px
padding-top: 12px

View file

@ -4,6 +4,9 @@
position: absolute;
left: 0;
top: 0;
width: min(100vw, 100vh);
height: min(100vw, 100vh);
/* HACK: `calc` isn't needed, but works around a bug in our asset pipeline,
* where libsass is trying to preprocess it. (We're not SASS tho?) */
width: calc(min(100vw, 100vh));
height: calc(min(100vw, 100vh));
}

View file

@ -1,40 +1,21 @@
class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index]
def index
@all_alt_styles = AltStyle.includes(:species, :color)
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
order(:species_id, :color_id)
@all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name)
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name)
if params[:species_id]
@species = Species.find(params[:species_id])
@alt_styles = @alt_styles.merge(@species.alt_styles)
end
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort
@all_color_names = @all_colors.map(&:human_name)
@all_species_names = @all_species.map(&:human_name)
@series_name = params[:series]
@color = find_color
@species = find_species
@alt_styles = @all_alt_styles.includes(:swf_assets)
@alt_styles.where!(series_name: @series_name) if @series_name.present?
@alt_styles.merge!(@color.alt_styles) if @color
@alt_styles.merge!(@species.alt_styles) if @species
# We're using the HTML5 image for our preview, so make sure we have all the
# We're going to link to the HTML5 image URL, so make sure we have all the
# manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
respond_to do |format|
format.html {
@alt_styles = @alt_styles.
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
render
}
format.html { render }
format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).
sort_by(&:full_name)
render json: @alt_styles.as_json(
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
only: [:id, :species_id, :color_id, :body_id, :series_name,
:adjective_name, :thumbnail_url],
include: {
@ -49,56 +30,4 @@ class AltStylesController < ApplicationController
}
end
end
def edit
@alt_style = AltStyle.find params[:id]
end
def update
@alt_style = AltStyle.find params[:id]
if @alt_style.update(alt_style_params)
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
redirect_to destination_after_save
else
render action: :edit, status: :bad_request
end
end
protected
def alt_style_params
params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
end
def find_color
if params[:color]
Color.find_by(name: params[:color])
end
end
def find_species
if params[:species_id]
Species.find_by(id: params[:species_id])
elsif params[:species]
Species.find_by(name: params[:species])
end
end
def destination_after_save
if params[:next] == "unlabeled-style"
next_unlabeled_style_path
else
alt_styles_path
end
end
def next_unlabeled_style_path
unlabeled_style = AltStyle.unlabeled.newest.first
if unlabeled_style
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
else
alt_styles_path
end
end
end

View file

@ -2,10 +2,12 @@ require 'async'
require 'async/container'
class ApplicationController < ActionController::Base
include FragmentLocalization
protect_from_forgery
helper_method :current_user, :support_staff?, :user_signed_in?
helper_method :current_user, :user_signed_in?
before_action :set_locale
before_action :configure_permitted_parameters, if: :devise_controller?
@ -21,12 +23,9 @@ class ApplicationController < ActionController::Base
class AccessDenied < StandardError; end
rescue_from AccessDenied, with: :on_access_denied
rescue_from Async::Stop, Async::Container::Terminate,
with: :on_request_stopped
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
def authenticate_user!
redirect_to(new_auth_user_session_path) unless user_signed_in?
end
@ -46,15 +45,15 @@ class ApplicationController < ActionController::Base
def user_signed_in?
auth_user_signed_in?
end
def infer_locale
return params[:locale] if valid_locale?(params[:locale])
return cookies[:locale] if valid_locale?(cookies[:locale])
Rails.logger.debug "Preferred languages: #{http_accept_language.user_preferred_languages}"
http_accept_language.language_region_compatible_from(I18n.available_locales.map(&:to_s)) ||
http_accept_language.language_region_compatible_from(I18n.public_locales.map(&:to_s)) ||
I18n.default_locale
end
def not_found(record_name='record')
raise ActionController::RoutingError.new("#{record_name} not found")
end
@ -68,11 +67,6 @@ class ApplicationController < ActionController::Base
status: :internal_server_error
end
def on_db_timeout
render file: 'public/503.html', layout: false,
status: :service_unavailable
end
def redirect_back!(default=:back)
redirect_to(params[:return_to] || default)
end
@ -82,7 +76,7 @@ class ApplicationController < ActionController::Base
end
def valid_locale?(locale)
locale && I18n.available_locales.include?(locale.to_sym)
locale && I18n.usable_locales.include?(locale.to_sym)
end
def configure_permitted_parameters
@ -110,13 +104,5 @@ class ApplicationController < ActionController::Base
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
return_to || root_path
end
def support_staff?
current_user&.support_staff?
end
def support_staff_only
raise AccessDenied, "Support staff only" unless support_staff?
end
end

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController
before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error
def index
@ -29,12 +28,6 @@ class ItemsController < ApplicationController
render json: {
items: @items.as_json(
methods: [:nc?, :pb?, :owned?, :wanted?],
include: {
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
},
),
appearances: load_appearances.as_json(
include: {
@ -113,18 +106,24 @@ class ItemsController < ApplicationController
end
end
def edit
@item = Item.find params[:id]
render layout: "application"
end
def update
@item = Item.find params[:id]
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!"
redirect_to @item
else
render action: "edit", layout: "application", status: :bad_request
def needed
if params[:color] && params[:species]
@pet_type = PetType.find_by_color_id_and_species_id(
params[:color],
params[:species]
)
end
unless @pet_type
raise ActiveRecord::RecordNotFound, 'Pet type not found'
end
@items = @pet_type.needed_items.order(:name)
assign_closeted!(@items)
respond_to do |format|
format.html { @pet_name = params[:name] ; render :layout => 'application' }
format.json { render :json => @items }
end
end
@ -180,15 +179,6 @@ class ItemsController < ApplicationController
protected
def item_params
params.require(:item).permit(
:name, :thumbnail_url, :description, :modeling_status_hint,
:is_manually_nc, :explicitly_body_specific,
).tap do |p|
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
end
end
def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in?
end
@ -240,8 +230,7 @@ class ItemsController < ApplicationController
@item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first ||
PetType.matching_name("Blue", "Acara").first!
preferring_simple.first
end
def validate_preview

View file

@ -47,24 +47,29 @@ class OutfitsController < ApplicationController
end
def new
@colors = Color.alphabetical
@colors = Color.funny.alphabetical
@species = Species.alphabetical
newest_items = Item.newest.limit(18)
@newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?)
# HACK: Skip this in development, because it's slow!
unless Rails.env.development?
newest_items = Item.newest.
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
.limit(18)
@newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?)
@newest_unmodeled_items_predicted_missing_species_by_color = {}
@newest_unmodeled_items_predicted_modeled_ratio = {}
@newest_unmodeled_items.each do |item|
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
standard_body_ids_by_species = item.
predicted_missing_standard_body_ids_by_species
if standard_body_ids_by_species.present?
h[:standard] = standard_body_ids_by_species
@newest_unmodeled_items_predicted_missing_species_by_color = {}
@newest_unmodeled_items_predicted_modeled_ratio = {}
@newest_unmodeled_items.each do |item|
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
standard_body_ids_by_species = item.
predicted_missing_standard_body_ids_by_species
if standard_body_ids_by_species.present?
h[:standard] = standard_body_ids_by_species
end
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
end
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
end
@species_count = Species.count

View file

@ -1,56 +0,0 @@
class PetStatesController < ApplicationController
before_action :support_staff_only
before_action :find_pet_state
before_action :preload_assets
def edit
end
def update
if @pet_state.update(pet_state_params)
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
redirect_to destination_after_save
else
render action: :edit, status: :bad_request
end
end
protected
def find_pet_state
@pet_type = PetType.find_by_param!(params[:pet_type_name])
@pet_state = @pet_type.pet_states.find(params[:id])
@reference_pet_type = @pet_type.reference
end
def preload_assets
SwfAsset.preload_manifests @pet_state.swf_assets
end
def pet_state_params
params.require(:pet_state).permit(:pose, :glitched)
end
def destination_after_save
if params[:next] == "unlabeled-appearance"
next_unlabeled_appearance_path
else
@pet_type
end
end
def next_unlabeled_appearance_path
unlabeled_appearance =
PetState.next_unlabeled_appearance(after_id: params[:after])
if unlabeled_appearance
edit_pet_type_pet_state_path(
unlabeled_appearance.pet_type,
unlabeled_appearance,
next: "unlabeled-appearance"
)
else
@pet_type
end
end
end

View file

@ -1,111 +1,10 @@
class PetTypesController < ApplicationController
def index
respond_to do |format|
format.html {
@species_names = Species.order(:name).map(&:human_name)
@color_names = Color.order(:name).map(&:human_name)
if params[:species].present?
@selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name
end
if params[:color].present?
@selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name
end
@selected_order =
if @selected_species.present? || @selected_color.present?
:alphabetical
else
:newest
end
@pet_types = PetType.
includes(:color, :species, :pet_states).
paginate(page: params[:page], per_page: 30)
@pet_types.where!(species_id: @selected_species) if @selected_species
@pet_types.where!(color_id: @selected_color) if @selected_color
if @selected_order == :newest
@pet_types.order!(created_at: :desc)
elsif @selected_order == :alphabetical
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
end
if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first
end
if support_staff?
@counts = {
total: PetState.count,
glitched: PetState.glitched.count,
needs_labeling: PetState.needs_labeling.count,
usable: PetState.usable.count,
}
@unlabeled_appearance = PetState.next_unlabeled_appearance
end
}
format.json {
if stale?(etag: PetState.last_updated_key)
render json: {
species: Species.order(:name).all,
colors: Color.order(:name).all,
supported_poses: PetState.all_supported_poses,
}
end
}
end
end
def show
@pet_type = find_pet_type
@pet_type = PetType.
where(species_id: params[:species_id]).
where(color_id: params[:color_id]).
first
respond_to do |format|
format.html do
@pet_states = group_pet_states @pet_type.pet_states
end
format.json { render json: @pet_type }
end
end
protected
# The API-ish route uses IDs, but the human-facing route uses names.
def find_pet_type
if params[:species_id] && params[:color_id]
PetType.find_by!(
species_id: params[:species_id],
color_id: params[:color_id],
)
elsif params[:name]
PetType.find_by_param!(params[:name])
else
raise "expected params: species_id and color_id, or name"
end
end
# The `canonical` pet states are the main ones we want to show: the most
# canonical state for each pose. The `other` pet states are, the others!
#
# If no main poses are available, then we just make all the poses
# "canonical", and show the whole mish-mash!
def group_pet_states(pet_states)
pose_groups = pet_states.emotion_order.group_by(&:pose)
main_groups =
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
other_groups =
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
if main_groups.empty?
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
end
canonical = main_groups.map(&:first).sort_by(&:pose)
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
{canonical:, other:}
render json: @pet_type
end
end

View file

@ -1,17 +1,20 @@
class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
rescue_from Pet::PetNotFound, with: :pet_not_found
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
rescue_from Pet::DownloadError, with: :pet_download_error
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load
raise Neopets::CustomPets::PetNotFound unless params[:name]
# Uncomment this to temporarily disable modeling for most users.
# return modeling_disabled unless user_signed_in? && current_user.admin?
raise Pet::PetNotFound unless params[:name]
@pet = Pet.load(params[:name])
points = contribute(current_user, @pet)
respond_to do |format|
format.html do
path = destination + "?" + @pet.wardrobe_query
path = destination + @pet.wardrobe_query
redirect_to path
end
@ -35,8 +38,9 @@ class PetsController < ApplicationController
def destination
case (params[:destination] || params[:origin])
when 'wardrobe' then wardrobe_path
else root_path
when 'wardrobe' then wardrobe_path + '?'
when 'needed_items' then needed_items_path + '?'
else root_path + '#'
end
end
@ -45,6 +49,12 @@ class PetsController < ApplicationController
:status => :not_found
end
def asset_download_error(e)
Rails.logger.warn e.message
pet_load_error :long_message => t('pets.load.asset_download_error'),
:status => :gateway_timeout
end
def pet_download_error(e)
Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n")

View file

@ -12,20 +12,13 @@ class SwfAssetsController < ApplicationController
helpers.image_url("favicon.png"),
@swf_asset.image_url,
*@swf_asset.canvas_movie_sprite_urls,
# For images, `images.neopets.com` is a generally safe host to load
# from (shouldn't be a vulnerable site or exfiltration vector), and
# doing this can help make this header a *lot* shorter, which helps
# our nginx reverse proxy (and probably some clients) handle it. (For
# example, see asset `667993` for "Engulfed in Flames Effect".)
hosts: ["https://images.neopets.com"],
)
}
policy.script_src -> {
src_list(
helpers.javascript_url("easeljs.min"),
helpers.javascript_url("tweenjs.min"),
helpers.javascript_url("lib/easeljs.min"),
helpers.javascript_url("lib/tweenjs.min"),
helpers.javascript_url("swf_assets/show"),
@swf_asset.canvas_movie_library_url,
)
@ -45,14 +38,7 @@ class SwfAssetsController < ApplicationController
private
def src_list(*urls, hosts: [])
urls.
# Ignore any `nil`s that might arise
filter(&:present?).
# Remove query strings from URLs (they're invalid in CSPs)
map { |url| url.sub(/\?.*\z/, "") }.
# For the given `hosts`, remove all their specific URLs, and just list
# the host itself.
reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
def src_list(*urls)
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
end
end

View file

@ -1,13 +0,0 @@
module AltStylesHelper
def view_or_edit_alt_style_url(alt_style)
if support_staff?
edit_alt_style_path alt_style
else
wardrobe_path(
species: alt_style.species_id,
color: alt_style.color_id,
style: alt_style.id,
)
end
end
end

View file

@ -1,4 +1,6 @@
module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL
path_or_url
@ -99,12 +101,6 @@ module ApplicationHelper
"matchu@openneo.net"
end
EDIT_ICON_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></g>'.html_safe
def edit_icon(alt: "Edit")
content_tag :svg, EDIT_ICON_SVG_SOURCE, alt:, class: "icon",
viewBox: "0 0 24 24", style: "width: 1em; height: 1em"
end
# SVG icon source from Chakra UI!
EXTERNAL_LINK_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></g>'.html_safe
def external_link_icon
@ -127,6 +123,10 @@ module ApplicationHelper
!@hide_home_link
end
def support_staff?
user_signed_in? && current_user.support_staff?
end
def impress_2020_meta_tags
origin = Rails.configuration.impress_2020_origin
support_secret = Rails.application.credentials.dig(
@ -142,9 +142,20 @@ module ApplicationHelper
end
end
JAVASCRIPT_LIBRARIES = {
:jquery => 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js',
:jquery_tmpl => 'https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js',
}
def include_javascript_libraries(*library_names)
raw(library_names.inject('') do |html, name|
html + javascript_include_tag(JAVASCRIPT_LIBRARIES[name], defer: true)
end)
end
def locale_options
current_locale_is_public = false
options = I18n.available_locales.map do |available_locale|
options = I18n.public_locales.map do |available_locale|
current_locale_is_public = true if I18n.locale == available_locale
# Include fallbacks data on the tag. Right now it's used in blog
# localization, but may conceivably be used for something else later.
@ -159,6 +170,13 @@ module ApplicationHelper
options
end
def localized_cache(key={}, &block)
localized_key = localize_fragment_key(key, locale)
# TODO: The digest feature is handy, but it's not compatible with how we
# check for fragments existence in the controller, so skip it for now.
cache(localized_key, skip_digest: true, &block)
end
def auth_user_sign_in_path_with_return_to
new_auth_user_session_path :return_to => request.fullpath
@ -213,10 +231,6 @@ module ApplicationHelper
@hide_title_header = true
end
def hide_after(last_day, &block)
yield if Date.today <= last_day
end
def use_responsive_design
@use_responsive_design = true
add_body_class "use-responsive-design"

View file

@ -14,30 +14,19 @@ module ItemsHelper
}
Sizes = {
face: 1, # 50x50
face_3x: 6, # 150x150
thumb: 2, # 150x150
full: 4, # 300x300
large: 5, # 500x500
xlarge: 7, # 640x640
zoom: 3, # 80x80
autocrop: 9, # <varies>
}
SizeUpgrades = {
face: :face_3x,
thumb: :full,
full: :xlarge,
face: 1,
thumb: 2,
zoom: 3,
full: 4,
face_2x: 6,
}
end
def pet_type_image_url(pet_type, emotion: :happy, size: :face)
PetTypeImage::Template.expand(
hash: pet_type.basic_image_hash || pet_type.image_hash,
emotion: PetTypeImage::Emotions.fetch(emotion),
size: PetTypeImage::Sizes.fetch(size),
emotion: PetTypeImage::Emotions[emotion],
size: PetTypeImage::Sizes[size],
).to_s
end
@ -257,10 +246,8 @@ module ItemsHelper
def pet_type_image(pet_type, emotion, size, **options)
src = pet_type_image_url(pet_type, emotion:, size:)
size_2x = PetTypeImage::SizeUpgrades[size]
srcset = if size_2x
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
srcset = if size == :face
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
end
image_tag(src, srcset:, **options)

View file

@ -1,4 +1,9 @@
module OutfitsHelper
LAST_DAY_OF_NEOPASS_ANNOUNCEMENT = Date.parse("2024-05-05")
def show_neopass_announcement?
Date.today <= LAST_DAY_OF_NEOPASS_ANNOUNCEMENT
end
def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil
end
@ -64,28 +69,5 @@ module OutfitsHelper
options = {:spellcheck => false, :id => nil}.merge(options)
text_field_tag 'name', nil, options
end
def outfit_viewer(...)
render partial: "outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
def support_outfit_viewer(...)
render partial: "support_outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
private
def parse_outfit_viewer_options(
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
)
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
if outfit.nil?
raise ArgumentError, "outfit viewer must have outfit or pet state"
end
{outfit:, preferred_image_format:, html_options:}
end
end

View file

@ -1,41 +0,0 @@
module PetStatesHelper
def pose_name(pose)
case pose
when "HAPPY_FEM"
"Happy (Feminine)"
when "HAPPY_MASC"
"Happy (Masculine)"
when "SAD_FEM"
"Sad (Feminine)"
when "SAD_MASC"
"Sad (Masculine)"
when "SICK_FEM"
"Sick (Feminine)"
when "SICK_MASC"
"Sick (Masculine)"
when "UNCONVERTED"
"Unconverted"
else
"Not labeled yet"
end
end
POSE_OPTIONS = %w(HAPPY_FEM SAD_FEM SICK_FEM HAPPY_MASC SAD_MASC SICK_MASC
UNCONVERTED UNKNOWN)
def pose_options
POSE_OPTIONS
end
def useful_pet_state_path(pet_type, pet_state)
if support_staff?
edit_pet_type_pet_state_path(pet_type, pet_state)
else
wardrobe_path(
color: pet_type.color_id,
species: pet_type.species_id,
pose: pet_state.pose,
state: pet_state.id,
)
end
end
end

View file

@ -1,16 +0,0 @@
module PetTypesHelper
def moon_progress(num, total)
nearest_quarter = (4.0 * num / total).round / 4.0
if nearest_quarter >= 1
"🌕️"
elsif nearest_quarter >= 0.75
"🌔"
elsif nearest_quarter >= 0.5
"🌓"
elsif nearest_quarter >= 0.25
"🌒"
else
"🌑"
end
end
end

View file

@ -1,64 +0,0 @@
module SupportFormHelper
class SupportFormBuilder < ActionView::Helpers::FormBuilder
attr_reader :template
delegate :capture, :check_box_tag, :concat, :content_tag,
:hidden_field_tag, :params, :render,
to: :template, private: true
def errors
render partial: "application/support_form/errors", locals: {form: self}
end
def fields(&block)
content_tag(:ul, class: "fields", &block)
end
def field(**options, &block)
content_tag(:li, **options, &block)
end
def radio_fieldset(legend, **options, &block)
render partial: "application/support_form/radio_fieldset",
locals: {form: self, legend:, options:, content: capture(&block)}
end
def radio_field(**options, &block)
content_tag(:li) do
content_tag(:label, **options, &block)
end
end
def radio_grid_fieldset(*args, &block)
radio_fieldset(*args, "data-type": "radio-grid", &block)
end
def thumbnail_input(method)
render partial: "application/support_form/thumbnail_input",
locals: {form: self, method:}
end
def actions(&block)
content_tag(:section, class: "actions", &block)
end
def go_to_next_field(after: nil, **options, &block)
content_tag(:label, class: "go-to-next", **options) do
concat hidden_field_tag(:after, after) if after
yield
end
end
def go_to_next_check_box(value)
check_box_tag "next", value, checked: params[:next] == value
end
end
def support_form_with(**options, &block)
form_with(
builder: SupportFormBuilder,
**options,
class: ["support-form", options[:class]],
&block
)
end
end

View file

@ -1,6 +1,5 @@
import "@hotwired/turbo-rails";
document.addEventListener("change", (e) => {
if (!e.target.matches("#locale")) return;
document.getElementById("locale-form").submit();
document.getElementById("locale").addEventListener("change", function () {
document.getElementById("locale-form").submit();
});

View file

@ -7,8 +7,8 @@ const rootNode = document.querySelector("#wardrobe-2020-root");
// TODO: Use the new React 18 APIs instead!
// eslint-disable-next-line react/no-deprecated
ReactDOM.render(
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
);

View file

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

View file

@ -0,0 +1,905 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Tooltip,
useColorModeValue,
useToken,
Wrap,
WrapItem,
Flex,
} from "@chakra-ui/react";
import { WarningTwoIcon } from "@chakra-ui/icons";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
function SpeciesFacesPicker({
selectedSpeciesId,
selectedColorId,
compatibleBodies,
couldProbablyModelMoreData,
onChange,
isLoading,
}) {
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
// data, which is part of the bundle and loads super-fast. For other colors,
// we load in all the faces of that color, falling back to basic colors when
// absent!
//
// TODO: Could we move this into our `build-cached-data` script, and just do
// the query all the time, and have Apollo happen to satisfy it fast?
// The semantics of returning our colorful random set could be weird…
const selectedColorIsBasic = colorIsBasic(selectedColorId);
const {
loading: loadingGQL,
error,
data,
} = useQuery(
gql`
query SpeciesFacesPicker($selectedColorId: ID!) {
color(id: $selectedColorId) {
id
appliedToAllCompatibleSpecies {
id
neopetsImageHash
species {
id
}
body {
id
}
}
}
}
`,
{
variables: { selectedColorId },
skip: selectedColorId == null || selectedColorIsBasic,
onError: (e) => console.error(e),
},
);
const allBodiesAreCompatible = compatibleBodies.some(
(body) => body.id === "0",
);
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
const providedSpeciesFace = speciesFacesFromData.find(
(f) => f.species.id === defaultSpeciesFace.speciesId,
);
if (providedSpeciesFace) {
return {
...defaultSpeciesFace,
colorId: selectedColorId,
bodyId: providedSpeciesFace.body.id,
// If this species/color pair exists, but without an image hash, then
// we want to provide a face so that it's enabled, but use the fallback
// image even though it's wrong, so that it looks like _something_.
neopetsImageHash:
providedSpeciesFace.neopetsImageHash ||
defaultSpeciesFace.neopetsImageHash,
};
} else {
return defaultSpeciesFace;
}
});
return (
<Box>
<Wrap spacing="0" justify="center">
{allSpeciesFaces.map((speciesFace) => (
<WrapItem key={speciesFace.speciesId}>
<SpeciesFaceOption
speciesId={speciesFace.speciesId}
speciesName={speciesFace.speciesName}
colorId={speciesFace.colorId}
neopetsImageHash={speciesFace.neopetsImageHash}
isSelected={speciesFace.speciesId === selectedSpeciesId}
// If the face color doesn't match the current color, this is a
// fallback face for an invalid species/color pair.
isValid={
speciesFace.colorId === selectedColorId || selectedColorIsBasic
}
bodyIsCompatible={
allBodiesAreCompatible ||
compatibleBodyIds.includes(speciesFace.bodyId)
}
couldProbablyModelMoreData={couldProbablyModelMoreData}
onChange={onChange}
isLoading={isLoading || loadingGQL}
/>
</WrapItem>
))}
</Wrap>
{error && (
<Flex
color="yellow.500"
fontSize="xs"
marginTop="1"
textAlign="center"
width="100%"
align="flex-start"
justify="center"
>
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
<Box>
Error loading this color's pet photos.
<br />
Check your connection and try again.
</Box>
</Flex>
)}
</Box>
);
}
const SpeciesFaceOption = React.memo(
({
speciesId,
speciesName,
colorId,
neopetsImageHash,
isSelected,
bodyIsCompatible,
isValid,
couldProbablyModelMoreData,
onChange,
isLoading,
}) => {
const selectedBorderColor = useColorModeValue("green.600", "green.400");
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
const focusBorderColor = "blue.400";
const focusBackgroundColor = "blue.100";
const [
selectedBorderColorValue,
selectedBackgroundColorValue,
focusBorderColorValue,
focusBackgroundColorValue,
] = useToken("colors", [
selectedBorderColor,
selectedBackgroundColor,
focusBorderColor,
focusBackgroundColor,
]);
const xlShadow = useToken("shadows", "xl");
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
const [inputIsFocused, setInputIsFocused] = React.useState(false);
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
const isHappy = isLoading || (isValid && bodyIsCompatible);
const emotionId = isHappy ? "1" : "2";
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
let disabledExplanation = null;
if (isLoading) {
// If we're still loading, don't try to explain anything yet!
} else if (!isValid) {
disabledExplanation = "(Can't be this color)";
} else if (!bodyIsCompatible) {
disabledExplanation = couldProbablyModelMoreData
? "(Item needs models)"
: "(Not compatible)";
}
const tooltipLabel = (
<div style={{ textAlign: "center" }}>
{speciesName}
{disabledExplanation && (
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
{disabledExplanation}
</div>
)}
</div>
);
// NOTE: Because we render quite a few of these, avoiding using Chakra
// elements like Box helps with render performance!
return (
<ClassNames>
{({ css }) => (
<DeferredTooltip
label={tooltipLabel}
placement="top"
gutter={-10}
// We track hover and focus state manually for the tooltip, so that
// keyboard nav to switch between options causes the tooltip to
// follow. (By default, the tooltip appears on the first tab focus,
// but not when you _change_ options!)
isOpen={labelIsHovered || inputIsFocused}
>
<label
style={{ cursor }}
onMouseEnter={() => setLabelIsHovered(true)}
onMouseLeave={() => setLabelIsHovered(false)}
>
<input
type="radio"
aria-label={speciesName}
name="species-faces-picker"
value={speciesId}
checked={isSelected}
// It's possible to get this selected via the SpeciesColorPicker,
// even if this would normally be disabled. If so, make this
// option enabled, so keyboard users can focus and change it.
disabled={isDisabled && !isSelected}
onChange={() => onChange({ speciesId, colorId })}
onFocus={() => setInputIsFocused(true)}
onBlur={() => setInputIsFocused(false)}
className={css`
/* Copied from Chakra's <VisuallyHidden /> */
border: 0px;
clip: rect(0px, 0px, 0px, 0px);
height: 1px;
width: 1px;
margin: -1px;
padding: 0px;
overflow: hidden;
white-space: nowrap;
position: absolute;
`}
/>
<div
className={css`
overflow: hidden;
transition: all 0.2s;
position: relative;
input:checked + & {
background: ${selectedBackgroundColorValue};
border-radius: 6px;
box-shadow:
${xlShadow},
${selectedBorderColorValue} 0 0 2px 2px;
transform: scale(1.2);
z-index: 1;
}
input:focus + & {
background: ${focusBackgroundColorValue};
box-shadow:
${xlShadow},
${focusBorderColorValue} 0 0 0 3px;
}
`}
>
<CrossFadeImage
src={`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png`}
srcSet={
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
}
alt={speciesName}
width={55}
height={55}
data-is-loading={isLoading}
data-is-disabled={isDisabled}
className={css`
filter: saturate(90%);
opacity: 0.9;
transition: all 0.2s;
&[data-is-disabled="true"] {
filter: saturate(0%);
opacity: 0.6;
}
&[data-is-loading="true"] {
animation: 0.8s linear 0s infinite alternate none running
pulse;
}
input:checked + * &[data-body-is-disabled="false"] {
opacity: 1;
filter: saturate(110%);
}
input:checked + * &[data-body-is-disabled="true"] {
opacity: 0.85;
}
@keyframes pulse {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}
/* Alt text for when the image fails to load! We hide it
* while still loading though! */
font-size: 0.75rem;
text-align: center;
&:-moz-loading {
visibility: hidden;
}
&:-moz-broken {
padding: 0.5rem;
}
`}
/>
</div>
</label>
</DeferredTooltip>
)}
</ClassNames>
);
},
);
/**
* CrossFadeImage is like <img>, but listens for successful load events, and
* fades from the previous image to the new image once it loads.
*
* We treat `src` as a unique key representing the image's identity, but we
* also carry along the rest of the props during the fade, like `srcSet` and
* `className`.
*/
function CrossFadeImage(incomingImageProps) {
const [prevImageProps, setPrevImageProps] = React.useState(null);
const [currentImageProps, setCurrentImageProps] = React.useState(null);
const incomingImageIsCurrentImage =
incomingImageProps.src === currentImageProps?.src;
const onLoadNextImage = () => {
setPrevImageProps(currentImageProps);
setCurrentImageProps(incomingImageProps);
};
// The main trick to this component is using React's `key` feature! When
// diffing the rendered tree, if React sees two nodes with the same `key`, it
// treats them as the same node and makes the prop changes to match.
//
// We usually use this in `.map`, to make sure that adds/removes in a list
// don't cause our children to shift around and swap their React state or DOM
// nodes with each other.
//
// But here, we use `key` to get React to transition the same <img> DOM node
// between 3 different states!
//
// The image starts its life as the last in the list, from
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
// as the `key`.
//
// When it loads, we update the state so that this `key` now belongs to the
// _second_ node, from `currentImageProps`. React will see this and make the
// correct transition for us: it sets opacity to 0, sets z-index to 2,
// removes aria-hidden, and removes the `onLoad` handler.
//
// Then, when another image is ready to show, we update the state so that
// this key now belongs to the _first_ node, from `prevImageProps` (and the
// second node is showing something new). React sees this, and makes the
// transition back to invisibility, but without the `onLoad` handler this
// time! (And transitions the current image into view, like it did for this
// one.)
//
// Finally, when yet _another_ image is ready to show, we stop rendering any
// images with this key anymore, and so React unmounts the image entirely.
//
// Thanks, React, for handling our multiple overlapping transitions through
// this little state machine! This could have been a LOT harder to write,
// whew!
return (
<ClassNames>
{({ css }) => (
<div
className={css`
display: grid;
grid-template-areas: "shared-overlapping-area";
isolation: isolate; /* Avoid z-index conflicts with parent! */
> div {
grid-area: shared-overlapping-area;
transition: opacity 0.2s;
}
`}
>
{prevImageProps && (
<div
key={prevImageProps.src}
className={css`
z-index: 3;
opacity: 0;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img {...prevImageProps} aria-hidden />
</div>
)}
{currentImageProps && (
<div
key={currentImageProps.src}
className={css`
z-index: 2;
opacity: 1;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
{...currentImageProps}
// If the current image _is_ the incoming image, we'll allow
// new props to come in and affect it. But if it's a new image
// incoming, we want to stick to the last props the current
// image had! (This matters for e.g. `bodyIsCompatible`
// becoming true in `SpeciesFaceOption` and restoring color,
// before the new color's image loads in.)
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
/>
</div>
)}
{!incomingImageIsCurrentImage && (
<div
key={incomingImageProps.src}
className={css`
z-index: 1;
opacity: 0;
`}
>
{/* eslint-disable-next-line jsx-a11y/alt-text */}
<img
{...incomingImageProps}
aria-hidden
onLoad={onLoadNextImage}
/>
</div>
)}
</div>
)}
</ClassNames>
);
}
/**
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
* true before mounting it, and unmounts it after closing.
*
* This can drastically improve render performance when there are lots of
* tooltip targets to re-render but it comes with some limitations, like the
* extra requirement to control `isOpen`, and some additional DOM structure!
*/
function DeferredTooltip({ children, isOpen, ...props }) {
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
React.useEffect(() => {
if (isOpen) {
setShouldShowToolip(true);
} else {
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
return () => clearTimeout(timeoutId);
}
}, [isOpen]);
return (
<ClassNames>
{({ css }) => (
<div
className={css`
position: relative;
`}
>
{children}
{shouldShowTooltip && (
<Tooltip isOpen={isOpen} {...props}>
<div
className={css`
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
`}
/>
</Tooltip>
)}
</div>
)}
</ClassNames>
);
}
// HACK: I'm just hardcoding all this, rather than connecting up to the
// database and adding a loading state. Tbh I'm not sure it's a good idea
// to load this dynamically until we have SSR to make it come in fast!
// And it's not so bad if this gets out of sync with the database,
// because the SpeciesColorPicker will still be usable!
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
export function colorIsBasic(colorId) {
return ["8", "34", "61", "84"].includes(colorId);
}
const DEFAULT_SPECIES_FACES = [
{
speciesName: "Acara",
speciesId: "1",
colorId: colors.GREEN,
bodyId: "93",
neopetsImageHash: "obxdjm88",
},
{
speciesName: "Aisha",
speciesId: "2",
colorId: colors.BLUE,
bodyId: "106",
neopetsImageHash: "n9ozx4z5",
},
{
speciesName: "Blumaroo",
speciesId: "3",
colorId: colors.YELLOW,
bodyId: "47",
neopetsImageHash: "kfonqhdc",
},
{
speciesName: "Bori",
speciesId: "4",
colorId: colors.YELLOW,
bodyId: "84",
neopetsImageHash: "sc2hhvhn",
},
{
speciesName: "Bruce",
speciesId: "5",
colorId: colors.YELLOW,
bodyId: "146",
neopetsImageHash: "wqz8xn4t",
},
{
speciesName: "Buzz",
speciesId: "6",
colorId: colors.YELLOW,
bodyId: "250",
neopetsImageHash: "jc9klfxm",
},
{
speciesName: "Chia",
speciesId: "7",
colorId: colors.RED,
bodyId: "212",
neopetsImageHash: "4lrb4n3f",
},
{
speciesName: "Chomby",
speciesId: "8",
colorId: colors.YELLOW,
bodyId: "74",
neopetsImageHash: "bdml26md",
},
{
speciesName: "Cybunny",
speciesId: "9",
colorId: colors.GREEN,
bodyId: "94",
neopetsImageHash: "xl6msllv",
},
{
speciesName: "Draik",
speciesId: "10",
colorId: colors.YELLOW,
bodyId: "132",
neopetsImageHash: "bob39shq",
},
{
speciesName: "Elephante",
speciesId: "11",
colorId: colors.RED,
bodyId: "56",
neopetsImageHash: "jhhhbrww",
},
{
speciesName: "Eyrie",
speciesId: "12",
colorId: colors.RED,
bodyId: "90",
neopetsImageHash: "6kngmhvs",
},
{
speciesName: "Flotsam",
speciesId: "13",
colorId: colors.GREEN,
bodyId: "136",
neopetsImageHash: "47vt32x2",
},
{
speciesName: "Gelert",
speciesId: "14",
colorId: colors.YELLOW,
bodyId: "138",
neopetsImageHash: "5nrd2lvd",
},
{
speciesName: "Gnorbu",
speciesId: "15",
colorId: colors.BLUE,
bodyId: "166",
neopetsImageHash: "6c275jcg",
},
{
speciesName: "Grarrl",
speciesId: "16",
colorId: colors.BLUE,
bodyId: "119",
neopetsImageHash: "j7q65fv4",
},
{
speciesName: "Grundo",
speciesId: "17",
colorId: colors.GREEN,
bodyId: "126",
neopetsImageHash: "5xn4kjf8",
},
{
speciesName: "Hissi",
speciesId: "18",
colorId: colors.RED,
bodyId: "67",
neopetsImageHash: "jsfvcqwt",
},
{
speciesName: "Ixi",
speciesId: "19",
colorId: colors.GREEN,
bodyId: "163",
neopetsImageHash: "w32r74vo",
},
{
speciesName: "Jetsam",
speciesId: "20",
colorId: colors.YELLOW,
bodyId: "147",
neopetsImageHash: "kz43rnld",
},
{
speciesName: "Jubjub",
speciesId: "21",
colorId: colors.GREEN,
bodyId: "80",
neopetsImageHash: "m267j935",
},
{
speciesName: "Kacheek",
speciesId: "22",
colorId: colors.YELLOW,
bodyId: "117",
neopetsImageHash: "4gsrb59g",
},
{
speciesName: "Kau",
speciesId: "23",
colorId: colors.BLUE,
bodyId: "201",
neopetsImageHash: "ktlxmrtr",
},
{
speciesName: "Kiko",
speciesId: "24",
colorId: colors.GREEN,
bodyId: "51",
neopetsImageHash: "42j5q3zx",
},
{
speciesName: "Koi",
speciesId: "25",
colorId: colors.GREEN,
bodyId: "208",
neopetsImageHash: "ncfn87wk",
},
{
speciesName: "Korbat",
speciesId: "26",
colorId: colors.RED,
bodyId: "196",
neopetsImageHash: "omx9c876",
},
{
speciesName: "Kougra",
speciesId: "27",
colorId: colors.BLUE,
bodyId: "143",
neopetsImageHash: "rfsbh59t",
},
{
speciesName: "Krawk",
speciesId: "28",
colorId: colors.BLUE,
bodyId: "150",
neopetsImageHash: "hxgsm5d4",
},
{
speciesName: "Kyrii",
speciesId: "29",
colorId: colors.YELLOW,
bodyId: "175",
neopetsImageHash: "blxmjgbk",
},
{
speciesName: "Lenny",
speciesId: "30",
colorId: colors.YELLOW,
bodyId: "173",
neopetsImageHash: "8r94jhfq",
},
{
speciesName: "Lupe",
speciesId: "31",
colorId: colors.YELLOW,
bodyId: "199",
neopetsImageHash: "z42535zh",
},
{
speciesName: "Lutari",
speciesId: "32",
colorId: colors.BLUE,
bodyId: "52",
neopetsImageHash: "qgg6z8s7",
},
{
speciesName: "Meerca",
speciesId: "33",
colorId: colors.YELLOW,
bodyId: "109",
neopetsImageHash: "kk2nn2jr",
},
{
speciesName: "Moehog",
speciesId: "34",
colorId: colors.GREEN,
bodyId: "134",
neopetsImageHash: "jgkoro5z",
},
{
speciesName: "Mynci",
speciesId: "35",
colorId: colors.BLUE,
bodyId: "95",
neopetsImageHash: "xwlo9657",
},
{
speciesName: "Nimmo",
speciesId: "36",
colorId: colors.BLUE,
bodyId: "96",
neopetsImageHash: "bx7fho8x",
},
{
speciesName: "Ogrin",
speciesId: "37",
colorId: colors.YELLOW,
bodyId: "154",
neopetsImageHash: "rjzmx24v",
},
{
speciesName: "Peophin",
speciesId: "38",
colorId: colors.RED,
bodyId: "55",
neopetsImageHash: "kokc52kh",
},
{
speciesName: "Poogle",
speciesId: "39",
colorId: colors.GREEN,
bodyId: "76",
neopetsImageHash: "fw6lvf3c",
},
{
speciesName: "Pteri",
speciesId: "40",
colorId: colors.RED,
bodyId: "156",
neopetsImageHash: "tjhwbro3",
},
{
speciesName: "Quiggle",
speciesId: "41",
colorId: colors.YELLOW,
bodyId: "78",
neopetsImageHash: "jdto7mj4",
},
{
speciesName: "Ruki",
speciesId: "42",
colorId: colors.BLUE,
bodyId: "191",
neopetsImageHash: "qsgbm5f6",
},
{
speciesName: "Scorchio",
speciesId: "43",
colorId: colors.RED,
bodyId: "187",
neopetsImageHash: "hkjoncsx",
},
{
speciesName: "Shoyru",
speciesId: "44",
colorId: colors.YELLOW,
bodyId: "46",
neopetsImageHash: "mmvn4tkg",
},
{
speciesName: "Skeith",
speciesId: "45",
colorId: colors.RED,
bodyId: "178",
neopetsImageHash: "fc4cxk3t",
},
{
speciesName: "Techo",
speciesId: "46",
colorId: colors.YELLOW,
bodyId: "100",
neopetsImageHash: "84gvowmj",
},
{
speciesName: "Tonu",
speciesId: "47",
colorId: colors.BLUE,
bodyId: "130",
neopetsImageHash: "jd433863",
},
{
speciesName: "Tuskaninny",
speciesId: "48",
colorId: colors.YELLOW,
bodyId: "188",
neopetsImageHash: "q39wn6vq",
},
{
speciesName: "Uni",
speciesId: "49",
colorId: colors.GREEN,
bodyId: "257",
neopetsImageHash: "njzvoflw",
},
{
speciesName: "Usul",
speciesId: "50",
colorId: colors.RED,
bodyId: "206",
neopetsImageHash: "rox4mgh5",
},
{
speciesName: "Vandagyre",
speciesId: "55",
colorId: colors.YELLOW,
bodyId: "306",
neopetsImageHash: "xkntzsww",
},
{
speciesName: "Wocky",
speciesId: "51",
colorId: colors.YELLOW,
bodyId: "101",
neopetsImageHash: "dnr2kj4b",
},
{
speciesName: "Xweetok",
speciesId: "52",
colorId: colors.RED,
bodyId: "68",
neopetsImageHash: "tdkqr2b6",
},
{
speciesName: "Yurble",
speciesId: "53",
colorId: colors.RED,
bodyId: "182",
neopetsImageHash: "h95cs547",
},
{
speciesName: "Zafara",
speciesId: "54",
colorId: colors.BLUE,
bodyId: "180",
neopetsImageHash: "x8c57g2l",
},
];
export default SpeciesFacesPicker;

View file

@ -0,0 +1,691 @@
import React from "react";
import { useQuery } from "@apollo/client";
import gql from "graphql-tag";
import {
AspectRatio,
Box,
Button,
Flex,
Grid,
IconButton,
Tooltip,
useColorModeValue,
usePrefersReducedMotion,
} from "@chakra-ui/react";
import { EditIcon, WarningIcon } from "@chakra-ui/icons";
import { MdPause, MdPlayArrow } from "react-icons/md";
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
import SpeciesColorPicker, {
useAllValidPetPoses,
getValidPoses,
getClosestPose,
} from "./components/SpeciesColorPicker";
import SpeciesFacesPicker, {
colorIsBasic,
} from "./ItemPage/SpeciesFacesPicker";
import {
itemAppearanceFragment,
petAppearanceFragment,
} from "./components/useOutfitAppearance";
import { useOutfitPreview } from "./components/OutfitPreview";
import { logAndCapture, useLocalStorage } from "./util";
import { useItemAppearances } from "./loaders/items";
function ItemPageOutfitPreview({ itemId }) {
const idealPose = React.useMemo(
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
[],
);
const [petState, setPetState] = React.useState({
// We'll fill these in once the canonical appearance data arrives.
speciesId: null,
colorId: null,
pose: null,
isValid: false,
// We use appearance ID, in addition to the above, to give the Apollo cache
// a really clear hint that the canonical pet appearance we preloaded is
// the exact right one to show! But switching species/color will null this
// out again, and that's okay. (We'll do an unnecessary reload if you
// switch back to it though... we could maybe do something clever there!)
appearanceId: null,
});
const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
"DTIItemPreviewPreferredSpeciesId",
null,
);
const [preferredColorId, setPreferredColorId] = useLocalStorage(
"DTIItemPreviewPreferredColorId",
null,
);
const setPetStateFromUserAction = React.useCallback(
(newPetState) =>
setPetState((prevPetState) => {
// When the user _intentionally_ chooses a species or color, save it in
// local storage for next time. (This won't update when e.g. their
// preferred species or color isn't available for this item, so we update
// to the canonical species or color automatically.)
//
// Re the "ifs", I have no reason to expect null to come in here, but,
// since this is touching client-persisted data, I want it to be even more
// reliable than usual!
if (
newPetState.speciesId &&
newPetState.speciesId !== prevPetState.speciesId
) {
setPreferredSpeciesId(newPetState.speciesId);
}
if (
newPetState.colorId &&
newPetState.colorId !== prevPetState.colorId
) {
if (colorIsBasic(newPetState.colorId)) {
// When the user chooses a basic color, don't index on it specifically,
// and instead reset to use default colors.
setPreferredColorId(null);
} else {
setPreferredColorId(newPetState.colorId);
}
}
return newPetState;
}),
[setPreferredColorId, setPreferredSpeciesId],
);
// We don't need to reload this query when preferred species/color change, so
// cache their initial values here to use as query arguments.
const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId);
const [initialPreferredColorId] = React.useState(preferredColorId);
const {
data: itemAppearancesData,
loading: loadingAppearances,
error: errorAppearances,
} = useItemAppearances(itemId);
const itemName = itemAppearancesData?.name ?? "";
const itemAppearances = itemAppearancesData?.appearances ?? [];
const restrictedZones = itemAppearancesData?.restrictedZones ?? [];
// Start by loading the "canonical" pet and item appearance for the outfit
// preview. We'll use this to initialize both the preview and the picker.
//
// If the user has a preferred species saved from using the ItemPage in the
// past, we'll send that instead. This will return the appearance on that
// species if possible, or the default canonical species if not.
//
// TODO: If this is a non-standard pet color, like Mutant, we'll do an extra
// query after this loads, because our Apollo cache can't detect the
// shared item appearance. (For standard colors though, our logic to
// cover standard-color switches works for this preloading too.)
const {
loading: loadingGQL,
error: errorGQL,
data,
} = useQuery(
gql`
query ItemPageOutfitPreview(
$itemId: ID!
$preferredSpeciesId: ID
$preferredColorId: ID
) {
item(id: $itemId) {
id
canonicalAppearance(
preferredSpeciesId: $preferredSpeciesId
preferredColorId: $preferredColorId
) {
id
...ItemAppearanceForOutfitPreview
body {
id
canonicalAppearance(preferredColorId: $preferredColorId) {
id
species {
id
name
}
color {
id
}
pose
...PetAppearanceForOutfitPreview
}
}
}
}
}
${itemAppearanceFragment}
${petAppearanceFragment}
`,
{
variables: {
itemId,
preferredSpeciesId: initialPreferredSpeciesId,
preferredColorId: initialPreferredColorId,
},
onCompleted: (data) => {
const canonicalBody = data?.item?.canonicalAppearance?.body;
const canonicalPetAppearance = canonicalBody?.canonicalAppearance;
setPetState({
speciesId: canonicalPetAppearance?.species?.id,
colorId: canonicalPetAppearance?.color?.id,
pose: canonicalPetAppearance?.pose,
isValid: true,
appearanceId: canonicalPetAppearance?.id,
});
},
},
);
const compatibleBodies = itemAppearances?.map(({ body }) => body) || [];
// If there's only one compatible body, and the canonical species's name
// appears in the item name, then this is probably a species-specific item,
// and we should adjust the UI to avoid implying that other species could
// model it.
const speciesName =
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name ??
"";
const isProbablySpeciesSpecific =
compatibleBodies.length === 1 &&
compatibleBodies[0] !== "all" &&
itemName.toLowerCase().includes(speciesName.toLowerCase());
const couldProbablyModelMoreData = !isProbablySpeciesSpecific;
// TODO: Does this double-trigger the HTTP request with SpeciesColorPicker?
const {
loading: loadingValids,
error: errorValids,
valids,
} = useAllValidPetPoses();
const [hasAnimations, setHasAnimations] = React.useState(false);
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
// This is like <OutfitPreview />, but we can use the appearance data, too!
const { appearance, preview } = useOutfitPreview({
speciesId: petState.speciesId,
colorId: petState.colorId,
pose: petState.pose,
appearanceId: petState.appearanceId,
wornItemIds: [itemId],
isLoading: loadingGQL || loadingValids,
spinnerVariant: "corner",
engine: "canvas",
onChangeHasAnimations: setHasAnimations,
});
// If there's an appearance loaded for this item, but it's empty, then the
// item is incompatible. (There should only be one item appearance: this one!)
const itemAppearance = appearance?.itemAppearances?.[0];
const itemLayers = itemAppearance?.layers || [];
const isCompatible = itemLayers.length > 0;
const usesHTML5 = itemLayers.every(layerUsesHTML5);
const onChange = React.useCallback(
({ speciesId, colorId }) => {
const validPoses = getValidPoses(valids, speciesId, colorId);
const pose = getClosestPose(validPoses, idealPose);
setPetStateFromUserAction({
speciesId,
colorId,
pose,
isValid: true,
appearanceId: null,
});
},
[valids, idealPose, setPetStateFromUserAction],
);
const borderColor = useColorModeValue("green.700", "green.400");
const errorColor = useColorModeValue("red.600", "red.400");
const error = errorGQL || errorAppearances || errorValids;
if (error) {
return <Box color="red.400">{error.message}</Box>;
}
return (
<Grid
templateAreas={{
base: `
"preview"
"speciesColorPicker"
"speciesFacesPicker"
"zones"
`,
md: `
"preview speciesFacesPicker"
"speciesColorPicker zones"
`,
}}
// HACK: Really I wanted 400px to match the natural height of the
// preview in md, but in Chromium that creates a scrollbar and
// 401px doesn't, not sure exactly why?
templateRows={{
base: "auto auto 200px auto",
md: "401px auto",
}}
templateColumns={{
base: "minmax(min-content, 400px)",
md: "minmax(min-content, 400px) fit-content(480px)",
}}
rowGap="4"
columnGap="6"
justifyContent="center"
width="100%"
>
<AspectRatio
gridArea="preview"
maxWidth="400px"
maxHeight="400px"
ratio="1"
border="1px"
borderColor={borderColor}
transition="border-color 0.2s"
borderRadius="lg"
boxShadow="lg"
overflow="hidden"
>
<Box>
{petState.isValid && preview}
<CustomizeMoreButton
speciesId={petState.speciesId}
colorId={petState.colorId}
pose={petState.pose}
itemId={itemId}
isDisabled={!petState.isValid}
/>
{hasAnimations && (
<PlayPauseButton
isPaused={isPaused}
onClick={() => setIsPaused(!isPaused)}
/>
)}
</Box>
</AspectRatio>
<Flex gridArea="speciesColorPicker" alignSelf="start" align="center">
<Box
// This box grows at the same rate as the box on the right, so the
// middle box will be centered, if there's space!
flex="1 0 0"
/>
<SpeciesColorPicker
speciesId={petState.speciesId}
colorId={petState.colorId}
pose={petState.pose}
idealPose={idealPose}
onChange={(species, color, isValid, closestPose) => {
setPetStateFromUserAction({
speciesId: species.id,
colorId: color.id,
pose: closestPose,
isValid,
appearanceId: null,
});
}}
speciesIsDisabled={isProbablySpeciesSpecific}
size="sm"
showPlaceholders
/>
<Box flex="1 0 0" lineHeight="1" paddingLeft="1">
{
// Wait for us to start _requesting_ the appearance, and _then_
// for it to load, and _then_ check compatibility.
!loadingGQL &&
!loadingAppearances &&
!appearance.loading &&
petState.isValid &&
!isCompatible && (
<Tooltip
label={
couldProbablyModelMoreData
? "Item needs models"
: "Not compatible"
}
placement="top"
>
<WarningIcon
color={errorColor}
transition="color 0.2"
marginLeft="2"
borderRadius="full"
tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }}
/>
</Tooltip>
)
}
</Box>
</Flex>
<Box
gridArea="speciesFacesPicker"
paddingTop="2"
overflow="auto"
padding="8px"
>
<SpeciesFacesPicker
selectedSpeciesId={petState.speciesId}
selectedColorId={petState.colorId}
compatibleBodies={compatibleBodies}
couldProbablyModelMoreData={couldProbablyModelMoreData}
onChange={onChange}
isLoading={loadingGQL || loadingAppearances || loadingValids}
/>
</Box>
<Flex gridArea="zones" justifySelf="center" align="center">
{itemAppearances.length > 0 && (
<ItemZonesInfo
itemAppearances={itemAppearances}
restrictedZones={restrictedZones}
/>
)}
<Box width="6" />
<Flex
// Avoid layout shift while loading
minWidth="54px"
>
<HTML5Badge
usesHTML5={usesHTML5}
// If we're not compatible, act the same as if we're loading:
// don't change the badge, but don't show one yet if we don't
// have one yet.
isLoading={appearance.loading || !isCompatible}
/>
</Flex>
</Flex>
</Grid>
);
}
function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) {
const url =
`/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` +
`objects[]=${itemId}`;
// The default background is good in light mode, but in dark mode it's a
// very subtle transparent white... make it a semi-transparent black, for
// better contrast against light-colored background items!
const backgroundColor = useColorModeValue(undefined, "blackAlpha.700");
const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900");
return (
<LinkOrButton
href={isDisabled ? null : url}
role="group"
position="absolute"
top="2"
right="2"
size="sm"
background={backgroundColor}
_hover={{ backgroundColor: backgroundColorHover }}
_focus={{ backgroundColor: backgroundColorHover, boxShadow: "outline" }}
boxShadow="sm"
isDisabled={isDisabled}
>
<ExpandOnGroupHover paddingRight="2">Customize more</ExpandOnGroupHover>
<EditIcon />
</LinkOrButton>
);
}
function LinkOrButton({ href, ...props }) {
if (href != null) {
return <Button as="a" href={href} {...props} />;
} else {
return <Button {...props} />;
}
}
/**
* ExpandOnGroupHover starts at width=0, and expands to full width when a
* parent with role="group" gains hover or focus state.
*/
function ExpandOnGroupHover({ children, ...props }) {
const [measuredWidth, setMeasuredWidth] = React.useState(null);
const measurerRef = React.useRef(null);
const prefersReducedMotion = usePrefersReducedMotion();
React.useLayoutEffect(() => {
if (!measurerRef) {
// I don't think this is possible, but I'd like to know if it happens!
logAndCapture(
new Error(
`Measurer node not ready during effect. Transition won't be smooth.`,
),
);
return;
}
if (measuredWidth != null) {
// Skip re-measuring when we already have a measured width. This is
// mainly defensive, to prevent the possibility of loops, even though
// this algorithm should be stable!
return;
}
const newMeasuredWidth = measurerRef.current.offsetWidth;
setMeasuredWidth(newMeasuredWidth);
}, [measuredWidth]);
return (
<Flex
// In block layout, the overflowing children would _also_ be constrained
// to width 0. But in flex layout, overflowing children _keep_ their
// natural size, so we can measure it even when not visible.
width="0"
overflow="hidden"
// Right-align the children, to keep the text feeling right-aligned when
// we expand. (To support left-side expansion, make this a prop!)
justify="flex-end"
// If the width somehow isn't measured yet, expand to width `auto`, which
// won't transition smoothly but at least will work!
_groupHover={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
_groupFocus={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
transition={!prefersReducedMotion && "width 0.2s"}
>
<Box ref={measurerRef} {...props}>
{children}
</Box>
</Flex>
);
}
function PlayPauseButton({ isPaused, onClick }) {
return (
<IconButton
icon={isPaused ? <MdPlayArrow /> : <MdPause />}
aria-label={isPaused ? "Play" : "Pause"}
onClick={onClick}
borderRadius="full"
boxShadow="md"
color="gray.50"
backgroundColor="blackAlpha.700"
position="absolute"
bottom="2"
left="2"
_hover={{ backgroundColor: "blackAlpha.900" }}
_focus={{ backgroundColor: "blackAlpha.900" }}
/>
);
}
function ItemZonesInfo({ itemAppearances, restrictedZones }) {
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
// merging zones with the same label, because that's how user-facing zone UI
// generally works!
const zoneLabelsAndTheirBodiesMap = {};
for (const { body, swfAssets } of itemAppearances) {
for (const { zone } of swfAssets) {
if (!zoneLabelsAndTheirBodiesMap[zone.label]) {
zoneLabelsAndTheirBodiesMap[zone.label] = {
zoneLabel: zone.label,
bodies: [],
};
}
zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body);
}
}
const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap);
const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
buildSortKeyForZoneLabelsAndTheirBodies(b),
),
);
const restrictedZoneLabels = [
...new Set(restrictedZones.map((z) => z.label)),
].sort();
// We only show body info if there's more than one group of bodies to talk
// about. If they all have the same zones, it's clear from context that any
// preview available in the list has the zones listed here.
const bodyGroups = new Set(
zoneLabelsAndTheirBodies.map(({ bodies }) =>
bodies.map((b) => b.id).join(","),
),
);
const showBodyInfo = bodyGroups.size > 1;
return (
<Flex
fontSize="sm"
textAlign="center"
// If the text gets too long, wrap Restricts onto another line, and center
// them relative to each other.
wrap="wrap"
justify="center"
data-test-id="item-zones-info"
>
<Box flex="0 0 auto" maxWidth="100%">
<Box as="header" fontWeight="bold" display="inline">
Occupies:
</Box>{" "}
<Box as="ul" listStyleType="none" display="inline">
{sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => (
<Box
key={zoneLabel}
as="li"
display="inline"
_notLast={{ _after: { content: '", "' } }}
>
<Box
as="span"
// Don't wrap any of the list item content. But, by putting
// this in an extra container element, we _do_ allow wrapping
// _between_ list items.
whiteSpace="nowrap"
>
<ItemZonesInfoListItem
zoneLabel={zoneLabel}
bodies={bodies}
showBodyInfo={showBodyInfo}
/>
</Box>
</Box>
))}
</Box>
</Box>
<Box width="4" flex="0 0 auto" />
<Box flex="0 0 auto" maxWidth="100%">
<Box as="header" fontWeight="bold" display="inline">
Restricts:
</Box>{" "}
{restrictedZoneLabels.length > 0 ? (
<Box as="ul" listStyleType="none" display="inline">
{restrictedZoneLabels.map((zoneLabel) => (
<Box
key={zoneLabel}
as="li"
display="inline"
_notLast={{ _after: { content: '", "' } }}
>
<Box
as="span"
// Don't wrap any of the list item content. But, by putting
// this in an extra container element, we _do_ allow wrapping
// _between_ list items.
whiteSpace="nowrap"
>
{zoneLabel}
</Box>
</Box>
))}
</Box>
) : (
<Box as="span" fontStyle="italic" opacity="0.8">
N/A
</Box>
)}
</Box>
</Flex>
);
}
function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) {
let content = zoneLabel;
if (showBodyInfo) {
if (bodies.some((b) => b.representsAllBodies)) {
content = <>{content} (all species)</>;
} else {
// TODO: This is a bit reductive, if it's different for like special
// colors, e.g. Blue Acara vs Mutant Acara, this will just show
// "Acara" in either case! (We are at least gonna be defensive here
// and remove duplicates, though, in case both the Blue Acara and
// Mutant Acara body end up in the same list.)
const speciesNames = new Set(bodies.map((b) => b.species.humanName));
const speciesListString = [...speciesNames].sort().join(", ");
content = (
<>
{content}{" "}
<Tooltip
label={speciesListString}
textAlign="center"
placement="bottom"
>
<Box
as="span"
tabIndex="0"
_focus={{ outline: "none", boxShadow: "outline" }}
fontStyle="italic"
textDecoration="underline"
style={{ textDecorationStyle: "dotted" }}
opacity="0.8"
>
{/* Show the speciesNames count, even though it's less info,
* because it's more important that the tooltip content matches
* the count we show! */}
({speciesNames.size} species)
</Box>
</Tooltip>
</>
);
}
}
return content;
}
function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
// Sort by "represents all bodies", then by body count descending, then
// alphabetically.
const representsAllBodies = bodies.some((body) => body.representsAllBodies);
// To sort by body count _descending_, we subtract it from a large number.
// Then, to make it work in string comparison, we pad it with leading zeroes.
// Hacky but solid!
const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0");
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
}
export default ItemPageOutfitPreview;

View file

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

View file

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

View file

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

Some files were not shown because too many files have changed in this diff Show more