1
0
Fork 1

Compare commits

..

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

522 changed files with 16350 additions and 19478 deletions
.gitignore
.husky
.rspec.ruby-versionGemfileGemfile.lock
app
assets
controllers
helpers
javascript

2
.gitignore vendored
View file

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

View file

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

1
.rspec
View file

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

View file

@ -1 +1 @@
3.3.7 3.3.0

41
Gemfile
View file

@ -1,13 +1,10 @@
source 'https://rubygems.org' source 'https://rubygems.org'
ruby '3.3.7' ruby '3.3.0'
gem 'rails', '~> 8.0', '>= 8.0.1' gem 'rails', '~> 7.1', '>= 7.1.3.4'
# The HTTP server running the Rails instance. # The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.0' gem 'falcon', '~> 0.43.0'
# HACK: Workaround for https://github.com/socketry/protocol-rack/issues/20
gem 'protocol-rack', '~> 0.10.0', '< 0.11.0'
# Our database is MySQL, in both development and production. # Our database is MySQL, in both development and production.
gem 'mysql2', '~> 0.5.5' gem 'mysql2', '~> 0.5.5'
@ -22,7 +19,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0' gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17' gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1' gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.3' gem 'jsbundling-rails', '~> 1.1'
gem 'turbo-rails', '~> 2.0' gem 'turbo-rails', '~> 2.0'
# For authentication. # For authentication.
@ -36,7 +33,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0' gem 'will_paginate', '~> 4.0'
# For translation, both for the site UI and for Neopets data. # For translation, both for the site UI and for Neopets data.
gem 'rails-i18n', '~> 8.0', '>= 8.0.1' gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
gem 'http_accept_language', '~> 2.1', '>= 2.1.1' gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages. # For reading and parsing HTML from Neopets.com, like importing Closet pages.
@ -60,19 +57,19 @@ gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
gem 'parallel', '~> 1.23' gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests. # For miscellaneous HTTP requests.
gem "httparty", "~> 0.22.0" gem "httparty", "~> 0.21.0"
gem "addressable", "~> 2.8" gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests. # For advanced batching of many HTTP requests.
gem "async", "~> 2.17", require: false gem "async", "~> 2.6", require: false
gem "async-http", "~> 0.86.0", require: false gem "async-http", "~> 0.61.0", require: false
gem "thread-local", "~> 1.1", require: false gem "thread-local", "~> 1.1", require: false
# For debugging. # For debugging.
group :development do gem 'web-console', '~> 4.2', group: :development
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2' # TODO: Review our use of content_tag_for etc and uninstall this!
end gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false gem 'bootsnap', '~> 1.16', require: false
@ -90,15 +87,5 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1" gem "shell", "~> 0.8.1"
# For workspace autocomplete. # For workspace autocomplete.
group :development do gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph", "~> 0.50.0" gem "solargraph-rails", "~> 1.1", group: :development
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

@ -7,105 +7,106 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (8.0.2) actioncable (7.1.3.4)
actionpack (= 8.0.2) actionpack (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
actionmailbox (8.0.2) actionmailbox (7.1.3.4)
actionpack (= 8.0.2) actionpack (= 7.1.3.4)
activejob (= 8.0.2) activejob (= 7.1.3.4)
activerecord (= 8.0.2) activerecord (= 7.1.3.4)
activestorage (= 8.0.2) activestorage (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
mail (>= 2.8.0) mail (>= 2.7.1)
actionmailer (8.0.2) net-imap
actionpack (= 8.0.2) net-pop
actionview (= 8.0.2) net-smtp
activejob (= 8.0.2) actionmailer (7.1.3.4)
activesupport (= 8.0.2) actionpack (= 7.1.3.4)
mail (>= 2.8.0) actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
actionpack (8.0.2) actionpack (7.1.3.4)
actionview (= 8.0.2) actionview (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4) rack (>= 2.2.4)
rack-session (>= 1.0.1) rack-session (>= 1.0.1)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
useragent (~> 0.16) actiontext (7.1.3.4)
actiontext (8.0.2) actionpack (= 7.1.3.4)
actionpack (= 8.0.2) activerecord (= 7.1.3.4)
activerecord (= 8.0.2) activestorage (= 7.1.3.4)
activestorage (= 8.0.2) activesupport (= 7.1.3.4)
activesupport (= 8.0.2)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (8.0.2) actionview (7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.11) erubi (~> 1.11)
rails-dom-testing (~> 2.2) rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6) rails-html-sanitizer (~> 1.6)
activejob (8.0.2) activejob (7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (8.0.2) activemodel (7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
activerecord (8.0.2) activerecord (7.1.3.4)
activemodel (= 8.0.2) activemodel (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
timeout (>= 0.4.0) timeout (>= 0.4.0)
activestorage (8.0.2) activestorage (7.1.3.4)
actionpack (= 8.0.2) actionpack (= 7.1.3.4)
activejob (= 8.0.2) activejob (= 7.1.3.4)
activerecord (= 8.0.2) activerecord (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
marcel (~> 1.0) marcel (~> 1.0)
activesupport (8.0.2) activesupport (7.1.3.4)
base64 base64
benchmark (>= 0.3)
bigdecimal bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1) concurrent-ruby (~> 1.0, >= 1.0.2)
connection_pool (>= 2.2.5) connection_pool (>= 2.2.5)
drb drb
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1) minitest (>= 5.1)
securerandom (>= 0.3) mutex_m
tzinfo (~> 2.0, >= 2.0.5) tzinfo (~> 2.0)
uri (>= 0.13.1) addressable (2.8.6)
addressable (2.8.7) public_suffix (>= 2.0.2, < 6.0)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0) aes_key_wrap (1.1.0)
ast (2.4.2) ast (2.4.2)
async (2.21.1) async (2.8.1)
console (~> 1.29) console (~> 1.10)
fiber-annotation fiber-annotation
io-event (~> 1.6, >= 1.6.5) io-event (~> 1.1)
async-container (0.18.3) timers (~> 4.1)
async (~> 2.10) async-container (0.16.13)
async-http (0.86.0)
async (>= 2.10.2)
async-pool (~> 0.9)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
protocol-http (~> 0.43)
protocol-http1 (>= 0.28.1)
protocol-http2 (~> 0.22)
traces (~> 0.10)
async-http-cache (0.4.4)
async-http (~> 0.56)
async-pool (0.10.2)
async (>= 1.25)
traces
async-service (0.12.0)
async async
async-container (~> 0.16) 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.3)
async-http (~> 0.56)
async-io (1.41.0)
async
async-pool (0.4.0)
async (>= 1.25)
attr_required (1.0.2) attr_required (1.0.2)
babel-source (5.8.35) babel-source (5.8.35)
babel-transpiler (0.7.0) babel-transpiler (0.7.0)
@ -114,31 +115,23 @@ GEM
backport (1.2.0) backport (1.2.0)
base64 (0.2.0) base64 (0.2.0)
bcrypt (3.1.20) bcrypt (3.1.20)
benchmark (0.4.0) benchmark (0.3.0)
bigdecimal (3.1.9) bigdecimal (3.1.6)
bindata (2.5.0) bindata (2.5.0)
bindex (0.8.1) bindex (0.8.1)
bootsnap (1.18.4) bootsnap (1.18.3)
msgpack (~> 1.2) msgpack (~> 1.2)
builder (3.3.0) build-environment (1.13.0)
childprocess (5.1.0) builder (3.2.4)
logger (~> 1.5) concurrent-ruby (1.2.3)
concurrent-ruby (1.3.5) connection_pool (2.4.1)
connection_pool (2.5.0) console (1.23.4)
console (1.29.2)
fiber-annotation fiber-annotation
fiber-local (~> 1.1) fiber-local
json json
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6) crass (1.0.6)
csv (3.3.2) date (3.3.4)
date (3.4.1) devise (4.9.3)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4)
bcrypt (~> 3.0) bcrypt (~> 3.0)
orm_adapter (~> 0.1) orm_adapter (~> 0.1)
railties (>= 4.1.0) railties (>= 4.1.0)
@ -151,85 +144,75 @@ GEM
dotenv-rails (2.8.1) dotenv-rails (2.8.1)
dotenv (= 2.8.1) dotenv (= 2.8.1)
railties (>= 3.2) railties (>= 3.2)
drb (2.2.1) drb (2.2.0)
ruby2_keywords
e2mmap (0.1.0) e2mmap (0.1.0)
email_validator (2.2.4) email_validator (2.2.4)
activemodel activemodel
erubi (1.13.1) erubi (1.12.0)
execjs (2.10.0) execjs (2.9.1)
falcon (0.48.4) falcon (0.43.0)
async async
async-container (~> 0.18) async-container (~> 0.16.0)
async-http (~> 0.75) async-http (~> 0.57)
async-http-cache (~> 0.4) async-http-cache (~> 0.4.0)
async-service (~> 0.10) async-io (~> 1.22)
build-environment (~> 1.13)
bundler bundler
localhost (~> 1.1) localhost (~> 1.1)
openssl (~> 3.0) openssl (~> 3.0)
process-metrics (~> 0.2) process-metrics (~> 0.2.0)
protocol-http (~> 0.31) protocol-rack (~> 0.1)
protocol-rack (~> 0.7) samovar (~> 2.1)
samovar (~> 2.3) faraday (2.9.0)
faraday (2.12.2) faraday-net_http (>= 2.0, < 3.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0) faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3) faraday (>= 1, < 3)
faraday-net_http (3.4.0) faraday-net_http (3.1.0)
net-http (>= 0.5.0) net-http
ffi (1.17.1) ffi (1.16.3)
fiber-annotation (0.2.0) fiber-annotation (0.2.0)
fiber-local (1.1.0) fiber-local (1.0.0)
fiber-storage
fiber-storage (1.0.0)
globalid (1.2.1) globalid (1.2.1)
activesupport (>= 6.1) activesupport (>= 6.1)
haml (6.3.0) haml (6.3.0)
temple (>= 0.8.2) temple (>= 0.8.2)
thor thor
tilt tilt
hashdiff (1.1.2)
hashie (5.0.0) hashie (5.0.0)
http_accept_language (2.1.1) http_accept_language (2.1.1)
httparty (0.22.0) httparty (0.21.0)
csv
mini_mime (>= 1.0.0) mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
i18n (1.14.7) i18n (1.14.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
io-console (0.8.0) io-console (0.7.2)
io-endpoint (0.14.0) io-event (1.4.4)
io-event (1.7.5) irb (1.11.2)
io-stream (0.6.1) rdoc
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2) reline (>= 0.4.2)
jaro_winkler (1.6.0) jaro_winkler (1.6.0)
jsbundling-rails (1.3.1) jsbundling-rails (1.3.0)
railties (>= 6.0.0) railties (>= 6.0.0)
json (2.9.1) json (2.7.1)
json-jwt (1.16.7) json-jwt (1.16.6)
activesupport (>= 4.2) activesupport (>= 4.2)
aes_key_wrap aes_key_wrap
base64 base64
bindata bindata
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
kramdown (2.5.1) kramdown (2.4.0)
rexml (>= 3.3.9) rexml
kramdown-parser-gfm (1.1.0) kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0) kramdown (~> 2.0)
language_server-protocol (3.17.0.3) language_server-protocol (3.17.0.3)
launchy (3.0.1) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
childprocess (~> 5.0) letter_opener (1.9.0)
letter_opener (1.10.0) launchy (>= 2.2, < 3)
launchy (>= 2.2, < 4) localhost (1.2.0)
localhost (1.3.1) loofah (2.22.0)
logger (1.7.0)
loofah (2.24.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
mail (2.8.1) mail (2.8.1)
@ -239,41 +222,40 @@ GEM
net-smtp net-smtp
mapping (1.1.1) mapping (1.1.1)
marcel (1.0.4) marcel (1.0.4)
memory_profiler (1.1.0) memory_profiler (1.0.1)
metrics (0.12.1)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.8) mini_portile2 (2.8.5)
minitest (5.25.5) minitest (5.22.2)
msgpack (1.7.5) msgpack (1.7.2)
multi_xml (0.7.1) multi_xml (0.6.0)
bigdecimal (~> 3.1) mutex_m (0.2.0)
mysql2 (0.5.6) mysql2 (0.5.6)
net-http (0.6.0) net-http (0.4.1)
uri uri
net-imap (0.5.6) net-imap (0.4.10)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
net-protocol net-protocol
net-protocol (0.2.2) net-protocol (0.2.2)
timeout timeout
net-smtp (0.5.1) net-smtp (0.4.0.1)
net-protocol net-protocol
nio4r (2.7.4) nio4r (2.7.3)
nokogiri (1.18.6) nokogiri (1.16.2)
mini_portile2 (~> 2.8.2) mini_portile2 (~> 2.8.2)
racc (~> 1.4) racc (~> 1.4)
omniauth (2.1.2) omniauth (2.1.2)
hashie (>= 3.4.6) hashie (>= 3.4.6)
rack (>= 2.2.3) rack (>= 2.2.3)
rack-protection rack-protection
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.1)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
omniauth_openid_connect (0.7.1) omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3) omniauth (>= 1.9, < 3)
openid_connect (~> 2.2) openid_connect (~> 2.2)
openid_connect (2.3.1) openid_connect (2.3.0)
activemodel activemodel
attr_required (>= 1.0.0) attr_required (>= 1.0.0)
email_validator email_validator
@ -286,35 +268,30 @@ GEM
tzinfo tzinfo
validate_url validate_url
webfinger (~> 2.0) webfinger (~> 2.0)
openssl (3.3.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
parallel (1.26.3) parallel (1.24.0)
parser (3.3.6.0) parser (3.3.3.0)
ast (~> 2.4.1) ast (~> 2.4.1)
racc racc
pp (0.6.2) process-metrics (0.2.1)
prettyprint
prettyprint (0.2.0)
process-metrics (0.3.0)
console (~> 1.8) console (~> 1.8)
json (~> 2)
samovar (~> 2.1) samovar (~> 2.1)
protocol-hpack (1.5.1) protocol-hpack (1.4.2)
protocol-http (0.47.1) protocol-http (0.25.0)
protocol-http1 (0.28.1) protocol-http1 (0.16.1)
protocol-http (~> 0.22) protocol-http (~> 0.22)
protocol-http2 (0.22.0) protocol-http2 (0.15.1)
protocol-hpack (~> 1.4) protocol-hpack (~> 1.4)
protocol-http (~> 0.18) protocol-http (~> 0.18)
protocol-rack (0.10.1) protocol-rack (0.4.1)
protocol-http (~> 0.37) protocol-http (~> 0.23)
rack (>= 1.0) rack (>= 1.0)
psych (5.2.3) psych (5.1.2)
date
stringio stringio
public_suffix (6.0.1) public_suffix (5.0.4)
racc (1.8.1) racc (1.7.3)
rack (3.1.12) rack (3.0.9.1)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1) rack-mini-profiler (3.3.1)
@ -326,54 +303,53 @@ GEM
faraday-follow_redirects faraday-follow_redirects
json-jwt (>= 1.11.0) json-jwt (>= 1.11.0)
rack (>= 2.1.0) rack (>= 2.1.0)
rack-protection (4.1.1) rack-protection (4.0.0)
base64 (>= 0.1.0) base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4) rack (>= 3.0.0, < 4)
rack-session (2.1.0) rack-session (2.0.0)
base64 (>= 0.1.0)
rack (>= 3.0.0) rack (>= 3.0.0)
rack-test (2.2.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rackup (2.2.1) rackup (2.1.0)
rack (>= 3) rack (>= 3)
rails (8.0.2) webrick (~> 1.8)
actioncable (= 8.0.2) rails (7.1.3.4)
actionmailbox (= 8.0.2) actioncable (= 7.1.3.4)
actionmailer (= 8.0.2) actionmailbox (= 7.1.3.4)
actionpack (= 8.0.2) actionmailer (= 7.1.3.4)
actiontext (= 8.0.2) actionpack (= 7.1.3.4)
actionview (= 8.0.2) actiontext (= 7.1.3.4)
activejob (= 8.0.2) actionview (= 7.1.3.4)
activemodel (= 8.0.2) activejob (= 7.1.3.4)
activerecord (= 8.0.2) activemodel (= 7.1.3.4)
activestorage (= 8.0.2) activerecord (= 7.1.3.4)
activesupport (= 8.0.2) activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.0.2) railties (= 7.1.3.4)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
nokogiri (>= 1.6) nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) nokogiri (~> 1.14)
rails-i18n (8.0.1) rails-i18n (7.0.8)
i18n (>= 0.7, < 2) i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9) railties (>= 6.0.0, < 8)
railties (8.0.2) railties (7.1.3.4)
actionpack (= 8.0.2) actionpack (= 7.1.3.4)
activesupport (= 8.0.2) activesupport (= 7.1.3.4)
irb (~> 1.13) irb
rackup (>= 1.0.0) rackup (>= 1.0.0)
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0, >= 1.2.2) thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6) zeitwerk (~> 2.6)
rainbow (3.1.1) rainbow (3.1.1)
rake (13.2.1) rake (13.1.0)
rbs (2.8.4) rbs (2.8.4)
rdiscount (2.2.7.3) rdiscount (2.2.7.3)
rdoc (6.13.1) rdoc (6.6.2)
psych (>= 4.0.0) psych (>= 4.0.0)
react-rails (2.7.1) react-rails (2.7.1)
babel-transpiler (>= 0.7.0) babel-transpiler (>= 0.7.0)
@ -381,49 +357,37 @@ GEM
execjs execjs
railties (>= 3.2) railties (>= 3.2)
tilt tilt
regexp_parser (2.10.0) record_tag_helper (1.0.1)
reline (0.6.0) actionview (>= 5)
regexp_parser (2.9.2)
reline (0.4.2)
io-console (~> 0.5) io-console (~> 0.5)
responders (3.1.1) responders (3.1.1)
actionpack (>= 5.2) actionpack (>= 5.2)
railties (>= 5.2) railties (>= 5.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.4.0) rexml (3.3.1)
rspec-core (3.13.2) strscan
rspec-support (~> 3.13.0) rubocop (1.64.1)
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.1.0)
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.2)
rubocop (1.70.0)
json (~> 2.3) json (~> 2.3)
language_server-protocol (>= 3.17.0) language_server-protocol (>= 3.17.0)
parallel (~> 1.10) parallel (~> 1.10)
parser (>= 3.3.0.2) parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0) rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0) regexp_parser (>= 1.8, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0) rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7) ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0) unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.37.0) rubocop-ast (1.31.3)
parser (>= 3.3.1.0) parser (>= 3.3.1.0)
ruby-progressbar (1.13.0) ruby-progressbar (1.13.0)
samovar (2.3.0) ruby2_keywords (0.0.5)
samovar (2.2.0)
console (~> 1.0) console (~> 1.0)
mapping (~> 1.0) mapping (~> 1.0)
sanitize (6.1.3) sanitize (6.1.0)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.12.0) nokogiri (>= 1.12.0)
sass-rails (6.0.0) sass-rails (6.0.0)
@ -436,12 +400,10 @@ GEM
sprockets (> 3.0) sprockets (> 3.0)
sprockets-rails sprockets-rails
tilt tilt
securerandom (0.4.1) sentry-rails (5.16.1)
sentry-rails (5.22.1)
railties (>= 5.0) railties (>= 5.0)
sentry-ruby (~> 5.22.1) sentry-ruby (~> 5.16.1)
sentry-ruby (5.22.1) sentry-ruby (5.16.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
shell (0.8.1) shell (0.8.1)
e2mmap e2mmap
@ -468,12 +430,13 @@ GEM
sprockets (4.2.1) sprockets (4.2.1)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4) rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2) sprockets-rails (3.4.2)
actionpack (>= 6.1) actionpack (>= 5.2)
activesupport (>= 6.1) activesupport (>= 5.2)
sprockets (>= 3.0.0) sprockets (>= 3.0.0)
stackprof (0.2.26) stackprof (0.2.26)
stringio (3.1.6) stringio (3.1.0)
strscan (3.1.0)
swd (2.0.3) swd (2.0.3)
activesupport (>= 3) activesupport (>= 3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
@ -481,23 +444,22 @@ GEM
faraday-follow_redirects faraday-follow_redirects
sync (0.5.0) sync (0.5.0)
temple (0.10.3) temple (0.10.3)
terser (1.2.4) terser (1.2.0)
execjs (>= 0.3.0, < 3) execjs (>= 0.3.0, < 3)
thor (1.3.2) thor (1.3.0)
thread-local (1.1.0) thread-local (1.1.0)
tilt (2.5.0) tilt (2.3.0)
timeout (0.4.3) timeout (0.4.1)
traces (0.14.1) timers (4.3.5)
turbo-rails (2.0.11) traces (0.11.1)
turbo-rails (2.0.5)
actionpack (>= 6.0.0) actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0) railties (>= 6.0.0)
tzinfo (2.0.6) tzinfo (2.0.6)
concurrent-ruby (~> 1.0) concurrent-ruby (~> 1.0)
unicode-display_width (3.1.3) unicode-display_width (2.5.0)
unicode-emoji (~> 4.0, >= 4.0.4) uri (0.13.0)
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15) validate_url (1.0.15)
activemodel (>= 3.0.0) activemodel (>= 3.0.0)
public_suffix public_suffix
@ -512,17 +474,13 @@ GEM
activesupport activesupport
faraday (~> 2.0) faraday (~> 2.0)
faraday-follow_redirects faraday-follow_redirects
webmock (3.24.0) webrick (1.8.1)
addressable (>= 2.8.0) websocket-driver (0.7.6)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0) websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5) websocket-extensions (0.1.5)
will_paginate (4.0.1) will_paginate (4.0.0)
yard (0.9.37) yard (0.9.36)
zeitwerk (2.7.2) zeitwerk (2.6.13)
PLATFORMS PLATFORMS
ruby ruby
@ -530,18 +488,17 @@ PLATFORMS
DEPENDENCIES DEPENDENCIES
RocketAMF! RocketAMF!
addressable (~> 2.8) addressable (~> 2.8)
async (~> 2.17) async (~> 2.6)
async-http (~> 0.86.0) async-http (~> 0.61.0)
bootsnap (~> 1.16) bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2) devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0) devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1) dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0) falcon (~> 0.43.0)
haml (~> 6.1, >= 6.1.1) haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1) http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.22.0) httparty (~> 0.21.0)
jsbundling-rails (~> 1.3) jsbundling-rails (~> 1.1)
letter_opener (~> 1.8, >= 1.8.1) letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0) memory_profiler (~> 1.0)
mysql2 (~> 0.5.5) mysql2 (~> 0.5.5)
@ -550,14 +507,13 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0) omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.7.1) omniauth_openid_connect (~> 0.7.1)
parallel (~> 1.23) parallel (~> 1.23)
protocol-rack (~> 0.10.0, < 0.11.0)
rack-attack (~> 6.7) rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1) rack-mini-profiler (~> 3.1)
rails (~> 8.0, >= 8.0.1) rails (~> 7.1, >= 7.1.3.4)
rails-i18n (~> 8.0, >= 8.0.1) rails-i18n (~> 7.0, >= 7.0.7)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
react-rails (~> 2.7, >= 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) sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0) sass-rails (~> 6.0)
sentry-rails (~> 5.12) sentry-rails (~> 5.12)
@ -571,11 +527,10 @@ DEPENDENCIES
thread-local (~> 1.1) thread-local (~> 1.1)
turbo-rails (~> 2.0) turbo-rails (~> 2.0)
web-console (~> 4.2) web-console (~> 4.2)
webmock (~> 3.24)
will_paginate (~> 4.0) will_paginate (~> 4.0)
RUBY VERSION RUBY VERSION
ruby 3.3.7p123 ruby 3.3.0p0
BUNDLED WITH BUNDLED WITH
2.5.18 2.5.5

View file

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

Binary file not shown.

Binary file not shown.

Before

(image error) Size: 7.9 KiB

Binary file not shown.

Before

(image error) Size: 23 KiB

Binary file not shown.

After

(image error) Size: 172 KiB

Binary file not shown.

After

(image error) Size: 8.9 KiB

Binary file not shown.

After

(image error) Size: 22 KiB

Binary file not shown.

After

(image error) Size: 1.9 KiB

Binary file not shown.

After

(image error) Size: 585 B

Binary file not shown.

After

(image error) Size: 601 B

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

Binary file not shown.

After

(image error) Size: 206 B

Binary file not shown.

After

(image error) Size: 516 B

Binary file not shown.

After

(image error) Size: 127 KiB

Binary file not shown.

After

(image error) Size: 1.7 KiB

Binary file not shown.

After

(image error) Size: 1.7 KiB

Binary file not shown.

After

(image error) Size: 16 KiB

Binary file not shown.

After

(image error) Size: 10 KiB

Binary file not shown.

After

(image error) Size: 5.1 KiB

Binary file not shown.

After

(image error) Size: 38 KiB

Binary file not shown.

Before

(image error) Size: 2.8 KiB

View file

@ -0,0 +1,20 @@
(function () {
var CSRFProtection;
var token = $('meta[name="csrf-token"]').attr("content");
if (token) {
CSRFProtection = function (xhr, settings) {
var sendToken =
typeof settings.useCSRFProtection === "undefined" || // default to true
settings.useCSRFProtection;
if (sendToken) {
xhr.setRequestHeader("X-CSRF-Token", token);
}
};
} else {
CSRFProtection = $.noop;
}
$.ajaxSetup({
beforeSend: CSRFProtection,
});
})();

File diff suppressed because it is too large Load diff

View file

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

View file

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

View file

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

View file

@ -1,115 +0,0 @@
// When the species face picker changes, update and submit the main picker form.
document.addEventListener("change", (e) => {
if (!e.target.matches("species-face-picker")) return;
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form",
);
const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']",
);
mainSpeciesField.value = e.target.value;
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
} catch (error) {
console.error("Couldn't update species picker: ", error);
}
});
// If the preview frame fails to load, try a full pageload.
document.addEventListener("turbo:frame-missing", (e) => {
if (!e.target.matches("#item-preview")) return;
e.detail.visit(e.detail.response.url);
e.preventDefault();
});
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
get value() {
return this.querySelector("input[type=radio]:checked")?.value;
}
#handleClick(e) {
if (e.target.matches("input[type=radio]")) {
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
}
class SpeciesFacePickerOptions extends HTMLElement {
static observedAttributes = ["inert", "aria-hidden"];
connectedCallback() {
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
this.#activate();
}
attributeChangedCallback() {
// If a Turbo Frame tries to morph us into being inert again, activate again!
// (It's important that the server's HTML always return `inert`, for progressive
// enhancement; and it's important to morph this element, so radio focus state
// is preserved. To thread that needle, we have to monitor and remove!)
this.#activate();
}
#activate() {
this.removeAttribute("inert");
this.removeAttribute("aria-hidden");
}
}
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
class MeasuredContainer extends HTMLElement {
static observedAttributes = ["style"];
connectedCallback() {
setTimeout(() => this.#measure(), 0);
}
attributeChangedCallback() {
// When `--natural-width` gets morphed away by Turbo, measure it again!
if (this.style.getPropertyValue("--natural-width") === "") {
this.#measure();
}
}
#measure() {
// Find our `<measured-content>` child, and set our natural width as
// `var(--natural-width)` in the context of our CSS styles.
const content = this.querySelector("measured-content");
if (content == null) {
throw new Error(`<measured-container> must contain a <measured-content>`);
}
this.style.setProperty("--natural-width", content.offsetWidth + "px");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

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

View file

@ -3,12 +3,10 @@ class OutfitViewer extends HTMLElement {
constructor() { constructor() {
super(); super();
this.#internals = this.attachInternals(); // for CSS `:state()` this.#internals = this.attachInternals();
} }
connectedCallback() { connectedCallback() {
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0); setTimeout(() => this.#connectToChildren(), 0);
} }
@ -69,8 +67,6 @@ class OutfitLayer extends HTMLElement {
} }
disconnectedCallback() { disconnectedCallback() {
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
// messages, if we were.
window.removeEventListener("message", this.#onMessage); window.removeEventListener("message", this.#onMessage);
} }
@ -87,36 +83,33 @@ class OutfitLayer extends HTMLElement {
const iframe = this.querySelector("iframe"); const iframe = this.querySelector("iframe");
if (image) { if (image) {
// If this is an image layer, track its loading state by listening // Initialize status based on the image's current `complete` attribute,
// to the load/error events, and initialize based on whether it's // then wait for load/error events to update it further if needed.
// already `complete` (which it can be if it loaded from cache).
this.#setStatus(image.complete ? "loaded" : "loading"); this.#setStatus(image.complete ? "loaded" : "loading");
image.addEventListener("load", () => this.#setStatus("loaded")); image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error")); image.addEventListener("error", () => this.#setStatus("error"));
} else if (iframe) { } else if (iframe) {
this.iframe = iframe; this.iframe = iframe;
// Initialize status to `loading`, and asynchronously request a // Initialize status to `loading`, and asynchronously request a status
// status message from the iframe if it managed to load before this // message from the iframe if it managed to load before this triggers
// triggers (impressive, but I think I've seen it happen!). Then, // (impressive, but I think I've seen it happen!). Then, wait for
// wait for messages or error events from the iframe to update // messages or error events from the iframe to update status further if
// status further if needed. // needed.
this.#setStatus("loading"); this.#setStatus("loading");
this.#sendMessageToIframe({ type: "requestStatus" }); this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m)); window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error")); this.iframe.addEventListener("error", () => this.#setStatus("error"));
} else { } else {
console.warn(`<outfit-layer> contained no image or iframe: `, this); throw new Error(`<outfit-layer> must contain an <img> or <iframe> tag`);
} }
} }
#onMessage({ source, data }) { #onMessage({ source, data }) {
// Ignore messages that aren't from *our* frame.
if (source !== this.iframe.contentWindow) { if (source !== this.iframe.contentWindow) {
return; return;
} }
// Validate the incoming status message, then set our status to match.
if (data.type === "status") { if (data.type === "status") {
if (data.status === "loaded") { if (data.status === "loaded") {
this.#setStatus("loaded"); this.#setStatus("loaded");
@ -125,21 +118,16 @@ class OutfitLayer extends HTMLElement {
this.#setStatus("error"); this.#setStatus("error");
} else { } else {
throw new Error( throw new Error(
`<outfit-layer> got unexpected status: ` + `<outfit-layer> got unexpected status: ${JSON.stringify(data.status)}`,
JSON.stringify(data.status),
); );
} }
} else { } else {
throw new Error( throw new Error(
`<outfit-layer> got unexpected message: ` + JSON.stringify(data), `<outfit-layer> got unexpected message: ${JSON.stringify(data)}`,
); );
} }
} }
/**
* Set the status value that the CSS `:state()` selector will match.
* For example, when loading, `:state(loading)` matches this element.
*/
#setStatus(newStatus) { #setStatus(newStatus) {
this.#internals.states.delete("loading"); this.#internals.states.delete("loading");
this.#internals.states.delete("loaded"); this.#internals.states.delete("loaded");
@ -147,9 +135,6 @@ class OutfitLayer extends HTMLElement {
this.#internals.states.add(newStatus); this.#internals.states.add(newStatus);
} }
/**
* Set whether CSS selector `:state(has-animations)` matches this element.
*/
#setHasAnimations(hasAnimations) { #setHasAnimations(hasAnimations) {
if (hasAnimations) { if (hasAnimations) {
this.#internals.states.add("has-animations"); this.#internals.states.add("has-animations");
@ -159,16 +144,7 @@ class OutfitLayer extends HTMLElement {
} }
#sendMessageToIframe(message) { #sendMessageToIframe(message) {
// If we have no frame or it hasn't loaded, ignore this message. if (this.iframe?.contentWindow == null) {
if (this.iframe == null) {
return;
}
if (this.iframe.contentWindow == null) {
console.debug(
`Ignoring message, frame not loaded yet: `,
this.iframe,
message,
);
return; return;
} }
@ -185,30 +161,27 @@ customElements.define("outfit-layer", OutfitLayer);
// aggressively reusing existing <outfit-layer> nodes for entirely different // aggressively reusing existing <outfit-layer> nodes for entirely different
// assets. (It's a lot clearer for managing the loading state, and not showing // assets. (It's a lot clearer for managing the loading state, and not showing
// old incorrect layers!) (We also tried using `id` to enforce this… no luck.) // old incorrect layers!) (We also tried using `id` to enforce this… no luck.)
function morphWithOutfitLayers(currentElement, newElement) {
Idiomorph.morph(currentElement, newElement.innerHTML, {
morphStyle: "innerHTML",
callbacks: {
beforeNodeMorphed: (currentNode, newNode) => {
// If Idiomorph wants to transform an <outfit-layer> to
// have a different data-asset-id attribute, we replace
// the node ourselves and abort the morph.
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
}
},
},
});
}
addEventListener("turbo:before-frame-render", (event) => { addEventListener("turbo:before-frame-render", (event) => {
// Rather than enforce Idiomorph must be loaded, let's just be resilient
// and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") { if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b); event.detail.render = (currentElement, newElement) => {
Idiomorph.morph(currentElement, newElement.innerHTML, {
morphStyle: "innerHTML",
callbacks: {
beforeNodeMorphed: (currentNode, newNode) => {
// If Idiomorph wants to transform an <outfit-layer> to
// have a different data-asset-id attribute, we replace
// the node ourselves and abort the morph.
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
}
},
},
});
};
} }
}); });

View file

@ -1,253 +1,272 @@
(function () { (function () {
function petImage(id, size) { function petImage(id, size) {
return "https://pets.neopets.com/" + id + "/1/" + size + ".png"; return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
} }
var PetQuery = {}, var PetQuery = {},
query_string = document.location.hash || document.location.search; query_string = document.location.hash || document.location.search;
for (const [key, value] of new URLSearchParams(query_string).entries()) { $.each(query_string.substr(1).split("&"), function () {
PetQuery[key] = value; var split_piece = this.split("=");
} if (split_piece.length == 2) {
PetQuery[split_piece[0]] = split_piece[1];
}
});
if (PetQuery.name) { if (PetQuery.name) {
if (PetQuery.species && PetQuery.color) { if (PetQuery.species && PetQuery.color) {
var image_url = petImage("cpn/" + PetQuery.name, 1); $("#pet-query-notice-template")
if (PetQuery.name.startsWith("@")) { .tmpl({
image_url = petImage("cp/" + PetQuery.name.substr(1), 1); pet_name: PetQuery.name,
} pet_image_url: petImage("cpn/" + PetQuery.name, 1),
$("#pet-query-notice-template") })
.tmpl({ .prependTo("#container");
pet_name: PetQuery.name, }
pet_image_url: image_url, }
})
.prependTo("#container");
}
}
var preview_el = $("#pet-preview"), var preview_el = $("#pet-preview"),
img_el = preview_el.find("img"), img_el = preview_el.find("img"),
response_el = preview_el.find("span"); response_el = preview_el.find("span");
var defaultPreviewUrl = img_el.attr("src"); var defaultPreviewUrl = img_el.attr("src");
preview_el.click(function () { preview_el.click(function () {
Preview.Job.current.visit(); Preview.Job.current.visit();
}); });
var Preview = { var Preview = {
clear: function () { clear: function () {
if (typeof Preview.Job.fallback != "undefined") if (typeof Preview.Job.fallback != "undefined")
Preview.Job.fallback.setAsCurrent(); Preview.Job.fallback.setAsCurrent();
}, },
displayLoading: function () { displayLoading: function () {
preview_el.addClass("loading"); preview_el.addClass("loading");
response_el.text("Loading..."); response_el.text("Loading...");
}, },
failed: function () { failed: function () {
preview_el.addClass("hidden"); preview_el.addClass("hidden");
}, },
notFound: function (key, options) { notFound: function (key, options) {
Preview.failed(); Preview.failed();
response_el.empty(); response_el.empty();
$("#preview-" + key + "-template") $("#preview-" + key + "-template")
.tmpl(options) .tmpl(options)
.appendTo(response_el); .appendTo(response_el);
}, },
updateWithName: function (name_el) { updateWithName: function (name_el) {
var name = name_el.val(), var name = name_el.val(),
job; job;
if (name) { if (name) {
currentName = name; currentName = name;
if (!Preview.Job.current || name != Preview.Job.current.name) { if (!Preview.Job.current || name != Preview.Job.current.name) {
job = new Preview.Job.Name(name); job = new Preview.Job.Name(name);
job.setAsCurrent(); job.setAsCurrent();
Preview.displayLoading(); Preview.displayLoading();
} }
} else { } else {
Preview.clear(); Preview.clear();
} }
}, },
}; };
function loadFeature() { function loadNotable() {
$.getJSON("/donations/features", function (features) { // TODO: add HTTPS to notables
if (features.length > 0) { // $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
var feature = features[Math.floor(Math.random() * features.length)]; // var notables = response.notables;
Preview.Job.fallback = new Preview.Job.Feature(feature); // var i = Math.floor(Math.random() * notables.length);
if (!Preview.Job.current) { // Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
Preview.Job.fallback.setAsCurrent(); // if(!Preview.Job.current) {
} // Preview.Job.fallback.setAsCurrent();
} // }
}); // });
} if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
}
loadFeature(); function loadFeature() {
$.getJSON("/donations/features", function (features) {
if (features.length > 0) {
var feature = features[Math.floor(Math.random() * features.length)];
Preview.Job.fallback = new Preview.Job.Feature(feature);
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
} else {
loadNotable();
}
});
}
Preview.Job = function (key, base) { loadFeature();
var job = this,
quality = 2;
job.loading = false;
function getImageSrc() { Preview.Job = function (key, base) {
if (base === "cp" || base === "cpn") { var job = this,
return petImage(base + "/" + key, quality); quality = 2;
} else if (base === "url") { job.loading = false;
return key;
} else {
throw new Error("unrecognized image base " + base);
}
}
function load() { function getImageSrc() {
job.loading = true; if (key.substr(0, 3) === "a:-") {
img_el.attr("src", getImageSrc()); // lol lazy code for prank image :P
} // TODO: HTTPS?
return (
"https://swfimages.impress.openneo.net" +
"/biology/000/000/0-2/" +
key.substr(2) +
"/300x300.png"
);
} else if (base === "cp" || base === "cpn") {
return petImage(base + "/" + key, quality);
} else if (base === "url") {
return key;
} else {
throw new Error("unrecognized image base " + base);
}
}
this.increaseQualityIfPossible = function () { function load() {
if (quality == 2) { job.loading = true;
quality = 4; img_el.attr("src", getImageSrc());
load(); }
}
};
this.setAsCurrent = function () { this.increaseQualityIfPossible = function () {
Preview.Job.current = job; if (quality == 2) {
load(); quality = 4;
}; load();
}
};
this.notFound = function () { this.setAsCurrent = function () {
Preview.notFound("pet-not-found"); Preview.Job.current = job;
}; load();
}; };
Preview.Job.Name = function (name) { this.notFound = function () {
this.name = name; Preview.notFound("pet-not-found");
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 () { Preview.Job.Name = function (name) {
$(".main-pet-name").val(this.name).closest("form").submit(); this.name = name;
}; Preview.Job.apply(this, [name, "cpn"]);
};
Preview.Job.Hash = function (hash, form) { this.visit = function () {
Preview.Job.apply(this, [hash, "cp"]); $(".main-pet-name").val(this.name).closest("form").submit();
};
};
this.visit = function () { Preview.Job.Hash = function (hash, form) {
window.location = Preview.Job.apply(this, [hash, "cp"]);
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
Preview.Job.Feature = function (feature) { this.visit = function () {
Preview.Job.apply(this, [feature.outfit_image_url, "url"]); window.location =
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n "/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
this.visit = function () { Preview.Job.Feature = function (feature) {
window.location = "/donate"; Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
}; this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
this.notFound = function () { this.visit = function () {
// The outfit thumbnail hasn't generated or is missing or something. window.location = "/donate";
// Let's fall back to a boring image for now. };
var boring = new Preview.Job.Feature({
donor_name: feature.donor_name,
outfit_image_url: defaultPreviewUrl,
});
boring.setAsCurrent();
};
};
$(function () { this.notFound = function () {
var previewWithNameTimeout; // The outfit thumbnail hasn't generated or is missing or something.
// Let's fall back to a boring image for now.
var boring = new Preview.Job.Feature({
donor_name: feature.donor_name,
outfit_image_url: defaultPreviewUrl,
});
boring.setAsCurrent();
};
};
var name_el = $(".main-pet-name"); $(function () {
name_el.val(PetQuery.name); var previewWithNameTimeout;
Preview.updateWithName(name_el);
name_el.keyup(function () { var name_el = $(".main-pet-name");
if (previewWithNameTimeout && Preview.Job.current) { name_el.val(PetQuery.name);
clearTimeout(previewWithNameTimeout); Preview.updateWithName(name_el);
Preview.Job.current.loading = false;
}
var name_el = $(this);
previewWithNameTimeout = setTimeout(function () {
Preview.updateWithName(name_el);
}, 250);
});
img_el name_el.keyup(function () {
.load(function () { if (previewWithNameTimeout && Preview.Job.current) {
if (Preview.Job.current.loading) { clearTimeout(previewWithNameTimeout);
Preview.Job.loading = false; Preview.Job.current.loading = false;
Preview.Job.current.increaseQualityIfPossible(); }
preview_el var name_el = $(this);
.removeClass("loading") previewWithNameTimeout = setTimeout(function () {
.removeClass("hidden") Preview.updateWithName(name_el);
.addClass("loaded"); }, 250);
response_el.text(Preview.Job.current.name); });
}
})
.error(function () {
if (Preview.Job.current.loading) {
Preview.Job.loading = false;
Preview.Job.current.notFound();
}
});
$(".species, .color").change(function () { img_el
var type = {}, .load(function () {
nameComponents = {}; if (Preview.Job.current.loading) {
var form = $(this).closest("form"); Preview.Job.loading = false;
form.find("select").each(function () { Preview.Job.current.increaseQualityIfPossible();
var el = $(this), preview_el
selectedEl = el.children(":selected"), .removeClass("loading")
key = el.attr("name"); .removeClass("hidden")
type[key] = selectedEl.val(); .addClass("loaded");
nameComponents[key] = selectedEl.text(); response_el.text(Preview.Job.current.name);
}); }
name = nameComponents.color + " " + nameComponents.species; })
Preview.displayLoading(); .error(function () {
$.ajax({ if (Preview.Job.current.loading) {
url: Preview.Job.loading = false;
"/species/" + Preview.Job.current.notFound();
type.species + }
"/colors/" + });
type.color +
"/pet_type.json",
dataType: "json",
success: function (data) {
var job;
if (data) {
job = new Preview.Job.Hash(data.image_hash, form);
job.name = name;
job.setAsCurrent();
} else {
Preview.notFound("pet-type-not-found", {
color_name: nameComponents.color,
species_name: nameComponents.species,
});
}
},
});
});
$(".load-pet-to-wardrobe").submit(function (e) { $(".species, .color").change(function () {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) { var type = {},
e.preventDefault(); nameComponents = {};
Preview.Job.current.visit(); var form = $(this).closest("form");
} form.find("select").each(function () {
}); var el = $(this),
}); selectedEl = el.children(":selected"),
key = el.attr("name");
type[key] = selectedEl.val();
nameComponents[key] = selectedEl.text();
});
name = nameComponents.color + " " + nameComponents.species;
Preview.displayLoading();
$.ajax({
url:
"/species/" +
type.species +
"/colors/" +
type.color +
"/pet_type.json",
dataType: "json",
success: function (data) {
var job;
if (data) {
job = new Preview.Job.Hash(data.image_hash, form);
job.name = name;
job.setAsCurrent();
} else {
Preview.notFound("pet-type-not-found", {
color_name: nameComponents.color,
species_name: nameComponents.species,
});
}
},
});
});
$("#latest-contribution-created-at").timeago(); $(".load-pet-to-wardrobe").submit(function (e) {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
e.preventDefault();
Preview.Job.current.visit();
}
});
});
$("#latest-contribution-created-at").timeago();
})(); })();

View file

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

View file

@ -1,110 +1,203 @@
var DEBUG = document.location.search.substr(0, 6) == "?debug";
/* 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);
}
function petThumbnailUrl(pet_name) {
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
/* 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 */ /* Bulk pets form */
(function () { (function () {
var form = $("#bulk-pets-form"), var form = $("#bulk-pets-form"),
queue_el = form.find("ul"), queue_el = form.find("ul"),
names_el = form.find("textarea"), names_el = form.find("textarea"),
add_el = $("#bulk-pets-form-add"), add_el = $("#bulk-pets-form-add"),
clear_el = $("#bulk-pets-form-clear"), clear_el = $("#bulk-pets-form-clear"),
bulk_load_queue; bulk_load_queue;
$(document.body).addClass("js"); $(document.body).addClass("js");
function petThumbnailUrl(pet_name) { bulk_load_queue = new (function BulkLoadQueue() {
// if first character is "@", use the hash url var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
if (pet_name[0] == "@") { var RECENTLY_SENT_MAX = 3;
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png"; var pets = [],
} url = form.attr("action") + ".json",
recently_sent_count = 0,
loading = false;
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png"; function Pet(name) {
} var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name })
.appendTo(queue_el);
bulk_load_queue = new (function BulkLoadQueue() { this.load = function () {
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30; el.removeClass("waiting").addClass("loading");
var RECENTLY_SENT_MAX = 3; var response_el = el.find("span.response");
var pets = [], pets.shift();
url = form.attr("action") + ".json", loading = true;
recently_sent_count = 0, $.ajax({
loading = false; complete: function (data) {
loading = false;
loadNextIfReady();
},
data: { name: name },
dataType: "json",
error: function (xhr) {
el.removeClass("loading").addClass("failed");
response_el.text(xhr.responseText);
},
success: function (data) {
var points = data.points;
el.removeClass("loading").addClass("loaded");
$("#bulk-pets-submission-success-template")
.tmpl({ points: points })
.appendTo(response_el);
},
type: "post",
url: url,
});
function Pet(name) { recently_sent_count++;
var el = $("#bulk-pets-submission-template") setTimeout(function () {
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) }) recently_sent_count--;
.appendTo(queue_el); loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
this.load = function () { this.add = function (name) {
el.removeClass("waiting").addClass("loading"); name = name.replace(/^\s+|\s+$/g, "");
var response_el = el.find("span.response"); if (name.length) {
pets.shift(); var pet = new Pet(name);
loading = true; pets.push(pet);
$.ajax({ if (pets.length == 1) loadNextIfReady();
beforeSend: (xhr) => { }
const token = document };
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
},
complete: function (data) {
loading = false;
loadNextIfReady();
},
data: { name: name },
dataType: "json",
error: function (xhr) {
el.removeClass("loading").addClass("failed");
response_el.text(xhr.responseText);
},
success: function (data) {
var points = data.points;
el.removeClass("loading").addClass("loaded");
$("#bulk-pets-submission-success-template")
.tmpl({ points: points })
.appendTo(response_el);
},
type: "post",
url: url,
});
recently_sent_count++; function loadNextIfReady() {
setTimeout(function () { if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
recently_sent_count--; pets[0].load();
loadNextIfReady(); }
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000); }
}; })();
}
this.add = function (name) { names_el.keyup(function () {
name = name.replace(/^\s+|\s+$/g, ""); var names = this.value.split("\n"),
if (name.length) { x = names.length - 1,
var pet = new Pet(name); i,
pets.push(pet); name;
if (pets.length == 1) loadNextIfReady(); for (i = 0; i < x; i++) {
} bulk_load_queue.add(names[i]);
}; }
this.value = x >= 0 ? names[x] : "";
});
function loadNextIfReady() { add_el.click(function () {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) { bulk_load_queue.add(names_el.val());
pets[0].load(); names_el.val("");
} });
}
})();
names_el.keyup(function () { clear_el.click(function () {
var names = this.value.split("\n"), queue_el.children("li.loaded, li.failed").remove();
x = names.length - 1, });
i,
name;
for (i = 0; i < x; i++) {
bulk_load_queue.add(names[i]);
}
this.value = x >= 0 ? names[x] : "";
});
add_el.click(function () {
bulk_load_queue.add(names_el.val());
names_el.val("");
});
clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove();
});
})(); })();

View file

@ -25,10 +25,6 @@ let numFramesSinceLastLog = 0;
// State for error reporting. // State for error reporting.
let hasLoggedRenderError = false; let hasLoggedRenderError = false;
////////////////////////////////////////////////////
//////// Loading the library and its assets ////////
////////////////////////////////////////////////////
function loadImage(src) { function loadImage(src) {
const image = new Image(); const image = new Image();
image.crossOrigin = "anonymous"; image.crossOrigin = "anonymous";
@ -68,8 +64,8 @@ async function getLibrary() {
// One more loading step as part of loading this library is loading the // One more loading step as part of loading this library is loading the
// images it uses for sprites. // images it uses for sprites.
// //
// NOTE: We also read these from the manifest, and include them in the // TODO: I guess the manifest has these too, so we could put them in preload
// document as preload meta tags, to get them moving faster. // meta tags to get them here faster?
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/"); const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map( const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [ library.properties.manifest.map(({ id, src }) => [
@ -100,10 +96,6 @@ async function getLibrary() {
return library; return library;
} }
/////////////////////////////////////
//////// Rendering the movie ////////
/////////////////////////////////////
function buildMovieClip(library) { function buildMovieClip(library) {
let constructorName; let constructorName;
try { try {
@ -159,22 +151,6 @@ function updateCanvasDimensions() {
movieClip.scaleY = internalHeight / library.properties.height; movieClip.scaleY = internalHeight / library.properties.height;
} }
window.addEventListener("resize", () => {
updateCanvasDimensions();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
////////////////////////////////////////////////////
//// Monitoring and controlling animation state ////
////////////////////////////////////////////////////
async function startMovie() { async function startMovie() {
// Load the movie's library (from the JS file already run), and use it to // Load the movie's library (from the JS file already run), and use it to
// build a movie clip. // build a movie clip.
@ -298,10 +274,6 @@ function getInitialPlayingStatus() {
} }
} }
//////////////////////////////////////////
//// Syncing with the parent document ////
//////////////////////////////////////////
/** /**
* Recursively scans the given MovieClip (or child createjs node), to see if * Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas. * there are any animated areas.
@ -340,6 +312,18 @@ function sendMessage(message) {
parent.postMessage(message, document.location.origin); parent.postMessage(message, document.location.origin);
} }
window.addEventListener("resize", () => {
updateCanvasDimensions();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
window.addEventListener("message", ({ data }) => { window.addEventListener("message", ({ data }) => {
// NOTE: For more sensitive messages, it's important for security to also // NOTE: For more sensitive messages, it's important for security to also
// check the `origin` property of the incoming event. But in this case, I'm // check the `origin` property of the incoming event. But in this case, I'm
@ -355,10 +339,6 @@ window.addEventListener("message", ({ data }) => {
} }
}); });
/////////////////////////////////
//// The actual entry point! ////
/////////////////////////////////
startMovie() startMovie()
.then(() => { .then(() => {
sendStatus(); sendStatus();

View file

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

View file

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

View file

@ -1,22 +0,0 @@
body.use-responsive-design
#container
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
#userbar
margin-left: auto
text-align: right

View file

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

View file

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

View file

@ -1,13 +0,0 @@
@import "../partials/clean/constants"
// Prefer to break the name at visually appealing points.
.rainbow-pool-list
.name
text-wrap: balance
// De-emphasize Prismatic styles, in browsers that support it.
.rainbow-pool-filters
select[name="series"]
option[value*=": "]
color: $soft-text-color
font-style: italic

View file

@ -4,14 +4,19 @@
@import partials/clean/mixins @import partials/clean/mixins
@import layout @import layout
@import responsive
@import partials/jquery.jgrowl @import partials/jquery.jgrowl
@import alt_styles/index
@import closet_hangers/index @import closet_hangers/index
@import closet_hangers/petpage
@import closet_lists/form @import closet_lists/form
@import neopets_page_import_tasks/new @import neopets_page_import_tasks/new
@import contributions/index @import contributions/index
@import items
@import items/index
@import items/show
@import item_trades/index
@import outfits/index @import outfits/index
@import outfits/new @import outfits/new
@import pets/bulk @import pets/bulk

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,14 +31,11 @@ body.closet_hangers-index
color: $soft-text-color color: $soft-text-color
margin-bottom: 1em margin-bottom: 1em
margin-left: 2em margin-left: 2em
min-height: $icon-height min-height: image-height("neomail.png")
display: flex
gap: .5em
align-items: center
a a
color: inherit color: inherit
margin-right: .5em
text-decoration: none text-decoration: none
&:hover &:hover
text-decoration: underline text-decoration: underline
@ -47,14 +44,13 @@ body.closet_hangers-index
background: background:
position: left center position: left center
repeat: no-repeat repeat: no-repeat
padding-left: image-width("neomail.png") + 4px
a.neomail, > form a.neomail, > form
background-image: image-url("neomail.png") background-image: image-url("neomail.png")
padding-left: $icon-width + 4px
a.lookup a.lookup
background-image: image-url("lookup.png") background-image: image-url("lookup.png")
padding-left: $icon-width + 4px
select select
width: 10em width: 10em

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,162 @@
@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 .75em
// 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
&[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color

View file

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

View file

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

View file

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

View file

@ -7,8 +7,9 @@ body.outfits-new
#pet-not-found #pet-not-found
display: none display: none
.announcement .neopass-announcement
border: 1px solid $module-border-color border: 1px solid #cd8400
color: #764a00
padding: .5em padding: .5em
display: grid display: grid
grid-template-areas: "thumbnail content" grid-template-areas: "thumbnail content"
@ -23,6 +24,9 @@ body.outfits-new
p:last-of-type p:last-of-type
margin-bottom: 0 margin-bottom: 0
a
color: #be7a00
#outfit-forms #outfit-forms
+clearfix +clearfix
+module +module
@ -78,57 +82,85 @@ body.outfits-new
font-size: 175% font-size: 175%
select select
font-size: 120% 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 #sections
display: grid +clearfix
grid-template-columns: 1fr 1fr 1fr display: table
list-style: none list-style: none
margin-top: 1em 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 h3
grid-area: header margin-bottom: .25em
margin-bottom: 0 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 div
grid-area: info
color: $soft-text-color color: $soft-text-color
font-size: 75% font-size: 75%
margin-left: 1em margin-left: 1em
z-index: 2 z-index: 2
strong h4, input
font-size: 116% font-size: 116%
a:has(img) h4, input[type=text]
grid-area: image color: inherit
h4 a
background: #ffffc0
img img
opacity: 0.75 +opacity(0.75)
float: right float: right
margin-left: .5em margin-left: .5em
&:hover &:hover
opacity: 1 +opacity(1)
p p
line-height: 1.5
min-height: 4.5em 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 #whats-new
margin-bottom: 1em margin-bottom: 1em
@ -297,3 +329,4 @@ body.outfits-new
#latest-contribution-created-at #latest-contribution-created-at
color: $soft-text-color color: $soft-text-color
margin-left: .5em margin-left: .5em

View file

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

View file

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

View file

@ -35,7 +35,6 @@
text-align: left text-align: left
display: flex display: flex
align-items: center align-items: center
flex-wrap: wrap
gap: 1em gap: 1em
abbr abbr
@ -67,21 +66,14 @@
background: #FEEBC8 background: #FEEBC8
color: #7B341E color: #7B341E
.support-form
grid-area: support
font-size: 85%
text-align: left
.user-lists-info .user-lists-info
grid-area: lists grid-area: lists
font-size: 85% font-size: 85%
text-align: left text-align: left
display: flex .user-lists-form-opener
gap: 1em &::after
content: " "
a::after
content: " "
.user-lists-form .user-lists-form
background: $background-color background: $background-color
@ -135,7 +127,6 @@
.item-subpages-nav .item-subpages-nav
display: flex display: flex
align-items: flex-end align-items: flex-end
gap: 1em
.preview-link .preview-link
margin-right: auto margin-right: auto
@ -176,4 +167,4 @@
background: $background-color background: $background-color
padding-bottom: calc(.5em + 1px) padding-bottom: calc(.5em + 1px)
font-weight: bold font-weight: bold
margin-bottom: -1px margin-bottom: -1px

View file

@ -18,8 +18,9 @@ $error-color: #8a1f11
$error-bg-color: #fbe3e4 $error-bg-color: #fbe3e4
$error-border-color: #fbc2c4 $error-border-color: #fbc2c4
$header-font: Delicious, system-ui, sans-serif $header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
$main-font: system-ui, 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-img-size: 80px
$object-width: 100px $object-width: 100px

View file

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

View file

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

View file

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

View file

@ -2,8 +2,70 @@
@import "../partials/clean/mixins" @import "../partials/clean/mixins"
body.pets-bulk body.pets-bulk
#bulk-pets-form #needed-items-form, #bulk-pets-form
text-align: center 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 margin-top: 12px
padding-top: 12px padding-top: 12px

View file

@ -1,35 +1,23 @@
class AltStylesController < ApplicationController class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index]
def index def index
@all_series_names = AltStyle.all_series_names @alt_styles = AltStyle.includes(:species, :color, :swf_assets).
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort order(:species_id, :color_id)
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
@series_name = params[:series] if params[:species_id]
@color = find_color @species = Species.find(params[:species_id])
@species = find_species @alt_styles = @alt_styles.merge(@species.alt_styles)
end
@alt_styles = AltStyle.includes(:color, :species, :swf_assets) # We're going to link to the HTML5 image URL, so make sure we have all the
@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
# manifests ready! # manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
respond_to do |format| respond_to do |format|
format.html { format.html { render }
@alt_styles = @alt_styles.
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
render
}
format.json { format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
render json: @alt_styles.as_json( only: [:id, :species_id, :color_id, :body_id, :series_name,
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url], :adjective_name, :thumbnail_url],
include: { include: {
swf_assets: { swf_assets: {
only: [:id, :body_id], only: [:id, :body_id],
@ -37,62 +25,9 @@ class AltStylesController < ApplicationController
methods: [:urls, :known_glitches], methods: [:urls, :known_glitches],
} }
}, },
methods: [:series_main_name, :adjective_name], methods: [:series_name, :adjective_name, :thumbnail_url],
) )
} }
end end
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, :real_full_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 end

View file

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

View file

@ -218,12 +218,8 @@ class ClosetHangersController < ApplicationController
def enforce_shadowban def enforce_shadowban
# If this user is shadowbanned, and this *doesn't* seem to be a request # If this user is shadowbanned, and this *doesn't* seem to be a request
# from that user, render the 404 page. # from that user, render the 404 page.
if @user.shadowbanned? if @user.shadowbanned? && !@user.likely_is?(current_user, request.remote_ip)
can_see = support_staff? || render file: "public/404.html", layout: false, status: :not_found
@user.likely_is?(current_user, request.remote_ip)
if !can_see
render file: "public/404.html", layout: false, status: :not_found
end
end end
end end

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController class ItemsController < ApplicationController
before_action :set_query before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error rescue_from Item::Search::Error, :with => :search_error
def index def index
@ -29,12 +28,6 @@ class ItemsController < ApplicationController
render json: { render json: {
items: @items.as_json( items: @items.as_json(
methods: [:nc?, :pb?, :owned?, :wanted?], methods: [:nc?, :pb?, :owned?, :wanted?],
include: {
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
},
), ),
appearances: load_appearances.as_json( appearances: load_appearances.as_json(
include: { include: {
@ -96,14 +89,6 @@ class ItemsController < ApplicationController
worn_items: [@item], worn_items: [@item],
) )
@preview_error = validate_preview @preview_error = validate_preview
@all_appearances = @item.appearances
@appearances_by_occupied_zone_label =
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l }
@selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
includes(:species).merge(Species.alphabetical)
end end
format.gif do format.gif do
@ -113,18 +98,24 @@ class ItemsController < ApplicationController
end end
end end
def edit def needed
@item = Item.find params[:id] if params[:color] && params[:species]
render layout: "application" @pet_type = PetType.find_by_color_id_and_species_id(
end params[:color],
params[:species]
def update )
@item = Item.find params[:id] end
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!" unless @pet_type
redirect_to @item raise ActiveRecord::RecordNotFound, 'Pet type not found'
else end
render action: "edit", layout: "application", status: :bad_request
@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
end end
@ -180,15 +171,6 @@ class ItemsController < ApplicationController
protected 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) def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in? current_user.assign_closeted_to_items!(items) if user_signed_in?
end end
@ -240,8 +222,7 @@ class ItemsController < ApplicationController
@item.compatible_pet_types. @item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>"). preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>"). preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first || preferring_simple.first
PetType.matching_name("Blue", "Acara").first!
end end
def validate_preview def validate_preview

View file

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

View file

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

View file

@ -1,111 +1,10 @@
class PetTypesController < ApplicationController class PetTypesController < ApplicationController
def index
respond_to do |format|
format.html {
@species_names = Species.order(:name).map(&:human_name)
@color_names = Color.order(:name).map(&:human_name)
if params[:species].present?
@selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name
end
if params[:color].present?
@selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name
end
@selected_order =
if @selected_species.present? || @selected_color.present?
:alphabetical
else
:newest
end
@pet_types = PetType.
includes(:color, :species, :pet_states).
paginate(page: params[:page], per_page: 30)
@pet_types.where!(species_id: @selected_species) if @selected_species
@pet_types.where!(color_id: @selected_color) if @selected_color
if @selected_order == :newest
@pet_types.order!(created_at: :desc)
elsif @selected_order == :alphabetical
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
end
if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first
end
if support_staff?
@counts = {
total: PetState.count,
glitched: PetState.glitched.count,
needs_labeling: PetState.needs_labeling.count,
usable: PetState.usable.count,
}
@unlabeled_appearance = PetState.next_unlabeled_appearance
end
}
format.json {
if stale?(etag: PetState.last_updated_key)
render json: {
species: Species.order(:name).all,
colors: Color.order(:name).all,
supported_poses: PetState.all_supported_poses,
}
end
}
end
end
def show 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| render json: @pet_type
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:}
end end
end end

View file

@ -1,17 +1,20 @@
class PetsController < ApplicationController class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found rescue_from Pet::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled rescue_from Pet::DownloadError, with: :pet_download_error
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load 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]) @pet = Pet.load(params[:name])
points = contribute(current_user, @pet) points = contribute(current_user, @pet)
respond_to do |format| respond_to do |format|
format.html do format.html do
path = destination + "?" + @pet.wardrobe_query path = destination + @pet.wardrobe_query
redirect_to path redirect_to path
end end
@ -35,8 +38,9 @@ class PetsController < ApplicationController
def destination def destination
case (params[:destination] || params[:origin]) case (params[:destination] || params[:origin])
when 'wardrobe' then wardrobe_path when 'wardrobe' then wardrobe_path + '?'
else root_path when 'needed_items' then needed_items_path + '?'
else root_path + '#'
end end
end end
@ -45,6 +49,12 @@ class PetsController < ApplicationController
:status => :not_found :status => :not_found
end 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) def pet_download_error(e)
Rails.logger.warn e.message Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n") Rails.logger.warn e.backtrace.join("\n")

View file

@ -12,20 +12,13 @@ class SwfAssetsController < ApplicationController
helpers.image_url("favicon.png"), helpers.image_url("favicon.png"),
@swf_asset.image_url, @swf_asset.image_url,
*@swf_asset.canvas_movie_sprite_urls, *@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".)
origins: ["https://images.neopets.com"],
) )
} }
policy.script_src -> { policy.script_src -> {
src_list( src_list(
helpers.javascript_url("easeljs.min"), helpers.javascript_url("lib/easeljs.min"),
helpers.javascript_url("tweenjs.min"), helpers.javascript_url("lib/tweenjs.min"),
helpers.javascript_url("swf_assets/show"), helpers.javascript_url("swf_assets/show"),
@swf_asset.canvas_movie_library_url, @swf_asset.canvas_movie_library_url,
) )
@ -45,23 +38,7 @@ class SwfAssetsController < ApplicationController
private private
def src_list(*urls, origins: []) def src_list(*urls)
clean_urls = urls. urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
# Ignore any `nil`s that might arise
filter(&:present?).
# Parse the URL.
map { |url| Addressable::URI.parse(url) }.
# Remove query strings from URLs (they're invalid in CSPs)
each { |url| url.query = nil }.
# For the given `origins`, remove all their specific URLs, because
# we'll just include the entire origin anyway.
reject { |url| origins.include?(url.origin) }.
# Normalize the URLs. (This fixes issues like when the canonical
# Neopets version of the URL contains plain unescaped spaces.)
each(&:normalize!).
# Convert the URLs back into strings.
map(&:to_s)
clean_urls + origins
end end
end end

View file

@ -1,6 +1,5 @@
class UsersController < ApplicationController class UsersController < ApplicationController
before_action :find_and_authorize_user!, only: [:edit, :update] before_action :find_and_authorize_user!, :only => [:update]
before_action :support_staff_only, only: [:edit]
def index # search, really def index # search, really
name = params[:name] name = params[:name]
@ -17,9 +16,6 @@ class UsersController < ApplicationController
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20 @users = User.top_contributors.paginate :page => params[:page], :per_page => 20
end end
def edit
end
def update def update
@user.attributes = user_params @user.attributes = user_params
success = @user.save success = @user.save
@ -46,24 +42,17 @@ class UsersController < ApplicationController
protected protected
ALLOWED_ATTRS = [
:owned_closet_hangers_visibility,
:wanted_closet_hangers_visibility,
:contact_neopets_connection_id,
]
def user_params def user_params
if support_staff? params.require(:user).permit(:owned_closet_hangers_visibility,
params.require(:user).permit( :wanted_closet_hangers_visibility, :contact_neopets_connection_id)
*ALLOWED_ATTRS, :name, :shadowbanned, :support_staff
)
else
params.require(:user).permit(*ALLOWED_ATTRS)
end
end end
def find_and_authorize_user! def find_and_authorize_user!
@user = User.find(params[:id]) if current_user.id == params[:id].to_i
raise AccessDenied unless current_user == @user || support_staff? @user = current_user
else
raise AccessDenied
end
end end
end end

View file

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

View file

@ -1,4 +1,6 @@
module ApplicationHelper module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url) def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL if path_or_url.include?('://') # already an absolute URL
path_or_url path_or_url
@ -99,12 +101,6 @@ module ApplicationHelper
"matchu@openneo.net" "matchu@openneo.net"
end 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! # 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 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 def external_link_icon
@ -127,6 +123,10 @@ module ApplicationHelper
!@hide_home_link !@hide_home_link
end end
def support_staff?
user_signed_in? && current_user.support_staff?
end
def impress_2020_meta_tags def impress_2020_meta_tags
origin = Rails.configuration.impress_2020_origin origin = Rails.configuration.impress_2020_origin
support_secret = Rails.application.credentials.dig( support_secret = Rails.application.credentials.dig(
@ -142,9 +142,20 @@ module ApplicationHelper
end end
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 def locale_options
current_locale_is_public = false 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 current_locale_is_public = true if I18n.locale == available_locale
# Include fallbacks data on the tag. Right now it's used in blog # Include fallbacks data on the tag. Right now it's used in blog
# localization, but may conceivably be used for something else later. # localization, but may conceivably be used for something else later.
@ -159,6 +170,13 @@ module ApplicationHelper
options options
end 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 def auth_user_sign_in_path_with_return_to
new_auth_user_session_path :return_to => request.fullpath new_auth_user_session_path :return_to => request.fullpath
@ -213,19 +231,6 @@ module ApplicationHelper
@hide_title_header = true @hide_title_header = true
end end
def hide_after(last_day, &block)
yield if Date.today <= last_day
end
def use_responsive_design
@use_responsive_design = true
add_body_class "use-responsive-design"
end
def use_responsive_design?
@use_responsive_design || false
end
def signed_in_meta_tag def signed_in_meta_tag
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe %(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
end end

View file

@ -14,42 +14,28 @@ module ItemsHelper
} }
Sizes = { Sizes = {
face: 1, # 50x50 face: 1,
face_3x: 6, # 150x150 thumb: 2,
zoom: 3,
thumb: 2, # 150x150 full: 4,
full: 4, # 300x300 face_2x: 6,
large: 5, # 500x500
xlarge: 7, # 640x640
zoom: 3, # 80x80
autocrop: 9, # <varies>
}
SizeUpgrades = {
face: :face_3x,
thumb: :full,
full: :xlarge,
} }
end end
def pet_type_image_url(pet_type, emotion: :happy, size: :face) def pet_type_image_url(pet_type, emotion: :happy, size: :face)
PetTypeImage::Template.expand( PetTypeImage::Template.expand(
hash: pet_type.basic_image_hash || pet_type.image_hash, hash: pet_type.basic_image_hash || pet_type.image_hash,
emotion: PetTypeImage::Emotions.fetch(emotion), emotion: PetTypeImage::Emotions[emotion],
size: PetTypeImage::Sizes.fetch(size), size: PetTypeImage::Sizes[size],
).to_s ).to_s
end end
def standard_species_search_links def standard_species_search_links
all_species = Species.alphabetical.map(&:id) build_on_pet_types(Species.alphabetical) do |pet_type|
PetType.random_basic_per_species(all_species).map do |pet_type| image = pet_type_image(pet_type, :happy, :zoom)
human_name = pet_type.species.human_name
image = pet_type_image pet_type, :happy, :zoom,
alt: human_name, title: human_name
query = "species:#{pet_type.species.name}" query = "species:#{pet_type.species.name}"
link_to(image, items_path(:q => query)) link_to(image, items_path(:q => query))
end.join.html_safe end
end end
def closet_list_verb(owned) def closet_list_verb(owned)
@ -126,7 +112,14 @@ module ItemsHelper
item_or_name = item_or_name.name if item_or_name.is_a? Item item_or_name = item_or_name.name if item_or_name.is_a? Item
SHOP_WIZARD_URL_TEMPLATE.expand(string: item_or_name).to_s SHOP_WIZARD_URL_TEMPLATE.expand(string: item_or_name).to_s
end end
SUPER_SHOP_WIZARD_URL_TEMPLATE = Addressable::Template.new(
"https://www.neopets.com/portal/supershopwiz.phtml{?string}"
)
def super_shop_wizard_url_for(item)
SUPER_SHOP_WIZARD_URL_TEMPLATE.expand(string: item.name).to_s
end
TRADING_POST_URL_TEMPLATE = Addressable::Template.new( TRADING_POST_URL_TEMPLATE = Addressable::Template.new(
"https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact{&search_string}" "https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact{&search_string}"
) )
@ -235,35 +228,21 @@ module ItemsHelper
cookies["DTIOutfitViewerIsPlaying"] == "true" cookies["DTIOutfitViewerIsPlaying"] == "true"
end end
def item_fits?(item, pet_type) private
item.appearances.any? { |a| a.fits? pet_type }
def build_on_pet_types(species, special_color=nil, &block)
species_ids = species.map(&:id)
pet_types = special_color ?
PetType.where(:color_id => special_color.id, :species_id => species_ids).
order(:species_id) :
PetType.random_basic_per_species(species.map(&:id))
pet_types.map(&block).join.html_safe
end end
def species_face_tooltip(pet_type, item) def pet_type_image(pet_type, emotion, size)
if item_fits?(item, pet_type)
"#{pet_type.species.human_name}"
else
"#{pet_type.species.human_name}: No data yet"
end
end
def item_zone_partial_fit?(appearances_in_zone, all_appearances)
appearances_in_zone.size < all_appearances.size
end
def item_zone_species_list(appearances_in_zone)
appearances_in_zone.map(&:species).uniq.map(&:human_name).sort.join(", ")
end
def pet_type_image(pet_type, emotion, size, **options)
src = pet_type_image_url(pet_type, emotion:, size:) src = pet_type_image_url(pet_type, emotion:, size:)
human_name = pet_type.species.name.humanize
size_2x = PetTypeImage::SizeUpgrades[size] image_tag(src, :alt => human_name, :title => human_name)
srcset = if size_2x
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
end
image_tag(src, srcset:, **options)
end end
def item_header_user_lists_form_state def item_header_user_lists_form_state

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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