Compare commits
No commits in common. "main" and "simpler-item-previews" have entirely different histories.
main
...
simpler-it
2
.gitignore
vendored
|
@ -4,8 +4,6 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.env.*
|
||||
/spec/examples.txt
|
||||
/.yardoc
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
|
|
@ -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
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
|
@ -1 +1 @@
|
|||
3.3.5
|
||||
3.3.4
|
||||
|
|
27
Gemfile
|
@ -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
|
||||
|
|
148
Gemfile.lock
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
BIN
app/assets/fonts/Delicious-Heavy.otf
Normal file
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 23 KiB |
BIN
app/assets/images/about/neopass-header.png
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
app/assets/images/about/neopass-survey.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
app/assets/images/about/neopass-survey@2x.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
app/assets/images/broken_item_thumbnail.gif
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/assets/images/emoticons/grin.gif
Normal file
After Width: | Height: | Size: 585 B |
BIN
app/assets/images/emoticons/tongue.gif
Normal file
After Width: | Height: | Size: 601 B |
BIN
app/assets/images/grid.png
Normal file
After Width: | Height: | Size: 206 B |
BIN
app/assets/images/image_mode_icon.png
Normal file
After Width: | Height: | Size: 516 B |
BIN
app/assets/images/image_mode_preview.png
Normal file
After Width: | Height: | Size: 127 KiB |
BIN
app/assets/images/loading_current_outfit.gif
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/loading_outfit_pane.gif
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/outfits/default.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
app/assets/images/outfits/medium_default.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
app/assets/images/outfits/small_default.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
app/assets/images/outfits/small_loading.gif
Normal file
After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 2.8 KiB |
20
app/assets/javascripts/ajax_auth.js
Normal 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,
|
||||
});
|
||||
})();
|
|
@ -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,
|
||||
|
|
8
app/assets/javascripts/closet_hangers/petpage.js
Normal 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);
|
||||
})();
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
(function () {
|
||||
$("span.choose-outfit select").change(function (e) {
|
||||
(function() {
|
||||
$('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());
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -54,7 +44,7 @@ class SpeciesFacePicker extends HTMLElement {
|
|||
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
this.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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);
|
||||
|
|
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
|
|
30
app/assets/stylesheets/_items.sass
Normal 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
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
18
app/assets/stylesheets/alt_styles/_index.sass
Normal 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
|
|
@ -1,4 +0,0 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
|
@ -1,3 +0,0 @@
|
|||
.rainbow-pool-list
|
||||
.name span
|
||||
display: inline-block
|
|
@ -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
|
||||
|
|
|
@ -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: "-"
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
58
app/assets/stylesheets/closet_hangers/_petpage.sass
Normal 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%
|
|
@ -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%
|
|
@ -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" %>");
|
||||
}
|
14
app/assets/stylesheets/fonts.css.sass
Normal 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")
|
29
app/assets/stylesheets/item_trades/_index.sass
Normal 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: ", "
|
|
@ -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: ", "
|
25
app/assets/stylesheets/items/_index.sass
Normal 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
|
||||
|
350
app/assets/stylesheets/items/_show.sass
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
29
app/assets/stylesheets/partials/_blue.sass
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -1,8 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-list
|
||||
--preview-base-width: 200px
|
||||
margin-bottom: 2em
|
||||
|
||||
.glitched
|
||||
cursor: help
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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();
|
||||
});
|
||||
|
|
905
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal 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;
|
691
app/javascript/wardrobe-2020/ItemPageOutfitPreview.js
Normal 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;
|
|
@ -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"),
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import AppProvider from "./AppProvider";
|
||||
import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
|
||||
import WardrobePage from "./WardrobePage";
|
||||
|
||||
export { AppProvider, WardrobePage };
|
||||
export { AppProvider, ItemPageOutfitPreview, WardrobePage };
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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!
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|