Compare commits

..

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

321 changed files with 15918 additions and 17382 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

27
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,13 +61,16 @@ 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.
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
@ -84,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,9 +128,6 @@ 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)
@ -153,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)
@ -185,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)
@ -223,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)
@ -234,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)
@ -245,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)
@ -284,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
@ -371,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)
@ -464,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)
@ -473,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)
@ -500,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
@ -518,17 +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)
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)
@ -539,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)
@ -557,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,
});
})();

View file

@ -1,11 +1,4 @@
(function () {
function addCSRFToken(xhr) {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
}
var hangersInitCallbacks = [];
function onHangersInit(callback) {
@ -292,7 +285,6 @@
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function (data) {
if (quantityEl.val() == 0) {
objectRemoved(objectWrapper);
@ -397,7 +389,6 @@
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function () {
button.val("Remove");
},
@ -474,7 +465,6 @@
url: form.attr("action"),
type: form.attr("method"),
data: data,
beforeSend: addCSRFToken,
success: function (html) {
var doc = $(html);
hangersEl.html(doc.find("#closet-hangers").html());
@ -511,7 +501,6 @@
url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
type: "delete",
dataType: "json",
beforeSend: addCSRFToken,
success: function () {
objectRemoved(hangerEls);
},
@ -578,7 +567,6 @@
closet_hanger: closetHanger,
return_to: window.location.pathname + window.location.search,
},
beforeSend: addCSRFToken,
complete: function () {
itemsSearchField.removeClass("loading");
},
@ -723,7 +711,6 @@
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function () {
contactForm.enableForms();
},
@ -744,7 +731,6 @@
type: "POST",
data: { neopets_connection: { neopets_username: newUsername } },
dataType: "json",
beforeSend: addCSRFToken,
success: function (connection) {
var newOption = $("<option/>", {
text: newUsername,

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) {
$('span.choose-outfit select').change(function(e) {
var select = $(this);
select.closest("li").find("input[type=text]").val(select.val());
select.closest('li').find('input[type=text]').val(select.val());
});
})();

View file

@ -4,11 +4,9 @@ document.addEventListener("change", (e) => {
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form",
);
const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']",
);
"#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) {
@ -16,14 +14,6 @@ document.addEventListener("change", (e) => {
}
});
// 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();
});
class SpeciesColorPicker extends HTMLElement {
#internals;
@ -81,35 +71,6 @@ class SpeciesFacePickerOptions extends HTMLElement {
}
}
// 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");
}
}
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

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

@ -6,20 +6,19 @@
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,
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
})
.prependTo("#container");
}
@ -70,6 +69,21 @@
},
};
function loadNotable() {
// TODO: add HTTPS to notables
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
// var notables = response.notables;
// var i = Math.floor(Math.random() * notables.length);
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
// if(!Preview.Job.current) {
// Preview.Job.fallback.setAsCurrent();
// }
// });
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
}
function loadFeature() {
$.getJSON("/donations/features", function (features) {
if (features.length > 0) {
@ -78,6 +92,8 @@
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
} else {
loadNotable();
}
});
}
@ -90,7 +106,16 @@
job.loading = false;
function getImageSrc() {
if (base === "cp" || base === "cpn") {
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;
@ -123,13 +148,7 @@
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.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();

View file

@ -1,3 +1,116 @@
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"),
@ -9,15 +122,6 @@
$(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";
}
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
bulk_load_queue = new (function BulkLoadQueue() {
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
var RECENTLY_SENT_MAX = 3;
@ -37,12 +141,6 @@
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();

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,110 +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

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,57 +0,0 @@
.support-form
display: flex
flex-direction: column
gap: 1em
align-items: flex-start
fieldset
width: 100%
display: grid
grid-template-columns: auto 1fr
align-items: center
gap: 1em
> *:nth-child(2n)
width: 40rch
max-width: 100%
box-sizing: border-box
input[type=url]
font-size: .85em
> label, .field-name
font-weight: bold
&:has(+ .radio-field)
align-self: start
.thumbnail-field
display: flex
align-items: center
gap: .25em
img
width: 40px
height: 40px
input
flex: 1 0 20ch
.radio-field
display: flex
flex-direction: column
gap: .25em
.field_with_errors
display: contents
.actions
display: flex
align-items: center
gap: 1em
label
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

@ -67,20 +67,13 @@
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
.user-lists-form-opener
&::after
content: " "
.user-lists-form

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,25 +0,0 @@
@import "../partials/clean/constants"
outfit-viewer
margin: 0 auto
.pose-options
list-style-type: none
display: grid
grid-template-columns: 1fr 1fr 1fr
gap: .25em
label
display: flex
align-items: center
gap: .5em
padding: .5em 1em
border: 1px solid $soft-border-color
border-radius: 1em
input
margin: 0
&:has(:checked)
background: $module-bg-color
border-color: $module-border-color

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,6 +2,8 @@ require 'async'
require 'async/container'
class ApplicationController < ActionController::Base
include FragmentLocalization
protect_from_forgery
helper_method :current_user, :user_signed_in?
@ -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
@ -51,7 +50,7 @@ class ApplicationController < ActionController::Base
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
@ -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,11 +104,5 @@ class ApplicationController < ActionController::Base
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
return_to || root_path
end
def support_staff_only
unless current_user&.support_staff?
raise AccessDenied, "Support staff only"
end
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"
def needed
if params[:color] && params[:species]
@pet_type = PetType.find_by_color_id_and_species_id(
params[:color],
params[:species]
)
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"
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,10 +47,14 @@ class OutfitsController < ApplicationController
end
def new
@colors = Color.alphabetical
@colors = Color.funny.alphabetical
@species = Species.alphabetical
newest_items = Item.newest.limit(18)
# 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?)
@ -66,6 +70,7 @@ class OutfitsController < ApplicationController
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
end
end
@species_count = Species.count

View file

@ -1,27 +0,0 @@
class PetStatesController < ApplicationController
before_action :find_pet_state
before_action :support_staff_only
def edit
end
def update
if @pet_state.update(pet_state_params)
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
redirect_to @pet_type
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])
end
def pet_state_params
params.require(:pet_state).permit(:pose, :glitched)
end
end

View file

@ -1,101 +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
}
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
@ -146,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.
@ -164,6 +171,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
end

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,7 +1,7 @@
module OutfitsHelper
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-11-08")
def show_announcement?
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
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)
@ -69,12 +69,5 @@ module OutfitsHelper
options = {:spellcheck => false, :id => nil}.merge(options)
text_field_tag 'name', nil, options
end
def outfit_viewer(outfit=nil, pet_state: nil, **html_options)
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
raise "outfit_viewer must have outfit or pet state" if outfit.nil?
render partial: "outfit_viewer", locals: {outfit:, 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,6 +1,5 @@
import "@hotwired/turbo-rails";
document.addEventListener("change", (e) => {
if (!e.target.matches("#locale")) return;
document.getElementById("locale").addEventListener("change", function () {
document.getElementById("locale-form").submit();
});

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

@ -24,8 +24,8 @@ import {
import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport";
const LoadableItemSupportDrawer = loadable(
() => import("./support/ItemSupportDrawer"),
const LoadableItemSupportDrawer = loadable(() =>
import("./support/ItemSupportDrawer"),
);
/**

View file

@ -777,13 +777,8 @@ function StyleExplanation() {
opacity="0.7"
marginTop="2"
>
<Box
as="a"
href="/rainbow-pool/styles"
target="_blank"
textDecoration="underline"
>
Pet Styles
<Box as="a" href="/alt-styles" target="_blank" textDecoration="underline">
Alt Styles
</Box>{" "}
are NC items that override the pet's appearance via the{" "}
<Box
@ -794,7 +789,7 @@ function StyleExplanation() {
>
Styling Chamber
</Box>
. Not all items fit all Pet Styles. The pet's color doesn't have to match.
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
</Box>
);
}

View file

@ -346,11 +346,14 @@ let cachedResponseForAllValidPetPoses = null;
* data from GraphQL serves on the first render, without a loading state.
*/
export function useAllValidPetPoses() {
const networkResponse = useFetch(buildImpress2020Url("/api/validPetPoses"), {
const networkResponse = useFetch(
buildImpress2020Url("/api/validPetPoses"),
{
responseType: "arrayBuffer",
// If we already have globally-cached valids, skip the request.
skip: cachedResponseForAllValidPetPoses != null,
});
},
);
// Use the globally-cached response if we have one, or await the network
// response if not.

View file

@ -11,7 +11,7 @@ export function getSupportSecret() {
function readOrigin() {
const node = document.querySelector("meta[name=impress-2020-origin]");
return node?.content || "https://impress-2020.openneo.net";
return node?.content || "https://impress-2020.openneo.net"
}
function readSupportSecret() {

View file

@ -1,4 +1,5 @@
import AppProvider from "./AppProvider";
import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
import WardrobePage from "./WardrobePage";
export { AppProvider, WardrobePage };
export { AppProvider, ItemPageOutfitPreview, WardrobePage };

View file

@ -13,7 +13,9 @@ export function useItemAppearances(id, options = {}) {
}
async function loadItemAppearancesData(id) {
const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`);
const res = await fetch(
`/items/${encodeURIComponent(id)}/appearances.json`,
);
if (!res.ok) {
throw new Error(
@ -199,10 +201,10 @@ function normalizeItemSearchAppearance(data, item) {
__typename: "ItemAppearance",
id: `item-${item.id}-body-${data.body.id}`,
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
restrictedZones: [
...item.restricted_zones,
...data.swf_assets.map((a) => a.restricted_zones).flat(),
].map(normalizeZone),
restrictedZones: data.swf_assets
.map((a) => a.restricted_zones)
.flat()
.map(normalizeZone),
};
}

View file

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

View file

@ -4,68 +4,49 @@ class AltStyle < ApplicationRecord
belongs_to :species
belongs_to :color
has_many :parent_swf_asset_relationships, as: :parent, dependent: :destroy
has_many :parent_swf_asset_relationships, as: :parent
has_many :swf_assets, through: :parent_swf_asset_relationships
has_many :contributions, as: :contributed, inverse_of: :contributed
validates :body_id, presence: true
validates :series_name, presence: true, allow_nil: true
validates :thumbnail_url, presence: true
before_validation :infer_thumbnail_url, unless: :thumbnail_url?
before_create :infer_series_name
before_create :infer_thumbnail_url
scope :matching_name, ->(series_name, color_name, species_name) {
color = Color.find_by_name!(color_name)
species = Species.find_by_name!(species_name)
where(series_name:, color_id: color.id, species_id: species.id)
}
scope :by_creation_date, -> {
order("DATE(created_at) DESC")
}
scope :unlabeled, -> { where(series_name: nil) }
scope :newest, -> { order(created_at: :desc) }
def pet_name
def name
I18n.translate('pet_types.human_name', color_human_name: color.human_name,
species_human_name: species.human_name)
end
alias_method :name, :pet_name
# If the series_name hasn't yet been set manually by support staff, show the
# string "<New?>" instead. But it won't be searchable by that string—that is,
# `fits:<New?>-faerie-draik` intentionally will not work, and the canonical
# filter name will be `fits:alt-style-IDNUMBER`, instead.
def series_name
real_series_name || "<New?>"
end
def real_series_name=(new_series_name)
self[:series_name] = new_series_name
end
def real_series_name
self[:series_name]
self[:series_name] || "<New?>"
end
# You can use this to check whether `series_name` is returning the actual
# value or its placeholder value.
def real_series_name?
real_series_name.present?
def has_real_series_name?
self[:series_name].present?
end
def adjective_name
"#{series_name} #{color.human_name}"
end
def full_name
"#{series_name} #{name}"
end
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
def preview_image_url
# Use the image URL for the first asset. Or, fall back to an empty image.
swf_assets.first&.image_url || EMPTY_IMAGE_URL
swf_asset = swf_assets.first
return nil if swf_asset.nil?
swf_asset.image_url
end
# Given a list of items, return how they look on this alt style.
@ -73,6 +54,28 @@ class AltStyle < ApplicationRecord
Item.appearances_for(items, self, ...)
end
def biology=(biology)
# TODO: This is very similar to what `PetState` does, but like… much much
# more compact? Idk if I'm missing something, or if I was just that much
# more clueless back when I wrote it, lol 😅
self.swf_assets = biology.values.map do |asset_data|
SwfAsset.from_biology_data(self.body_id, asset_data)
end
end
# Until the end of 2024, assume new alt styles are from the "Nostalgic"
# series. That way, we can stop having to manually label them all as they
# come out and get modeled (TNT is prolific rn!), but we aren't gonna get too
# greedy and forget about this and use Nostalgic for some far-future thing,
# in ways that will certainly be fixable but would also be confusing and
# embarrassing.
NOSTALGIC_FINAL_DAY = Date.new(2024, 12, 31)
def infer_series_name
if !has_real_series_name? && Date.today <= NOSTALGIC_FINAL_DAY
self.series_name = "Nostalgic"
end
end
# At time of writing, most batches of Alt Styles thumbnails used a simple
# pattern for the item thumbnail URL, but that's not always the case anymore.
# For now, let's keep using this format as the default value when creating a
@ -82,7 +85,7 @@ class AltStyle < ApplicationRecord
)
DEFAULT_THUMBNAIL_URL = "https://images.neopets.com/items/mall_bg_circle.gif"
def infer_thumbnail_url
if real_series_name?
if has_real_series_name?
self.thumbnail_url = THUMBNAIL_URL_TEMPLATE.expand(
series: series_name.gsub(/\s+/, '').downcase,
color: color.name.gsub(/\s+/, '').downcase,
@ -93,10 +96,6 @@ class AltStyle < ApplicationRecord
end
end
def real_thumbnail_url?
thumbnail_url != DEFAULT_THUMBNAIL_URL
end
# For convenience in the console!
def self.find_by_name(color_name, species_name)
color = Color.find_by_name(color_name)

View file

@ -161,7 +161,7 @@ class AuthUser < AuthRecord
# means we can wrap it in a `with_timeout` block!)
neopets_username = Sync do |task|
task.with_timeout(5) do
Neopets::NeoPass.load_main_neopets_username(auth.credentials.token)
NeoPass.load_main_neopets_username(auth.credentials.token)
end
rescue Async::TimeoutError
nil # If the request times out, just move on!

View file

@ -1,11 +1,11 @@
class Color < ApplicationRecord
has_many :pet_types
has_many :alt_styles
scope :alphabetical, -> { order(:name) }
scope :basic, -> { where(basic: true) }
scope :standard, -> { where(standard: true) }
scope :nonstandard, -> { where(standard: false) }
scope :funny, -> { order(:prank) unless pranks_funny? }
validates :name, presence: true
@ -14,34 +14,29 @@ class Color < ApplicationRecord
end
def human_name
if name
name.split(' ').map { |word| word.capitalize }.join(' ')
if prank? && !Color.pranks_funny?
unfunny_human_name + ' ' + I18n.translate('colors.prank_suffix')
else
I18n.translate('colors.default_human_name')
unfunny_human_name
end
end
def to_param
name? ? human_name : id.to_s
end
def example_pet_type(preferred_species: nil)
preferred_species ||= Species.first
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
"species_id ASC").first
end
def default_gender_presentation
if name.downcase.ends_with? "boy"
:masc
elsif name.downcase.ends_with? "girl"
:fem
def unfunny_human_name
if name
name.split(' ').map { |word| word.capitalize }.join(' ')
else
nil
I18n.translate('colors.default_human_name')
end
end
def self.param_to_id(param)
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
def self.pranks_funny?
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
now.month == 4 && now.day == 1
end
end

View file

@ -10,29 +10,16 @@ class Item < ApplicationRecord
SwfAssetType = 'object'
serialize :cached_compatible_body_ids, coder: Serializers::IntegerSet
serialize :cached_occupied_zone_ids, coder: Serializers::IntegerSet
has_many :closet_hangers
has_one :contribution, as: :contributed, inverse_of: :contributed
has_one :contribution, :as => :contributed, :inverse_of => :contributed
has_one :nc_mall_record
has_many :parent_swf_asset_relationships, as: :parent
has_many :swf_assets, through: :parent_swf_asset_relationships
has_many :parent_swf_asset_relationships, :as => :parent
has_many :swf_assets, :through => :parent_swf_asset_relationships
belongs_to :dyeworks_base_item, class_name: "Item",
default: -> { inferred_dyeworks_base_item }, optional: true
has_many :dyeworks_variants, class_name: "Item",
inverse_of: :dyeworks_base_item
# We require a name field. A number of other fields must be *specified*: they
# can't be nil, to help ensure we aren't forgetting any fields when importing
# items. But sometimes they happen to be blank (e.g. when TNT leaves an item
# description empty, oops), in which case we want to accept that reality!
validates_presence_of :name
validates :description, :thumbnail_url, :rarity, :price, :zones_restrict,
exclusion: {in: [nil], message: "must be specified"}
after_save :update_cached_fields,
if: :modeling_status_hint_previously_changed?
attr_writer :current_body_id, :owned, :wanted
@ -73,25 +60,39 @@ class Item < ApplicationRecord
where('description NOT LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
}
scope :is_modeled, -> {
where(cached_predicted_fully_modeled: true)
}
scope :is_not_modeled, -> {
where(cached_predicted_fully_modeled: false)
}
scope :occupies, ->(zone_label) {
Zone.matching_label(zone_label).
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
zone_ids = Zone.matching_label(zone_label).map(&:id)
# NOTE: In searches, this query performs much better using a subquery
# instead of joins! This is because, in the joins case, filtering by an
# `swf_assets` field but sorting by an `items` field causes the query
# planner to only be able to use an index for *one* of them. In this case,
# MySQL can use the `swf_assets`.`zone_id` index to get the item IDs for
# the subquery, then use the `items`.`name` index to sort them.
i = arel_table
psa = ParentSwfAssetRelationship.arel_table
sa = SwfAsset.arel_table
where(
ParentSwfAssetRelationship.joins(:swf_asset).
where(sa[:zone_id].in(zone_ids)).
where(psa[:parent_type].eq("Item")).
where(psa[:parent_id].eq(i[:id])).
arel.exists
)
}
scope :not_occupies, ->(zone_label) {
Zone.matching_label(zone_label).
map { |z| not_occupies_zone_id(z.id) }.reduce(all, &:and)
}
scope :occupies_zone_id, ->(zone_id) {
where("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
}
scope :not_occupies_zone_id, ->(zone_id) {
where.not("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
zone_ids = Zone.matching_label(zone_label).map(&:id)
i = Item.arel_table
sa = SwfAsset.arel_table
# Querying for "has NO swf_assets matching these zone IDs" is trickier than
# the positive case! To do it, we GROUP_CONCAT the zone_ids together for
# each item, then use FIND_IN_SET to search the result for each zone ID,
# and assert that it must not find a match. (This is uhh, not exactly fast,
# so it helps to have other tighter conditions applied first!)
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
condition = zone_ids.map { 'FIND_IN_SET(?, GROUP_CONCAT(zone_id)) = 0' }.join(' AND ')
joins(:swf_assets).group(i[:id]).having(condition, *zone_ids).distinct
}
scope :restricts, ->(zone_label) {
zone_ids = Zone.matching_label(zone_label).map(&:id)
@ -104,12 +105,31 @@ class Item < ApplicationRecord
where("NOT (#{condition})", *zone_ids)
}
scope :fits, ->(body_id) {
where("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
or(where("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
}
scope :not_fits, ->(body_id) {
where.not("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
and(where.not("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
i = Item.arel_table
sa = SwfAsset.arel_table
# Querying for "has NO swf_assets matching these body IDs" is trickier than
# the positive case! To do it, we GROUP_CONCAT the body_ids together for
# each item, then use FIND_IN_SET to search the result for the body ID,
# and assert that it must not find a match. (This is uhh, not exactly fast,
# so it helps to have other tighter conditions applied first!)
#
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
#
# NOTE: The `fits` and `not_fits` counts don't perfectly add up to the
# total number of items, 5 items aren't accounted for? I'm not going to
# bother looking into this, but one thing I notice is items with no assets
# somehow would not match either scope in this impl (but LEFT JOIN would!)
joins(:swf_assets).group(i[:id]).
having(
"FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0 AND " +
"FIND_IN_SET(0, GROUP_CONCAT(body_id)) = 0",
body_id
).
distinct
}
def nc_trade_value
@ -223,14 +243,8 @@ class Item < ApplicationRecord
normalized_name = name.downcase.gsub("female", "girl").gsub("male", "boy").
gsub(/\s/, "")
# For each color, normalize its name, look for it in the item name, and
# return the matching color that appears earliest. (This is important for
# items that contain multiple color names, like the "Royal Girl Elephante
# Gold Bracelets".)
Color.all.to_h { |c| [c, c.name.downcase.gsub(/\s/, "")] }.
transform_values { |n| normalized_name.index(n) }.
filter { |c, n| n.present? }.
min_by { |c, i| i }&.first
Color.order(:name).
find { |c| normalized_name.include?(c.name.downcase.gsub(/\s/, "")) }
end
# If this is a PB item, return the corresponding Species, inferred from the
@ -276,23 +290,6 @@ class Item < ApplicationRecord
restricted_zones + occupied_zones
end
def update_cached_fields
# First, clear out some cached instance variables we use for performance,
# to ensure we recompute the latest values.
@predicted_body_ids = nil
@predicted_missing_body_ids = nil
# We also need to reload our associations, so they include any new records.
swf_assets.reload
# Finally, compute and save our cached fields.
self.cached_occupied_zone_ids = occupied_zone_ids
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
self.cached_predicted_fully_modeled =
predicted_fully_modeled?(use_cached: false)
self.save!
end
def species_support_ids
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
end
@ -303,82 +300,69 @@ class Item < ApplicationRecord
write_attribute('species_support_ids', replacement)
end
def modeling_hinted_done?
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
def support_species?(species)
species_support_ids.blank? || species_support_ids.include?(species.id)
end
def modeled_body_ids
@modeled_body_ids ||= swf_assets.select('DISTINCT body_id').map(&:body_id)
end
def modeled_color_ids
# Might be empty if modeled_body_ids is 0. But it's currently not called
# in that scenario, so, whatever.
@modeled_color_ids ||= PetType.select('DISTINCT color_id').
where(body_id: modeled_body_ids).
map(&:color_id)
end
def basic_body_ids
@basic_body_ids ||= begin
basic_color_ids ||= Color.select([:id]).basic.map(&:id)
PetType.select('DISTINCT body_id').
where(color_id: basic_color_ids).map(&:body_id)
end
end
def predicted_body_ids
@predicted_body_ids ||= if modeling_hinted_done?
# If we've manually set this item to no longer report as needing modeling,
# predict that the current bodies are all of the compatible bodies.
compatible_body_ids
elsif compatible_body_ids.include?(0)
@predicted_body_ids ||= if modeled_body_ids.include?(0)
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This
# isn't folded into the case below, in case this item somehow got a
# body-specific and non-body-specific asset. In all the cases I've seen
# it, that indicates a glitched item, but this method chooses to reflect
# behavior elsewhere in the app by saying that we can put this item on
# anybody. (Heh. Any body.))
compatible_body_ids
elsif compatible_body_ids.size == 1
modeled_body_ids
elsif modeled_body_ids.size == 1
# This might just be a species-specific item. Let's be conservative in
# our prediction, though we'll revise it if we see another body ID.
compatible_body_ids
elsif compatible_body_ids.size == 0
# If somehow we have this item, but not any modeling data for it (weird!),
# consider it to fit all standard pet types until shown otherwise.
PetType.basic.released_before(released_at_estimate).
distinct.pluck(:body_id).sort
modeled_body_ids
else
# First, find our compatible pet types, then pair each body ID with its
# color. (As an optimization, we omit standard colors, other than the
# basic colors. We also flatten the basic colors into the single color
# ID "basic", so we can treat them specially.)
compatible_pairs = compatible_pet_types.joins(:color).
merge(Color.nonstandard.or(Color.basic)).
distinct.pluck(
Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id)
# If an item is worn by more than one body, then it must be wearable by
# all bodies of the same color. (To my knowledge, anyway. I'm not aware
# of any exceptions.) So, let's find those bodies by first finding those
# colors.
basic_modeled_body_ids, nonbasic_modeled_body_ids = modeled_body_ids.
partition { |bi| basic_body_ids.include?(bi) }
# Group colors by body, to help us find bodies unique to certain colors.
compatible_color_ids_by_body_id = {}.tap do |h|
compatible_pairs.each do |(color_id, body_id)|
h[body_id] ||= []
h[body_id] << color_id
output = []
if basic_modeled_body_ids.present?
output += basic_body_ids
end
if nonbasic_modeled_body_ids.present?
nonbasic_modeled_color_ids = PetType.select('DISTINCT color_id').
where(body_id: nonbasic_modeled_body_ids).
map(&:color_id)
output += PetType.select('DISTINCT body_id').
where(color_id: nonbasic_modeled_color_ids).
map(&:body_id)
end
# Find non-basic colors with at least one unique compatible body. (This
# means we'll ignore e.g. the Maraquan Mynci, which has the same body as
# the Blue Mynci, as not indicating Maraquan compatibility in general.)
modelable_color_ids =
compatible_color_ids_by_body_id.
filter { |k, v| v.size == 1 && v.first != "basic" }.
values.map(&:first).uniq
# We can model on basic pets (perhaps in addition to the above) if we
# find at least one compatible basic body that doesn't *also* fit any of
# the modelable colors we identified above.
basic_is_modelable =
compatible_color_ids_by_body_id.values.
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
# Filter to pet types that match the colors that seem compatible.
predicted_pet_types =
(basic_is_modelable ? PetType.basic : PetType.none).
or(PetType.where(color_id: modelable_color_ids))
# Only include species that were released when this item was. If we don't
# know our creation date (we don't have it for some old records), assume
# it's pretty old.
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
# Get all body IDs for the pet types we decided are modelable.
predicted_pet_types.distinct.pluck(:body_id).sort
output
end
end
def predicted_missing_body_ids
@predicted_missing_body_ids ||= predicted_body_ids - compatible_body_ids
@predicted_missing_body_ids ||= predicted_body_ids - modeled_body_ids
end
def predicted_missing_standard_body_ids_by_species_id
@ -398,8 +382,9 @@ class Item < ApplicationRecord
end
def predicted_missing_nonstandard_body_pet_types
body_ids = predicted_missing_body_ids - PetType.basic_body_ids
PetType.joins(:color).where(body_id: body_ids, colors: {standard: false})
PetType.joins(:color).
where(body_id: predicted_missing_body_ids - basic_body_ids,
colors: {standard: false})
end
def predicted_missing_nonstandard_body_ids_by_species_by_color
@ -424,19 +409,12 @@ class Item < ApplicationRecord
body_ids_by_species_by_color
end
def predicted_fully_modeled?(use_cached: true)
return cached_predicted_fully_modeled? if use_cached
def predicted_fully_modeled?
predicted_missing_body_ids.empty?
end
def predicted_modeled_ratio
compatible_body_ids.size.to_f / predicted_body_ids.size
end
# We estimate the item's release time as either when we first saw it, or 2010
# if it's so old that we don't have a record.
def released_at_estimate
created_at || Time.new(2010)
modeled_body_ids.size.to_f / predicted_body_ids.size
end
def as_json(options={})
@ -446,9 +424,7 @@ class Item < ApplicationRecord
}.merge(options))
end
def compatible_body_ids(use_cached: true)
return cached_compatible_body_ids if use_cached
def compatible_body_ids
swf_assets.map(&:body_id).uniq
end

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