Compare commits
227 commits
one-more-h
...
main
Author | SHA1 | Date | |
---|---|---|---|
c32a495780 | |||
ea5c315c2a | |||
ab238ab2a6 | |||
0d2648d030 | |||
d9bf4f745b | |||
407c4b38a5 | |||
6dc5aa28a4 | |||
b656ccd982 | |||
02836494ae | |||
b6e6f27fdf | |||
aeb00f73cf | |||
06a301e6d7 | |||
1119bbb292 | |||
fdbfa3c03f | |||
252f4f1df1 | |||
2d3d4051fe | |||
3cd02baa09 | |||
8347633a84 | |||
661a5385f4 | |||
c27477fabe | |||
c7bea666c9 | |||
f49f9f386d | |||
3937ba354f | |||
388bb9a251 | |||
e846a75f7a | |||
270b27c1d2 | |||
4cbac13df1 | |||
0261d02137 | |||
e82c606ee8 | |||
ed5b62e161 | |||
5472ccebef | |||
f6f618c9d5 | |||
39bed6b157 | |||
af5187edb6 | |||
21eaf7b266 | |||
91851bc340 | |||
3e7d27eaa3 | |||
f7109e398a | |||
f90380c4e6 | |||
218dc5b6f9 | |||
bc0097850d | |||
ec0b8d9cb9 | |||
a57b3629db | |||
1d1dc15320 | |||
b6c21dfe40 | |||
c4a7e7916f | |||
217d25edab | |||
dd213e8078 | |||
c5995a2bd1 | |||
1ad3ea8f96 | |||
b245690a60 | |||
3ed1c46b64 | |||
9e3ce74ed5 | |||
5f31e38428 | |||
8f9daf4d52 | |||
3242981eb2 | |||
54b25ef08e | |||
e4e81f0694 | |||
e3d196fe87 | |||
0b3dd02323 | |||
48c1a58df9 | |||
42e7eabdd8 | |||
a208fca8d2 | |||
3ac89e830e | |||
d82c7f817a | |||
5264947608 | |||
90407403ba | |||
242b85470d | |||
43717e2535 | |||
bc1f7152bf | |||
9eaee4a2d4 | |||
52ca41dbff | |||
c03e7446e3 | |||
6402e5abc3 | |||
f81415d327 | |||
13ceec8fcc | |||
40765c729e | |||
d26f3a7598 | |||
06721f77e9 | |||
f9be3dceb1 | |||
c9c080e74d | |||
e65634d8bc | |||
4c5d14c591 | |||
28bd6ecca4 | |||
7a837edaf6 | |||
f3894759d6 | |||
30ada0b7e1 | |||
8a38ce90dc | |||
6d25b3424f | |||
8902527438 | |||
044dface14 | |||
b1890d4f6f | |||
3a5f33fd56 | |||
a54a844e03 | |||
c78d45a0b5 | |||
930bfca028 | |||
29aa769bda | |||
66438eae1a | |||
3b5b13c172 | |||
5b1d1f0695 | |||
e92e315743 | |||
eb2fb125b9 | |||
d8ff99475e | |||
9726ecb1a5 | |||
540ce08caa | |||
881e63cfbd | |||
09e5a39b4c | |||
bf20c9bb31 | |||
7607c2c015 | |||
abfe1e6df7 | |||
e36e273d50 | |||
83e5ad6bcc | |||
acb52cb870 | |||
7ef689d658 | |||
23c083ff1d | |||
6b7c73870a | |||
e7a0ff1234 | |||
50c9ba53e7 | |||
89c729ecbe | |||
bb83f6fd36 | |||
7891acd3b1 | |||
16deee94e4 | |||
2cc0c5b031 | |||
381a892af8 | |||
1a0fb68b1c | |||
4f9fbc1ac0 | |||
ad51690617 | |||
5648f55d2c | |||
6934b636fb | |||
83fe0d20e0 | |||
be5ad31a1d | |||
1626f0706c | |||
7fad6abfed | |||
c985c50a1b | |||
bba863b94b | |||
7c1b3ca447 | |||
71f0aa4908 | |||
13a0362e6d | |||
fe67211fdf | |||
0244653cb0 | |||
2c0d55edd1 | |||
be0faaa36e | |||
f87f4e61b3 | |||
dfca88bed3 | |||
bd001e643e | |||
1d51e28144 | |||
fe4db1b605 | |||
860b8eef72 | |||
61e22e3943 | |||
03e4233f67 | |||
b6bddb14be | |||
e52838ba70 | |||
7ba68c52d4 | |||
26add4577c | |||
efda6d74ab | |||
4a431a4ae8 | |||
4bcc3aaebb | |||
5890e52e53 | |||
dd8426fefd | |||
2a9818b2d1 | |||
0b72b5568c | |||
86e1f31231 | |||
a99fb3ec02 | |||
d11c18129d | |||
0958111341 | |||
775baa250b | |||
2bd8afd486 | |||
1f1c6d92b1 | |||
e4a640ccee | |||
d465f4125e | |||
946a6326ac | |||
d5a901b917 | |||
39e5ca59c4 | |||
4fa80d33cc | |||
d66f81c96b | |||
f8a5ce4490 | |||
81f0845d4a | |||
f0257ba2d3 | |||
d056a5e766 | |||
5214a14990 | |||
06a89689d8 | |||
a08fb89d59 | |||
80307f21f7 | |||
75040ffbf3 | |||
6f45cd0485 | |||
4e33477c65 | |||
b28255cafd | |||
99e8b46157 | |||
734b7fba1d | |||
a1d6961249 | |||
e7148ffae3 | |||
64b1d11faa | |||
e63f4df25b | |||
535a0029f9 | |||
c0e4291745 | |||
d27c03606f | |||
40a3f5bf68 | |||
4bc38db5aa | |||
2ab1951e68 | |||
cae2f3ca74 | |||
31619071af | |||
f20a1b5398 | |||
3bd6f09a54 | |||
38474d19d7 | |||
73e0b3bb3c | |||
1f53615654 | |||
7f55456454 | |||
f23bebb607 | |||
cf2cd41531 | |||
d45162897d | |||
02b510bb3f | |||
9ebc498888 | |||
5bf2ef42a0 | |||
0a5d369735 | |||
ebd400369a | |||
81e4d16816 | |||
95ae669549 | |||
989c96fd2b | |||
fdf1f31867 | |||
c7b0ec71ef | |||
287d7af1b9 | |||
58d7c38523 | |||
68b6f46939 | |||
cf6a19a7fc | |||
9e052789db | |||
30f211caf3 | |||
dab865689f |
2
.gitignore
vendored
|
@ -4,6 +4,8 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.env.*
|
||||
/spec/examples.txt
|
||||
/.yardoc
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
yarn lint --max-warnings=0 --fix
|
||||
# Run the linter, and all our tests.
|
||||
yarn lint --max-warnings=0 --fix && bin/rake test spec
|
||||
|
|
|
@ -1 +0,0 @@
|
|||
/app/assets/javascripts/lib
|
1
.rspec
Normal file
|
@ -0,0 +1 @@
|
|||
--require spec_helper
|
|
@ -1 +1 @@
|
|||
3.3.4
|
||||
3.3.5
|
||||
|
|
26
Gemfile
|
@ -1,7 +1,7 @@
|
|||
source 'https://rubygems.org'
|
||||
ruby '3.3.4'
|
||||
ruby '3.3.5'
|
||||
|
||||
gem 'rails', '~> 7.1', '>= 7.1.3.4'
|
||||
gem 'rails', '~> 7.2', '>= 7.2.1'
|
||||
|
||||
# The HTTP server running the Rails instance.
|
||||
gem 'falcon', '~> 0.48.0'
|
||||
|
@ -19,7 +19,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
|
|||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||
gem 'jsbundling-rails', '~> 1.1'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
gem 'turbo-rails', '~> 2.0'
|
||||
|
||||
# For authentication.
|
||||
|
@ -66,10 +66,10 @@ gem "async-http", "~> 0.75.0", require: false
|
|||
gem "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For debugging.
|
||||
gem 'web-console', '~> 4.2', group: :development
|
||||
|
||||
# TODO: Review our use of content_tag_for etc and uninstall this!
|
||||
gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
gem 'web-console', '~> 4.2'
|
||||
end
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', '~> 1.16', require: false
|
||||
|
@ -87,5 +87,13 @@ gem "sentry-rails", "~> 5.12"
|
|||
gem "shell", "~> 0.8.1"
|
||||
|
||||
# For workspace autocomplete.
|
||||
gem "solargraph", "~> 0.50.0", group: :development
|
||||
gem "solargraph-rails", "~> 1.1", group: :development
|
||||
group :development do
|
||||
gem "solargraph", "~> 0.50.0"
|
||||
gem "solargraph-rails", "~> 1.1"
|
||||
end
|
||||
|
||||
# For automated tests.
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "webmock", "~> 3.24", group: :test
|
||||
end
|
||||
|
|
97
Gemfile.lock
|
@ -128,9 +128,15 @@ GEM
|
|||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
|
@ -150,7 +156,7 @@ GEM
|
|||
activemodel
|
||||
erubi (1.13.0)
|
||||
execjs (2.9.1)
|
||||
falcon (0.48.0)
|
||||
falcon (0.48.2)
|
||||
async
|
||||
async-container (~> 0.18)
|
||||
async-http (~> 0.75)
|
||||
|
@ -163,8 +169,9 @@ GEM
|
|||
protocol-http (~> 0.31)
|
||||
protocol-rack (~> 0.7)
|
||||
samovar (~> 2.3)
|
||||
faraday (2.11.0)
|
||||
faraday (2.12.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
|
@ -181,19 +188,20 @@ GEM
|
|||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
hashdiff (1.1.2)
|
||||
hashie (5.0.0)
|
||||
http_accept_language (2.1.1)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.5)
|
||||
i18n (1.14.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.7.2)
|
||||
io-endpoint (0.13.1)
|
||||
io-event (1.6.5)
|
||||
io-stream (0.4.0)
|
||||
irb (1.14.0)
|
||||
io-stream (0.4.1)
|
||||
irb (1.14.1)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
|
@ -218,7 +226,7 @@ GEM
|
|||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
localhost (1.3.1)
|
||||
logger (1.6.0)
|
||||
logger (1.6.1)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
|
@ -229,7 +237,7 @@ GEM
|
|||
net-smtp
|
||||
mapping (1.1.1)
|
||||
marcel (1.0.4)
|
||||
memory_profiler (1.0.2)
|
||||
memory_profiler (1.1.0)
|
||||
metrics (0.10.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
|
@ -240,7 +248,7 @@ GEM
|
|||
mysql2 (0.5.6)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.16)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
@ -279,22 +287,22 @@ GEM
|
|||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.4.2)
|
||||
parser (3.3.5.0)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
process-metrics (0.3.0)
|
||||
console (~> 1.8)
|
||||
json (~> 2)
|
||||
samovar (~> 2.1)
|
||||
protocol-hpack (1.5.0)
|
||||
protocol-http (0.33.0)
|
||||
protocol-http1 (0.22.0)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.37.0)
|
||||
protocol-http1 (0.27.0)
|
||||
protocol-http (~> 0.22)
|
||||
protocol-http2 (0.18.0)
|
||||
protocol-http2 (0.19.1)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.18)
|
||||
protocol-rack (0.7.0)
|
||||
protocol-http (~> 0.27)
|
||||
protocol-rack (0.10.0)
|
||||
protocol-http (~> 0.37)
|
||||
rack (>= 1.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
|
@ -366,30 +374,43 @@ GEM
|
|||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
record_tag_helper (1.0.1)
|
||||
actionview (>= 5)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
reline (0.5.10)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rubocop (1.65.1)
|
||||
rexml (3.3.7)
|
||||
rspec-core (3.13.2)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.0.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.1)
|
||||
rubocop (1.66.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.1)
|
||||
rubocop-ast (1.32.3)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
samovar (2.3.0)
|
||||
|
@ -446,7 +467,6 @@ GEM
|
|||
sprockets (>= 3.0.0)
|
||||
stackprof (0.2.26)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
|
@ -456,18 +476,17 @@ GEM
|
|||
temple (0.10.3)
|
||||
terser (1.2.3)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
thor (1.3.1)
|
||||
thor (1.3.2)
|
||||
thread-local (1.1.0)
|
||||
tilt (2.4.0)
|
||||
timeout (0.4.1)
|
||||
traces (0.13.1)
|
||||
turbo-rails (2.0.6)
|
||||
turbo-rails (2.0.10)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.5.0)
|
||||
unicode-display_width (2.6.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
validate_url (1.0.15)
|
||||
|
@ -484,13 +503,17 @@ GEM
|
|||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webrick (1.8.1)
|
||||
webmock (3.24.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.1)
|
||||
yard (0.9.36)
|
||||
zeitwerk (2.6.17)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.6.18)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
@ -501,6 +524,7 @@ DEPENDENCIES
|
|||
async (~> 2.17)
|
||||
async-http (~> 0.75.0)
|
||||
bootsnap (~> 1.16)
|
||||
debug (~> 1.9.2)
|
||||
devise (~> 4.9, >= 4.9.2)
|
||||
devise-encryptable (~> 0.2.0)
|
||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||
|
@ -508,7 +532,7 @@ DEPENDENCIES
|
|||
haml (~> 6.1, >= 6.1.1)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
httparty (~> 0.22.0)
|
||||
jsbundling-rails (~> 1.1)
|
||||
jsbundling-rails (~> 1.3)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
memory_profiler (~> 1.0)
|
||||
mysql2 (~> 0.5.5)
|
||||
|
@ -519,11 +543,11 @@ DEPENDENCIES
|
|||
parallel (~> 1.23)
|
||||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 3.1)
|
||||
rails (~> 7.1, >= 7.1.3.4)
|
||||
rails (~> 7.2, >= 7.2.1)
|
||||
rails-i18n (~> 7.0, >= 7.0.7)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
react-rails (~> 2.7, >= 2.7.1)
|
||||
record_tag_helper (~> 1.0, >= 1.0.1)
|
||||
rspec-rails (~> 7.0)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
|
@ -537,10 +561,11 @@ DEPENDENCIES
|
|||
thread-local (~> 1.1)
|
||||
turbo-rails (~> 2.0)
|
||||
web-console (~> 4.2)
|
||||
webmock (~> 3.24)
|
||||
will_paginate (~> 4.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.4p94
|
||||
ruby 3.3.5p100
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.18
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
//= link_tree ../images
|
||||
//= link_tree ../javascripts .js
|
||||
//= link_tree ../../../vendor/javascript .js
|
||||
//= link_tree ../stylesheets .css
|
||||
//= link_directory ../fonts .otf
|
||||
//= link_tree ../builds
|
||||
|
|
Before Width: | Height: | Size: 172 KiB |
Before Width: | Height: | Size: 1.9 KiB |
Before Width: | Height: | Size: 585 B |
Before Width: | Height: | Size: 601 B |
Before Width: | Height: | Size: 206 B |
Before Width: | Height: | Size: 516 B |
Before Width: | Height: | Size: 127 KiB |
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 1.7 KiB |
Before Width: | Height: | Size: 16 KiB |
Before Width: | Height: | Size: 10 KiB |
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 38 KiB |
BIN
app/assets/images/rainbow_pool.png
Normal file
After Width: | Height: | Size: 2.8 KiB |
|
@ -1,20 +0,0 @@
|
|||
(function () {
|
||||
var CSRFProtection;
|
||||
var token = $('meta[name="csrf-token"]').attr("content");
|
||||
if (token) {
|
||||
CSRFProtection = function (xhr, settings) {
|
||||
var sendToken =
|
||||
typeof settings.useCSRFProtection === "undefined" || // default to true
|
||||
settings.useCSRFProtection;
|
||||
if (sendToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
CSRFProtection = $.noop;
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: CSRFProtection,
|
||||
});
|
||||
})();
|
|
@ -1,4 +1,11 @@
|
|||
(function () {
|
||||
function addCSRFToken(xhr) {
|
||||
const token = document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
|
||||
var hangersInitCallbacks = [];
|
||||
|
||||
function onHangersInit(callback) {
|
||||
|
@ -285,6 +292,7 @@
|
|||
type: "post",
|
||||
data: data,
|
||||
dataType: "json",
|
||||
beforeSend: addCSRFToken,
|
||||
complete: function (data) {
|
||||
if (quantityEl.val() == 0) {
|
||||
objectRemoved(objectWrapper);
|
||||
|
@ -389,6 +397,7 @@
|
|||
type: "post",
|
||||
data: data,
|
||||
dataType: "json",
|
||||
beforeSend: addCSRFToken,
|
||||
complete: function () {
|
||||
button.val("Remove");
|
||||
},
|
||||
|
@ -465,6 +474,7 @@
|
|||
url: form.attr("action"),
|
||||
type: form.attr("method"),
|
||||
data: data,
|
||||
beforeSend: addCSRFToken,
|
||||
success: function (html) {
|
||||
var doc = $(html);
|
||||
hangersEl.html(doc.find("#closet-hangers").html());
|
||||
|
@ -501,6 +511,7 @@
|
|||
url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
|
||||
type: "delete",
|
||||
dataType: "json",
|
||||
beforeSend: addCSRFToken,
|
||||
success: function () {
|
||||
objectRemoved(hangerEls);
|
||||
},
|
||||
|
@ -567,6 +578,7 @@
|
|||
closet_hanger: closetHanger,
|
||||
return_to: window.location.pathname + window.location.search,
|
||||
},
|
||||
beforeSend: addCSRFToken,
|
||||
complete: function () {
|
||||
itemsSearchField.removeClass("loading");
|
||||
},
|
||||
|
@ -711,6 +723,7 @@
|
|||
type: "post",
|
||||
data: data,
|
||||
dataType: "json",
|
||||
beforeSend: addCSRFToken,
|
||||
complete: function () {
|
||||
contactForm.enableForms();
|
||||
},
|
||||
|
@ -731,6 +744,7 @@
|
|||
type: "POST",
|
||||
data: { neopets_connection: { neopets_username: newUsername } },
|
||||
dataType: "json",
|
||||
beforeSend: addCSRFToken,
|
||||
success: function (connection) {
|
||||
var newOption = $("<option/>", {
|
||||
text: newUsername,
|
||||
|
|
|
@ -1,8 +0,0 @@
|
|||
(function () {
|
||||
function setChecked() {
|
||||
var el = $(this);
|
||||
el.closest("li").toggleClass("checked", el.is(":checked"));
|
||||
}
|
||||
|
||||
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
|
||||
})();
|
|
@ -81,23 +81,35 @@ class SpeciesFacePickerOptions extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
class MeasuredContent extends HTMLElement {
|
||||
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
|
||||
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
|
||||
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
|
||||
class MeasuredContainer extends HTMLElement {
|
||||
static observedAttributes = ["style"];
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
|
||||
#measure() {
|
||||
// Find our `<measured-container>` parent, and set our natural width
|
||||
// as `var(--natural-width)` in the context of its CSS styles.
|
||||
const container = this.closest("measured-container");
|
||||
if (container == null) {
|
||||
throw new Error(`<measured-content> must be in a <measured-container>`);
|
||||
attributeChangedCallback() {
|
||||
// When `--natural-width` gets morphed away by Turbo, measure it again!
|
||||
if (this.style.getPropertyValue("--natural-width") === "") {
|
||||
this.#measure();
|
||||
}
|
||||
container.style.setProperty("--natural-width", this.offsetWidth + "px");
|
||||
}
|
||||
|
||||
#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-content", MeasuredContent);
|
||||
customElements.define("measured-container", MeasuredContainer);
|
||||
|
|
|
@ -21,10 +21,6 @@ class OutfitViewer extends HTMLElement {
|
|||
this.#setIsPlaying(playPauseToggle.checked);
|
||||
this.#setIsPlayingCookie(playPauseToggle.checked);
|
||||
});
|
||||
|
||||
// Tell the CSS our first frame has rendered, which we use for loading
|
||||
// state transitions.
|
||||
this.#internals.states.add("after-first-frame");
|
||||
}
|
||||
|
||||
#setIsPlaying(isPlaying) {
|
||||
|
|
|
@ -12,10 +12,14 @@
|
|||
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
var image_url = petImage("cpn/" + PetQuery.name, 1);
|
||||
if (PetQuery.name.startsWith("@")) {
|
||||
image_url = petImage("cp/" + PetQuery.name.substr(1), 1);
|
||||
}
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
|
||||
pet_image_url: image_url,
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
|
|
|
@ -37,6 +37,12 @@
|
|||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
beforeSend: (xhr) => {
|
||||
const token = document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
},
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
|
|
|
@ -32,9 +32,6 @@ body
|
|||
a[href]
|
||||
color: $link-color
|
||||
|
||||
p
|
||||
font-family: $text-font
|
||||
|
||||
input, button, select
|
||||
font:
|
||||
family: inherit
|
||||
|
@ -77,7 +74,7 @@ $container_width: 800px
|
|||
input, button, select, label
|
||||
cursor: pointer
|
||||
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
|
||||
border-radius: 3px
|
||||
background: #fff
|
||||
border: 1px solid $input-border-color
|
||||
|
@ -86,6 +83,15 @@ input[type=text], input[type=password], input[type=search], input[type=number],
|
|||
&:focus, &:active
|
||||
color: inherit
|
||||
|
||||
select:has(option[value='']:checked)
|
||||
color: #666
|
||||
|
||||
option[value='']
|
||||
color: #666
|
||||
|
||||
option:not([value=''])
|
||||
color: $text-color
|
||||
|
||||
textarea
|
||||
font: inherit
|
||||
|
||||
|
|
|
@ -3,10 +3,20 @@ body.use-responsive-design
|
|||
max-width: 100%
|
||||
padding-inline: 1rem
|
||||
box-sizing: border-box
|
||||
padding-top: 0
|
||||
|
||||
#main-nav
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
#home-link, #userbar
|
||||
position: static
|
||||
|
||||
#home-link
|
||||
margin-left: 1rem
|
||||
padding-inline: 0
|
||||
padding-inline: .5rem
|
||||
margin-inline: -.5rem
|
||||
margin-right: auto
|
||||
|
||||
#userbar
|
||||
margin-right: 1rem
|
||||
margin-left: auto
|
||||
text-align: right
|
||||
|
|
|
@ -1,18 +0,0 @@
|
|||
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
|
4
app/assets/stylesheets/alt_styles/edit.sass
Normal file
|
@ -0,0 +1,4 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
3
app/assets/stylesheets/alt_styles/index.sass
Normal file
|
@ -0,0 +1,3 @@
|
|||
.rainbow-pool-list
|
||||
.name span
|
||||
display: inline-block
|
|
@ -8,9 +8,7 @@
|
|||
|
||||
@import partials/jquery.jgrowl
|
||||
|
||||
@import alt_styles/index
|
||||
@import closet_hangers/index
|
||||
@import closet_hangers/petpage
|
||||
@import closet_lists/form
|
||||
@import neopets_page_import_tasks/new
|
||||
@import contributions/index
|
||||
|
|
23
app/assets/stylesheets/application/breadcrumbs.sass
Normal file
|
@ -0,0 +1,23 @@
|
|||
#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: "-"
|
110
app/assets/stylesheets/application/outfit-viewer.sass
Normal file
|
@ -0,0 +1,110 @@
|
|||
@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
|
74
app/assets/stylesheets/application/rainbow-pool.sass
Normal file
|
@ -0,0 +1,74 @@
|
|||
@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
|
102
app/assets/stylesheets/application/support-form.sass
Normal file
|
@ -0,0 +1,102 @@
|
|||
@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
|
|
@ -1,58 +0,0 @@
|
|||
@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%
|
57
app/assets/stylesheets/closet_hangers/petpage.sass
Normal file
|
@ -0,0 +1,57 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
|
@ -1,7 +1,7 @@
|
|||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>)");
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
|
@ -15,25 +15,3 @@
|
|||
font-style: italic;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Sans";
|
||||
src: local("Noto Sans"), url("<%= font_path "NotoSans-Variable.ttf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Sans";
|
||||
font-style: italic;
|
||||
src: local("Noto Sans"), url("<%= font_path "NotoSans-Italic-Variable.ttf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Serif";
|
||||
src: local("Noto Serif"), url("<%= font_path "NotoSerif-Variable.ttf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Noto Serif";
|
||||
font-style: italic;
|
||||
src: local("Noto Serif"), url("<%= font_path "NotoSerif-Italic-Variable.ttf" %>");
|
||||
}
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
@import "../application/outfit-viewer"
|
||||
|
||||
#container
|
||||
width: 900px // A bit more generous to the preview area!
|
||||
|
||||
|
@ -78,93 +80,10 @@
|
|||
width: var(--natural-width)
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
overflow: hidden
|
||||
|
||||
// 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%
|
||||
|
@ -178,19 +97,9 @@ outfit-viewer
|
|||
// is loading.
|
||||
//
|
||||
// We only apply the delay here, not on the base styles, because fading
|
||||
// *out* on load should be instant. We also wait for the outfit-viewer to
|
||||
// execute a `setTimeout(0)`, to make sure we always *start* in the
|
||||
// non-loading state. This is because it's sometimes possible for the page to
|
||||
// start with the web component already in `state(loading)`, and we need to
|
||||
// make sure we *start* in *non-loading* state for the transition delay to
|
||||
// happen. (This can happen when you Turbo-navigate between multiple items.)
|
||||
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
|
||||
cursor: wait
|
||||
|
||||
&:state(after-first-frame)
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
// *out* on load should be instant.
|
||||
#item-preview[busy] outfit-viewer
|
||||
+outfit-viewer-loading
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
|
|
|
@ -78,85 +78,57 @@ body.outfits-new
|
|||
font-size: 175%
|
||||
select
|
||||
font-size: 120%
|
||||
#description, #top-contributors
|
||||
float: left
|
||||
#description
|
||||
margin-right: 2%
|
||||
width: 64%
|
||||
#top-contributors
|
||||
border: 1px solid $input-border-color
|
||||
margin-top: 1em
|
||||
padding: 1%
|
||||
width: 30%
|
||||
ol
|
||||
margin-left: 2em
|
||||
padding-left: 1em
|
||||
> a
|
||||
font-size: 80%
|
||||
display: block
|
||||
text-align: right
|
||||
#how-can-i-help, #i-found-something
|
||||
+module
|
||||
float: left
|
||||
padding: 1%
|
||||
width: 46%
|
||||
h2
|
||||
font-style: italic
|
||||
input, button
|
||||
font-size: 115%
|
||||
input[type=text]
|
||||
border-color: $module-border-color
|
||||
width: 12em
|
||||
#how-can-i-help
|
||||
margin-right: 1%
|
||||
#i-found-something
|
||||
margin-left: 1%
|
||||
a
|
||||
float: right
|
||||
font-size: 87.5%
|
||||
margin-top: 1em
|
||||
$section-count: 3
|
||||
$section-border-width: 1px
|
||||
$section-padding: 0.5em
|
||||
$section-width: 100% / $section-count
|
||||
// (A - (B-1)*C) / B
|
||||
#sections
|
||||
+clearfix
|
||||
display: table
|
||||
display: grid
|
||||
grid-template-columns: 1fr 1fr 1fr
|
||||
list-style: none
|
||||
margin-top: 1em
|
||||
h3
|
||||
margin-bottom: .25em
|
||||
li
|
||||
border-left:
|
||||
color: $module-border-color
|
||||
style: solid
|
||||
width: $section-border-width
|
||||
display: table-cell
|
||||
padding: $section-padding
|
||||
position: relative
|
||||
width: $section-width
|
||||
&:first-child
|
||||
border-left: 0
|
||||
display: grid
|
||||
grid-template-areas: "header image" "info image" "form form"
|
||||
grid-template-rows: auto auto auto
|
||||
row-gap: .5em
|
||||
padding: 0.5em
|
||||
&:not(:first-child)
|
||||
border-left: 1px solid $module-border-color
|
||||
h3
|
||||
grid-area: header
|
||||
margin-bottom: 0
|
||||
div
|
||||
grid-area: info
|
||||
color: $soft-text-color
|
||||
font-size: 75%
|
||||
margin-left: 1em
|
||||
z-index: 2
|
||||
h4, input
|
||||
strong
|
||||
font-size: 116%
|
||||
h4, input[type=text]
|
||||
color: inherit
|
||||
h4 a
|
||||
background: #ffffc0
|
||||
a:has(img)
|
||||
grid-area: image
|
||||
img
|
||||
+opacity(0.75)
|
||||
opacity: 0.75
|
||||
float: right
|
||||
margin-left: .5em
|
||||
&:hover
|
||||
+opacity(1)
|
||||
opacity: 1
|
||||
p
|
||||
line-height: 1.5
|
||||
min-height: 4.5em
|
||||
margin-bottom: 0
|
||||
form
|
||||
grid-area: form
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
font-size: .85em
|
||||
margin-left: 1em
|
||||
margin-right: .5em
|
||||
|
||||
input[type=text], input[type=search]
|
||||
// TODO: It doesn't make sense to me that this is the right style? I
|
||||
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
|
||||
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
|
||||
// which I would have *thought* is the same as this, but isn't! Idk!
|
||||
width: 100%
|
||||
|
||||
#whats-new
|
||||
margin-bottom: 1em
|
||||
|
@ -325,4 +297,3 @@ body.outfits-new
|
|||
#latest-contribution-created-at
|
||||
color: $soft-text-color
|
||||
margin-left: .5em
|
||||
|
||||
|
|
|
@ -67,14 +67,21 @@
|
|||
background: #FEEBC8
|
||||
color: #7B341E
|
||||
|
||||
.support-form
|
||||
grid-area: support
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
.user-lists-info
|
||||
grid-area: lists
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
.user-lists-form-opener
|
||||
&::after
|
||||
content: " ›"
|
||||
display: flex
|
||||
gap: 1em
|
||||
|
||||
a::after
|
||||
content: " ›"
|
||||
|
||||
.user-lists-form
|
||||
background: $background-color
|
||||
|
|
|
@ -18,9 +18,8 @@ $error-color: #8a1f11
|
|||
$error-bg-color: #fbe3e4
|
||||
$error-border-color: #fbc2c4
|
||||
|
||||
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
|
||||
$main-font: "Noto Sans", Helvetica, Arial, Verdana, sans-serif
|
||||
$text-font: "Noto Serif", Georgia, "Times New Roman", Times, serif
|
||||
$header-font: Delicious, system-ui, sans-serif
|
||||
$main-font: system-ui, sans-serif
|
||||
|
||||
$object-img-size: 80px
|
||||
$object-width: 100px
|
||||
|
|
15
app/assets/stylesheets/pet_states/edit.sass
Normal file
|
@ -0,0 +1,15 @@
|
|||
outfit-viewer
|
||||
margin: 0 auto
|
||||
|
||||
.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
|
8
app/assets/stylesheets/pet_types/show.sass
Normal file
|
@ -0,0 +1,8 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-list
|
||||
--preview-base-width: 200px
|
||||
margin-bottom: 2em
|
||||
|
||||
.glitched
|
||||
cursor: help
|
|
@ -1,21 +1,40 @@
|
|||
class AltStylesController < ApplicationController
|
||||
before_action :support_staff_only, except: [:index]
|
||||
|
||||
def index
|
||||
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
|
||||
order(:species_id, :color_id)
|
||||
@all_alt_styles = AltStyle.includes(:species, :color)
|
||||
|
||||
if params[:species_id]
|
||||
@species = Species.find(params[:species_id])
|
||||
@alt_styles = @alt_styles.merge(@species.alt_styles)
|
||||
end
|
||||
@all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name)
|
||||
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name)
|
||||
|
||||
# We're going to link to the HTML5 image URL, so make sure we have all the
|
||||
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort
|
||||
@all_color_names = @all_colors.map(&:human_name)
|
||||
@all_species_names = @all_species.map(&:human_name)
|
||||
|
||||
@series_name = params[:series]
|
||||
@color = find_color
|
||||
@species = find_species
|
||||
|
||||
@alt_styles = @all_alt_styles.includes(:swf_assets)
|
||||
@alt_styles.where!(series_name: @series_name) if @series_name.present?
|
||||
@alt_styles.merge!(@color.alt_styles) if @color
|
||||
@alt_styles.merge!(@species.alt_styles) if @species
|
||||
|
||||
# We're using the HTML5 image for our preview, so make sure we have all the
|
||||
# manifests ready!
|
||||
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
|
||||
|
||||
respond_to do |format|
|
||||
format.html { render }
|
||||
format.html {
|
||||
@alt_styles = @alt_styles.
|
||||
by_creation_date.order(:color_id, :species_id, :series_name).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
render
|
||||
}
|
||||
format.json {
|
||||
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
|
||||
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).
|
||||
sort_by(&:full_name)
|
||||
render json: @alt_styles.as_json(
|
||||
only: [:id, :species_id, :color_id, :body_id, :series_name,
|
||||
:adjective_name, :thumbnail_url],
|
||||
include: {
|
||||
|
@ -30,4 +49,56 @@ class AltStylesController < ApplicationController
|
|||
}
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
end
|
||||
|
||||
def update
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
|
||||
if @alt_style.update(alt_style_params)
|
||||
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def alt_style_params
|
||||
params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
|
||||
end
|
||||
|
||||
def find_color
|
||||
if params[:color]
|
||||
Color.find_by(name: params[:color])
|
||||
end
|
||||
end
|
||||
|
||||
def find_species
|
||||
if params[:species_id]
|
||||
Species.find_by(id: params[:species_id])
|
||||
elsif params[:species]
|
||||
Species.find_by(name: params[:species])
|
||||
end
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-style"
|
||||
next_unlabeled_style_path
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_style_path
|
||||
unlabeled_style = AltStyle.unlabeled.newest.first
|
||||
if unlabeled_style
|
||||
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,12 +2,10 @@ require 'async'
|
|||
require 'async/container'
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
include FragmentLocalization
|
||||
|
||||
protect_from_forgery
|
||||
|
||||
helper_method :current_user, :user_signed_in?
|
||||
|
||||
helper_method :current_user, :support_staff?, :user_signed_in?
|
||||
|
||||
before_action :set_locale
|
||||
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
|
@ -23,9 +21,12 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
class AccessDenied < StandardError; end
|
||||
rescue_from AccessDenied, with: :on_access_denied
|
||||
|
||||
rescue_from Async::Stop, Async::Container::Terminate,
|
||||
with: :on_request_stopped
|
||||
|
||||
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
|
||||
|
||||
def authenticate_user!
|
||||
redirect_to(new_auth_user_session_path) unless user_signed_in?
|
||||
end
|
||||
|
@ -45,15 +46,15 @@ class ApplicationController < ActionController::Base
|
|||
def user_signed_in?
|
||||
auth_user_signed_in?
|
||||
end
|
||||
|
||||
|
||||
def infer_locale
|
||||
return params[:locale] if valid_locale?(params[:locale])
|
||||
return cookies[:locale] if valid_locale?(cookies[:locale])
|
||||
Rails.logger.debug "Preferred languages: #{http_accept_language.user_preferred_languages}"
|
||||
http_accept_language.language_region_compatible_from(I18n.public_locales.map(&:to_s)) ||
|
||||
http_accept_language.language_region_compatible_from(I18n.available_locales.map(&:to_s)) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
|
||||
def not_found(record_name='record')
|
||||
raise ActionController::RoutingError.new("#{record_name} not found")
|
||||
end
|
||||
|
@ -67,6 +68,11 @@ class ApplicationController < ActionController::Base
|
|||
status: :internal_server_error
|
||||
end
|
||||
|
||||
def on_db_timeout
|
||||
render file: 'public/503.html', layout: false,
|
||||
status: :service_unavailable
|
||||
end
|
||||
|
||||
def redirect_back!(default=:back)
|
||||
redirect_to(params[:return_to] || default)
|
||||
end
|
||||
|
@ -76,7 +82,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def valid_locale?(locale)
|
||||
locale && I18n.usable_locales.include?(locale.to_sym)
|
||||
locale && I18n.available_locales.include?(locale.to_sym)
|
||||
end
|
||||
|
||||
def configure_permitted_parameters
|
||||
|
@ -104,5 +110,13 @@ class ApplicationController < ActionController::Base
|
|||
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
|
||||
return_to || root_path
|
||||
end
|
||||
|
||||
def support_staff?
|
||||
current_user&.support_staff?
|
||||
end
|
||||
|
||||
def support_staff_only
|
||||
raise AccessDenied, "Support staff only" unless support_staff?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
class ItemsController < ApplicationController
|
||||
before_action :set_query
|
||||
before_action :support_staff_only, except: [:index, :show, :sources]
|
||||
rescue_from Item::Search::Error, :with => :search_error
|
||||
|
||||
def index
|
||||
|
@ -112,6 +113,21 @@ class ItemsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@item = Item.find params[:id]
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def update
|
||||
@item = Item.find params[:id]
|
||||
if @item.update(item_params)
|
||||
flash[:notice] = "\"#{@item.name}\" successfully saved!"
|
||||
redirect_to @item
|
||||
else
|
||||
render action: "edit", layout: "application", status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
def sources
|
||||
# Load all the items, then group them by source.
|
||||
item_ids = params[:ids].split(",")
|
||||
|
@ -164,6 +180,15 @@ class ItemsController < ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def item_params
|
||||
params.require(:item).permit(
|
||||
:name, :thumbnail_url, :description, :modeling_status_hint,
|
||||
:is_manually_nc, :explicitly_body_specific,
|
||||
).tap do |p|
|
||||
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
|
||||
end
|
||||
end
|
||||
|
||||
def assign_closeted!(items)
|
||||
current_user.assign_closeted_to_items!(items) if user_signed_in?
|
||||
end
|
||||
|
@ -215,7 +240,8 @@ class ItemsController < ApplicationController
|
|||
@item.compatible_pet_types.
|
||||
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
|
||||
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
|
||||
preferring_simple.first
|
||||
preferring_simple.first ||
|
||||
PetType.matching_name("Blue", "Acara").first!
|
||||
end
|
||||
|
||||
def validate_preview
|
||||
|
|
|
@ -47,29 +47,24 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def new
|
||||
@colors = Color.funny.alphabetical
|
||||
@colors = Color.alphabetical
|
||||
@species = Species.alphabetical
|
||||
|
||||
# HACK: Skip this in development, because it's slow!
|
||||
unless Rails.env.development?
|
||||
newest_items = Item.newest.
|
||||
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
|
||||
.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
newest_items = Item.newest.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
|
||||
@species_count = Species.count
|
||||
|
|
50
app/controllers/pet_states_controller.rb
Normal file
|
@ -0,0 +1,50 @@
|
|||
class PetStatesController < ApplicationController
|
||||
before_action :find_pet_state
|
||||
before_action :support_staff_only
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @pet_state.update(pet_state_params)
|
||||
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
||||
redirect_to 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 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
|
||||
|
||||
if unlabeled_appearance
|
||||
edit_pet_type_pet_state_path(
|
||||
unlabeled_appearance.pet_type,
|
||||
unlabeled_appearance,
|
||||
next: "unlabeled-appearance"
|
||||
)
|
||||
else
|
||||
@pet_type
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,10 +1,111 @@
|
|||
class PetTypesController < ApplicationController
|
||||
def show
|
||||
@pet_type = PetType.
|
||||
where(species_id: params[:species_id]).
|
||||
where(color_id: params[:color_id]).
|
||||
first
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@species_names = Species.order(:name).map(&:human_name)
|
||||
@color_names = Color.order(:name).map(&:human_name)
|
||||
|
||||
render json: @pet_type
|
||||
if params[:species].present?
|
||||
@selected_species = Species.find_by!(name: params[:species])
|
||||
@selected_species_name = @selected_species.human_name
|
||||
end
|
||||
if params[:color].present?
|
||||
@selected_color = Color.find_by!(name: params[:color])
|
||||
@selected_color_name = @selected_color.human_name
|
||||
end
|
||||
@selected_order =
|
||||
if @selected_species.present? || @selected_color.present?
|
||||
:alphabetical
|
||||
else
|
||||
:newest
|
||||
end
|
||||
|
||||
@pet_types = PetType.
|
||||
includes(:color, :species, :pet_states).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
|
||||
@pet_types.where!(species_id: @selected_species) if @selected_species
|
||||
@pet_types.where!(color_id: @selected_color) if @selected_color
|
||||
if @selected_order == :newest
|
||||
@pet_types.order!(created_at: :desc)
|
||||
elsif @selected_order == :alphabetical
|
||||
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
|
||||
end
|
||||
|
||||
if @selected_species && @selected_color && @pet_types.size == 1
|
||||
redirect_to @pet_types.first
|
||||
end
|
||||
|
||||
if support_staff?
|
||||
@counts = {
|
||||
total: PetState.count,
|
||||
glitched: PetState.glitched.count,
|
||||
needs_labeling: PetState.needs_labeling.count,
|
||||
usable: PetState.usable.count,
|
||||
}
|
||||
@unlabeled_appearance = PetState.next_unlabeled_appearance
|
||||
end
|
||||
}
|
||||
|
||||
format.json {
|
||||
if stale?(etag: PetState.last_updated_key)
|
||||
render json: {
|
||||
species: Species.order(:name).all,
|
||||
colors: Color.order(:name).all,
|
||||
supported_poses: PetState.all_supported_poses,
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@pet_type = find_pet_type
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pet_states = group_pet_states @pet_type.pet_states
|
||||
end
|
||||
format.json { render json: @pet_type }
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# The API-ish route uses IDs, but the human-facing route uses names.
|
||||
def find_pet_type
|
||||
if params[:species_id] && params[:color_id]
|
||||
PetType.find_by!(
|
||||
species_id: params[:species_id],
|
||||
color_id: params[:color_id],
|
||||
)
|
||||
elsif params[:name]
|
||||
PetType.find_by_param!(params[:name])
|
||||
else
|
||||
raise "expected params: species_id and color_id, or name"
|
||||
end
|
||||
end
|
||||
|
||||
# The `canonical` pet states are the main ones we want to show: the most
|
||||
# canonical state for each pose. The `other` pet states are, the others!
|
||||
#
|
||||
# If no main poses are available, then we just make all the poses
|
||||
# "canonical", and show the whole mish-mash!
|
||||
def group_pet_states(pet_states)
|
||||
pose_groups = pet_states.emotion_order.group_by(&:pose)
|
||||
main_groups =
|
||||
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
other_groups =
|
||||
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
|
||||
if main_groups.empty?
|
||||
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
|
||||
end
|
||||
|
||||
canonical = main_groups.map(&:first).sort_by(&:pose)
|
||||
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
|
||||
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
|
||||
|
||||
{canonical:, other:}
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
class PetsController < ApplicationController
|
||||
rescue_from Pet::PetNotFound, with: :pet_not_found
|
||||
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
|
||||
rescue_from Pet::DownloadError, with: :pet_download_error
|
||||
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
|
||||
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
|
||||
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
||||
|
||||
def load
|
||||
# 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]
|
||||
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
||||
@pet = Pet.load(params[:name])
|
||||
points = contribute(current_user, @pet)
|
||||
|
||||
|
@ -48,12 +45,6 @@ class PetsController < ApplicationController
|
|||
:status => :not_found
|
||||
end
|
||||
|
||||
def asset_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
pet_load_error :long_message => t('pets.load.asset_download_error'),
|
||||
:status => :gateway_timeout
|
||||
end
|
||||
|
||||
def pet_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
Rails.logger.warn e.backtrace.join("\n")
|
||||
|
|
|
@ -12,13 +12,20 @@ class SwfAssetsController < ApplicationController
|
|||
helpers.image_url("favicon.png"),
|
||||
@swf_asset.image_url,
|
||||
*@swf_asset.canvas_movie_sprite_urls,
|
||||
|
||||
# For images, `images.neopets.com` is a generally safe host to load
|
||||
# from (shouldn't be a vulnerable site or exfiltration vector), and
|
||||
# doing this can help make this header a *lot* shorter, which helps
|
||||
# our nginx reverse proxy (and probably some clients) handle it. (For
|
||||
# example, see asset `667993` for "Engulfed in Flames Effect".)
|
||||
hosts: ["https://images.neopets.com"],
|
||||
)
|
||||
}
|
||||
|
||||
policy.script_src -> {
|
||||
src_list(
|
||||
helpers.javascript_url("lib/easeljs.min"),
|
||||
helpers.javascript_url("lib/tweenjs.min"),
|
||||
helpers.javascript_url("easeljs.min"),
|
||||
helpers.javascript_url("tweenjs.min"),
|
||||
helpers.javascript_url("swf_assets/show"),
|
||||
@swf_asset.canvas_movie_library_url,
|
||||
)
|
||||
|
@ -38,7 +45,14 @@ class SwfAssetsController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def src_list(*urls)
|
||||
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
|
||||
def src_list(*urls, hosts: [])
|
||||
urls.
|
||||
# Ignore any `nil`s that might arise
|
||||
filter(&:present?).
|
||||
# Remove query strings from URLs (they're invalid in CSPs)
|
||||
map { |url| url.sub(/\?.*\z/, "") }.
|
||||
# For the given `hosts`, remove all their specific URLs, and just list
|
||||
# the host itself.
|
||||
reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
|
||||
end
|
||||
end
|
||||
|
|
13
app/helpers/alt_styles_helper.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
module AltStylesHelper
|
||||
def view_or_edit_alt_style_url(alt_style)
|
||||
if support_staff?
|
||||
edit_alt_style_path alt_style
|
||||
else
|
||||
wardrobe_path(
|
||||
species: alt_style.species_id,
|
||||
color: alt_style.color_id,
|
||||
style: alt_style.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,6 +1,4 @@
|
|||
module ApplicationHelper
|
||||
include FragmentLocalization
|
||||
|
||||
def absolute_url(path_or_url)
|
||||
if path_or_url.include?('://') # already an absolute URL
|
||||
path_or_url
|
||||
|
@ -129,10 +127,6 @@ module ApplicationHelper
|
|||
!@hide_home_link
|
||||
end
|
||||
|
||||
def support_staff?
|
||||
user_signed_in? && current_user.support_staff?
|
||||
end
|
||||
|
||||
def impress_2020_meta_tags
|
||||
origin = Rails.configuration.impress_2020_origin
|
||||
support_secret = Rails.application.credentials.dig(
|
||||
|
@ -148,20 +142,9 @@ module ApplicationHelper
|
|||
end
|
||||
end
|
||||
|
||||
JAVASCRIPT_LIBRARIES = {
|
||||
:jquery => 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js',
|
||||
:jquery_tmpl => 'https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js',
|
||||
}
|
||||
|
||||
def include_javascript_libraries(*library_names)
|
||||
raw(library_names.inject('') do |html, name|
|
||||
html + javascript_include_tag(JAVASCRIPT_LIBRARIES[name], defer: true)
|
||||
end)
|
||||
end
|
||||
|
||||
def locale_options
|
||||
current_locale_is_public = false
|
||||
options = I18n.public_locales.map do |available_locale|
|
||||
options = I18n.available_locales.map do |available_locale|
|
||||
current_locale_is_public = true if I18n.locale == available_locale
|
||||
# Include fallbacks data on the tag. Right now it's used in blog
|
||||
# localization, but may conceivably be used for something else later.
|
||||
|
@ -176,13 +159,6 @@ module ApplicationHelper
|
|||
|
||||
options
|
||||
end
|
||||
|
||||
def localized_cache(key={}, &block)
|
||||
localized_key = localize_fragment_key(key, locale)
|
||||
# TODO: The digest feature is handy, but it's not compatible with how we
|
||||
# check for fragments existence in the controller, so skip it for now.
|
||||
cache(localized_key, skip_digest: true, &block)
|
||||
end
|
||||
|
||||
def auth_user_sign_in_path_with_return_to
|
||||
new_auth_user_session_path :return_to => request.fullpath
|
||||
|
@ -237,6 +213,10 @@ module ApplicationHelper
|
|||
@hide_title_header = true
|
||||
end
|
||||
|
||||
def hide_after(last_day, &block)
|
||||
yield if Date.today <= last_day
|
||||
end
|
||||
|
||||
def use_responsive_design
|
||||
@use_responsive_design = true
|
||||
add_body_class "use-responsive-design"
|
||||
|
|
|
@ -14,19 +14,30 @@ module ItemsHelper
|
|||
}
|
||||
|
||||
Sizes = {
|
||||
face: 1,
|
||||
thumb: 2,
|
||||
zoom: 3,
|
||||
full: 4,
|
||||
face_2x: 6,
|
||||
face: 1, # 50x50
|
||||
face_3x: 6, # 150x150
|
||||
|
||||
thumb: 2, # 150x150
|
||||
full: 4, # 300x300
|
||||
large: 5, # 500x500
|
||||
xlarge: 7, # 640x640
|
||||
|
||||
zoom: 3, # 80x80
|
||||
autocrop: 9, # <varies>
|
||||
}
|
||||
|
||||
SizeUpgrades = {
|
||||
face: :face_3x,
|
||||
thumb: :full,
|
||||
full: :xlarge,
|
||||
}
|
||||
end
|
||||
|
||||
def pet_type_image_url(pet_type, emotion: :happy, size: :face)
|
||||
PetTypeImage::Template.expand(
|
||||
hash: pet_type.basic_image_hash || pet_type.image_hash,
|
||||
emotion: PetTypeImage::Emotions[emotion],
|
||||
size: PetTypeImage::Sizes[size],
|
||||
emotion: PetTypeImage::Emotions.fetch(emotion),
|
||||
size: PetTypeImage::Sizes.fetch(size),
|
||||
).to_s
|
||||
end
|
||||
|
||||
|
@ -246,8 +257,10 @@ module ItemsHelper
|
|||
|
||||
def pet_type_image(pet_type, emotion, size, **options)
|
||||
src = pet_type_image_url(pet_type, emotion:, size:)
|
||||
srcset = if size == :face
|
||||
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
|
||||
|
||||
size_2x = PetTypeImage::SizeUpgrades[size]
|
||||
srcset = if size_2x
|
||||
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
|
||||
end
|
||||
|
||||
image_tag(src, srcset:, **options)
|
||||
|
|
|
@ -1,9 +1,4 @@
|
|||
module OutfitsHelper
|
||||
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-09-13")
|
||||
def show_announcement?
|
||||
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
|
||||
end
|
||||
|
||||
def destination_tag(value)
|
||||
hidden_field_tag 'destination', value, :id => nil
|
||||
end
|
||||
|
@ -69,5 +64,12 @@ module OutfitsHelper
|
|||
options = {:spellcheck => false, :id => nil}.merge(options)
|
||||
text_field_tag 'name', nil, options
|
||||
end
|
||||
|
||||
def outfit_viewer(outfit=nil, pet_state: nil, **html_options)
|
||||
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
|
||||
raise "outfit_viewer must have outfit or pet state" if outfit.nil?
|
||||
|
||||
render partial: "outfit_viewer", locals: {outfit:, html_options:}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
41
app/helpers/pet_states_helper.rb
Normal file
|
@ -0,0 +1,41 @@
|
|||
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
|
16
app/helpers/pet_types_helper.rb
Normal file
|
@ -0,0 +1,16 @@
|
|||
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
|
60
app/helpers/support_form_helper.rb
Normal file
|
@ -0,0 +1,60 @@
|
|||
module SupportFormHelper
|
||||
class SupportFormBuilder < ActionView::Helpers::FormBuilder
|
||||
attr_reader :template
|
||||
delegate :capture, :check_box_tag, :content_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(**options, &block)
|
||||
content_tag(:label, class: "go-to-next", **options, &block)
|
||||
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
|
|
@ -1,5 +1,6 @@
|
|||
import "@hotwired/turbo-rails";
|
||||
|
||||
document.getElementById("locale").addEventListener("change", function () {
|
||||
document.addEventListener("change", (e) => {
|
||||
if (!e.target.matches("#locale")) return;
|
||||
document.getElementById("locale-form").submit();
|
||||
});
|
||||
|
|
|
@ -777,8 +777,13 @@ function StyleExplanation() {
|
|||
opacity="0.7"
|
||||
marginTop="2"
|
||||
>
|
||||
<Box as="a" href="/alt-styles" target="_blank" textDecoration="underline">
|
||||
Alt Styles
|
||||
<Box
|
||||
as="a"
|
||||
href="/rainbow-pool/styles"
|
||||
target="_blank"
|
||||
textDecoration="underline"
|
||||
>
|
||||
Pet Styles
|
||||
</Box>{" "}
|
||||
are NC items that override the pet's appearance via the{" "}
|
||||
<Box
|
||||
|
@ -789,7 +794,7 @@ function StyleExplanation() {
|
|||
>
|
||||
Styling Chamber
|
||||
</Box>
|
||||
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
|
||||
. Not all items fit all Pet Styles. The pet's color doesn't have to match.
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,49 +4,68 @@ class AltStyle < ApplicationRecord
|
|||
belongs_to :species
|
||||
belongs_to :color
|
||||
|
||||
has_many :parent_swf_asset_relationships, as: :parent
|
||||
has_many :parent_swf_asset_relationships, as: :parent, dependent: :destroy
|
||||
has_many :swf_assets, through: :parent_swf_asset_relationships
|
||||
has_many :contributions, as: :contributed, inverse_of: :contributed
|
||||
|
||||
validates :body_id, presence: true
|
||||
validates :series_name, presence: true, allow_nil: true
|
||||
validates :thumbnail_url, presence: true
|
||||
|
||||
before_create :infer_series_name
|
||||
before_create :infer_thumbnail_url
|
||||
before_validation :infer_thumbnail_url, unless: :thumbnail_url?
|
||||
|
||||
scope :matching_name, ->(series_name, color_name, species_name) {
|
||||
color = Color.find_by_name!(color_name)
|
||||
species = Species.find_by_name!(species_name)
|
||||
where(series_name:, color_id: color.id, species_id: species.id)
|
||||
}
|
||||
scope :by_creation_date, -> {
|
||||
order("DATE(created_at) DESC")
|
||||
}
|
||||
scope :unlabeled, -> { where(series_name: nil) }
|
||||
scope :newest, -> { order(created_at: :desc) }
|
||||
|
||||
def name
|
||||
def pet_name
|
||||
I18n.translate('pet_types.human_name', color_human_name: color.human_name,
|
||||
species_human_name: species.human_name)
|
||||
end
|
||||
|
||||
alias_method :name, :pet_name
|
||||
|
||||
# If the series_name hasn't yet been set manually by support staff, show the
|
||||
# string "<New?>" instead. But it won't be searchable by that string—that is,
|
||||
# `fits:<New?>-faerie-draik` intentionally will not work, and the canonical
|
||||
# filter name will be `fits:alt-style-IDNUMBER`, instead.
|
||||
def series_name
|
||||
self[:series_name] || "<New?>"
|
||||
real_series_name || AltStyle.placeholder_name
|
||||
end
|
||||
|
||||
def real_series_name=(new_series_name)
|
||||
self[:series_name] = new_series_name
|
||||
end
|
||||
|
||||
def real_series_name
|
||||
self[:series_name]
|
||||
end
|
||||
|
||||
# You can use this to check whether `series_name` is returning the actual
|
||||
# value or its placeholder value.
|
||||
def has_real_series_name?
|
||||
self[:series_name].present?
|
||||
def real_series_name?
|
||||
real_series_name.present?
|
||||
end
|
||||
|
||||
def adjective_name
|
||||
"#{series_name} #{color.human_name}"
|
||||
end
|
||||
|
||||
def preview_image_url
|
||||
swf_asset = swf_assets.first
|
||||
return nil if swf_asset.nil?
|
||||
def full_name
|
||||
"#{series_name} #{name}"
|
||||
end
|
||||
|
||||
swf_asset.image_url
|
||||
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
|
||||
def preview_image_url
|
||||
# Use the image URL for the first asset. Or, fall back to an empty image.
|
||||
swf_assets.first&.image_url || EMPTY_IMAGE_URL
|
||||
end
|
||||
|
||||
# Given a list of items, return how they look on this alt style.
|
||||
|
@ -54,28 +73,6 @@ class AltStyle < ApplicationRecord
|
|||
Item.appearances_for(items, self, ...)
|
||||
end
|
||||
|
||||
def biology=(biology)
|
||||
# TODO: This is very similar to what `PetState` does, but like… much much
|
||||
# more compact? Idk if I'm missing something, or if I was just that much
|
||||
# more clueless back when I wrote it, lol 😅
|
||||
self.swf_assets = biology.values.map do |asset_data|
|
||||
SwfAsset.from_biology_data(self.body_id, asset_data)
|
||||
end
|
||||
end
|
||||
|
||||
# Until the end of 2024, assume new alt styles are from the "Nostalgic"
|
||||
# series. That way, we can stop having to manually label them all as they
|
||||
# come out and get modeled (TNT is prolific rn!), but we aren't gonna get too
|
||||
# greedy and forget about this and use Nostalgic for some far-future thing,
|
||||
# in ways that will certainly be fixable but would also be confusing and
|
||||
# embarrassing.
|
||||
NOSTALGIC_FINAL_DAY = Date.new(2024, 12, 31)
|
||||
def infer_series_name
|
||||
if !has_real_series_name? && Date.today <= NOSTALGIC_FINAL_DAY
|
||||
self.series_name = "Nostalgic"
|
||||
end
|
||||
end
|
||||
|
||||
# At time of writing, most batches of Alt Styles thumbnails used a simple
|
||||
# pattern for the item thumbnail URL, but that's not always the case anymore.
|
||||
# For now, let's keep using this format as the default value when creating a
|
||||
|
@ -85,7 +82,7 @@ class AltStyle < ApplicationRecord
|
|||
)
|
||||
DEFAULT_THUMBNAIL_URL = "https://images.neopets.com/items/mall_bg_circle.gif"
|
||||
def infer_thumbnail_url
|
||||
if has_real_series_name?
|
||||
if real_series_name?
|
||||
self.thumbnail_url = THUMBNAIL_URL_TEMPLATE.expand(
|
||||
series: series_name.gsub(/\s+/, '').downcase,
|
||||
color: color.name.gsub(/\s+/, '').downcase,
|
||||
|
@ -96,6 +93,14 @@ class AltStyle < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def real_thumbnail_url?
|
||||
thumbnail_url != DEFAULT_THUMBNAIL_URL
|
||||
end
|
||||
|
||||
def self.placeholder_name
|
||||
"<New?>"
|
||||
end
|
||||
|
||||
# For convenience in the console!
|
||||
def self.find_by_name(color_name, species_name)
|
||||
color = Color.find_by_name(color_name)
|
||||
|
|
|
@ -161,7 +161,7 @@ class AuthUser < AuthRecord
|
|||
# means we can wrap it in a `with_timeout` block!)
|
||||
neopets_username = Sync do |task|
|
||||
task.with_timeout(5) do
|
||||
NeoPass.load_main_neopets_username(auth.credentials.token)
|
||||
Neopets::NeoPass.load_main_neopets_username(auth.credentials.token)
|
||||
end
|
||||
rescue Async::TimeoutError
|
||||
nil # If the request times out, just move on!
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
class Color < ApplicationRecord
|
||||
has_many :pet_types
|
||||
has_many :alt_styles
|
||||
|
||||
scope :alphabetical, -> { order(:name) }
|
||||
scope :basic, -> { where(basic: true) }
|
||||
scope :standard, -> { where(standard: true) }
|
||||
scope :nonstandard, -> { where(standard: false) }
|
||||
scope :funny, -> { order(:prank) unless pranks_funny? }
|
||||
|
||||
validates :name, presence: true
|
||||
|
||||
|
@ -14,27 +14,23 @@ class Color < ApplicationRecord
|
|||
end
|
||||
|
||||
def human_name
|
||||
if prank? && !Color.pranks_funny?
|
||||
unfunny_human_name + ' ' + I18n.translate('colors.prank_suffix')
|
||||
if name
|
||||
name.split(' ').map { |word| word.capitalize }.join(' ')
|
||||
else
|
||||
unfunny_human_name
|
||||
I18n.translate('colors.default_human_name')
|
||||
end
|
||||
end
|
||||
|
||||
def to_param
|
||||
name? ? human_name : id.to_s
|
||||
end
|
||||
|
||||
def example_pet_type(preferred_species: nil)
|
||||
preferred_species ||= Species.first
|
||||
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
|
||||
"species_id ASC").first
|
||||
end
|
||||
|
||||
def unfunny_human_name
|
||||
if name
|
||||
name.split(' ').map { |word| word.capitalize }.join(' ')
|
||||
else
|
||||
I18n.translate('colors.default_human_name')
|
||||
end
|
||||
end
|
||||
|
||||
def default_gender_presentation
|
||||
if name.downcase.ends_with? "boy"
|
||||
:masc
|
||||
|
@ -45,8 +41,7 @@ class Color < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def self.pranks_funny?
|
||||
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
|
||||
now.month == 4 && now.day == 1
|
||||
def self.param_to_id(param)
|
||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
||||
end
|
||||
end
|
||||
|
|
|
@ -10,16 +10,29 @@ class Item < ApplicationRecord
|
|||
|
||||
SwfAssetType = 'object'
|
||||
|
||||
serialize :cached_compatible_body_ids, coder: Serializers::IntegerSet
|
||||
serialize :cached_occupied_zone_ids, coder: Serializers::IntegerSet
|
||||
|
||||
has_many :closet_hangers
|
||||
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
||||
has_one :contribution, as: :contributed, inverse_of: :contributed
|
||||
has_one :nc_mall_record
|
||||
has_many :parent_swf_asset_relationships, :as => :parent
|
||||
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
||||
has_many :parent_swf_asset_relationships, as: :parent
|
||||
has_many :swf_assets, through: :parent_swf_asset_relationships
|
||||
belongs_to :dyeworks_base_item, class_name: "Item",
|
||||
default: -> { inferred_dyeworks_base_item }, optional: true
|
||||
has_many :dyeworks_variants, class_name: "Item",
|
||||
inverse_of: :dyeworks_base_item
|
||||
|
||||
# We require a name field. A number of other fields must be *specified*: they
|
||||
# can't be nil, to help ensure we aren't forgetting any fields when importing
|
||||
# items. But sometimes they happen to be blank (e.g. when TNT leaves an item
|
||||
# description empty, oops), in which case we want to accept that reality!
|
||||
validates_presence_of :name
|
||||
validates :description, :thumbnail_url, :rarity, :price, :zones_restrict,
|
||||
exclusion: {in: [nil], message: "must be specified"}
|
||||
|
||||
after_save :update_cached_fields,
|
||||
if: :modeling_status_hint_previously_changed?
|
||||
|
||||
attr_writer :current_body_id, :owned, :wanted
|
||||
|
||||
|
@ -60,39 +73,25 @@ class Item < ApplicationRecord
|
|||
where('description NOT LIKE ?',
|
||||
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
|
||||
}
|
||||
scope :is_modeled, -> {
|
||||
where(cached_predicted_fully_modeled: true)
|
||||
}
|
||||
scope :is_not_modeled, -> {
|
||||
where(cached_predicted_fully_modeled: false)
|
||||
}
|
||||
scope :occupies, ->(zone_label) {
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
|
||||
# NOTE: In searches, this query performs much better using a subquery
|
||||
# instead of joins! This is because, in the joins case, filtering by an
|
||||
# `swf_assets` field but sorting by an `items` field causes the query
|
||||
# planner to only be able to use an index for *one* of them. In this case,
|
||||
# MySQL can use the `swf_assets`.`zone_id` index to get the item IDs for
|
||||
# the subquery, then use the `items`.`name` index to sort them.
|
||||
i = arel_table
|
||||
psa = ParentSwfAssetRelationship.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
where(
|
||||
ParentSwfAssetRelationship.joins(:swf_asset).
|
||||
where(sa[:zone_id].in(zone_ids)).
|
||||
where(psa[:parent_type].eq("Item")).
|
||||
where(psa[:parent_id].eq(i[:id])).
|
||||
arel.exists
|
||||
)
|
||||
Zone.matching_label(zone_label).
|
||||
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
|
||||
}
|
||||
scope :not_occupies, ->(zone_label) {
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
i = Item.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
# Querying for "has NO swf_assets matching these zone IDs" is trickier than
|
||||
# the positive case! To do it, we GROUP_CONCAT the zone_ids together for
|
||||
# each item, then use FIND_IN_SET to search the result for each zone ID,
|
||||
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
||||
# so it helps to have other tighter conditions applied first!)
|
||||
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
||||
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
||||
condition = zone_ids.map { 'FIND_IN_SET(?, GROUP_CONCAT(zone_id)) = 0' }.join(' AND ')
|
||||
joins(:swf_assets).group(i[:id]).having(condition, *zone_ids).distinct
|
||||
Zone.matching_label(zone_label).
|
||||
map { |z| not_occupies_zone_id(z.id) }.reduce(all, &:and)
|
||||
}
|
||||
scope :occupies_zone_id, ->(zone_id) {
|
||||
where("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
|
||||
}
|
||||
scope :not_occupies_zone_id, ->(zone_id) {
|
||||
where.not("FIND_IN_SET(?, cached_occupied_zone_ids) > 0", zone_id)
|
||||
}
|
||||
scope :restricts, ->(zone_label) {
|
||||
zone_ids = Zone.matching_label(zone_label).map(&:id)
|
||||
|
@ -105,31 +104,12 @@ class Item < ApplicationRecord
|
|||
where("NOT (#{condition})", *zone_ids)
|
||||
}
|
||||
scope :fits, ->(body_id) {
|
||||
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
|
||||
where("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
|
||||
or(where("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
|
||||
}
|
||||
scope :not_fits, ->(body_id) {
|
||||
i = Item.arel_table
|
||||
sa = SwfAsset.arel_table
|
||||
# Querying for "has NO swf_assets matching these body IDs" is trickier than
|
||||
# the positive case! To do it, we GROUP_CONCAT the body_ids together for
|
||||
# each item, then use FIND_IN_SET to search the result for the body ID,
|
||||
# and assert that it must not find a match. (This is uhh, not exactly fast,
|
||||
# so it helps to have other tighter conditions applied first!)
|
||||
#
|
||||
# TODO: I feel like this could also be solved with a LEFT JOIN, idk if that
|
||||
# performs any better? In Rails 5+ `left_outer_joins` is built in so!
|
||||
#
|
||||
# NOTE: The `fits` and `not_fits` counts don't perfectly add up to the
|
||||
# total number of items, 5 items aren't accounted for? I'm not going to
|
||||
# bother looking into this, but one thing I notice is items with no assets
|
||||
# somehow would not match either scope in this impl (but LEFT JOIN would!)
|
||||
joins(:swf_assets).group(i[:id]).
|
||||
having(
|
||||
"FIND_IN_SET(?, GROUP_CONCAT(body_id)) = 0 AND " +
|
||||
"FIND_IN_SET(0, GROUP_CONCAT(body_id)) = 0",
|
||||
body_id
|
||||
).
|
||||
distinct
|
||||
where.not("FIND_IN_SET(?, cached_compatible_body_ids) > 0", body_id).
|
||||
and(where.not("FIND_IN_SET('0', cached_compatible_body_ids) > 0"))
|
||||
}
|
||||
|
||||
def nc_trade_value
|
||||
|
@ -243,8 +223,14 @@ class Item < ApplicationRecord
|
|||
normalized_name = name.downcase.gsub("female", "girl").gsub("male", "boy").
|
||||
gsub(/\s/, "")
|
||||
|
||||
Color.order(:name).
|
||||
find { |c| normalized_name.include?(c.name.downcase.gsub(/\s/, "")) }
|
||||
# For each color, normalize its name, look for it in the item name, and
|
||||
# return the matching color that appears earliest. (This is important for
|
||||
# items that contain multiple color names, like the "Royal Girl Elephante
|
||||
# Gold Bracelets".)
|
||||
Color.all.to_h { |c| [c, c.name.downcase.gsub(/\s/, "")] }.
|
||||
transform_values { |n| normalized_name.index(n) }.
|
||||
filter { |c, n| n.present? }.
|
||||
min_by { |c, i| i }&.first
|
||||
end
|
||||
|
||||
# If this is a PB item, return the corresponding Species, inferred from the
|
||||
|
@ -290,6 +276,23 @@ class Item < ApplicationRecord
|
|||
restricted_zones + occupied_zones
|
||||
end
|
||||
|
||||
def update_cached_fields
|
||||
# First, clear out some cached instance variables we use for performance,
|
||||
# to ensure we recompute the latest values.
|
||||
@predicted_body_ids = nil
|
||||
@predicted_missing_body_ids = nil
|
||||
|
||||
# We also need to reload our associations, so they include any new records.
|
||||
swf_assets.reload
|
||||
|
||||
# Finally, compute and save our cached fields.
|
||||
self.cached_occupied_zone_ids = occupied_zone_ids
|
||||
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
|
||||
self.cached_predicted_fully_modeled =
|
||||
predicted_fully_modeled?(use_cached: false)
|
||||
self.save!
|
||||
end
|
||||
|
||||
def species_support_ids
|
||||
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
|
||||
end
|
||||
|
@ -299,70 +302,83 @@ class Item < ApplicationRecord
|
|||
replacement = replacement.join(',') if replacement.is_a?(Array)
|
||||
write_attribute('species_support_ids', replacement)
|
||||
end
|
||||
|
||||
def support_species?(species)
|
||||
species_support_ids.blank? || species_support_ids.include?(species.id)
|
||||
end
|
||||
|
||||
def modeled_body_ids
|
||||
@modeled_body_ids ||= swf_assets.select('DISTINCT body_id').map(&:body_id)
|
||||
end
|
||||
|
||||
def modeled_color_ids
|
||||
# Might be empty if modeled_body_ids is 0. But it's currently not called
|
||||
# in that scenario, so, whatever.
|
||||
@modeled_color_ids ||= PetType.select('DISTINCT color_id').
|
||||
where(body_id: modeled_body_ids).
|
||||
map(&:color_id)
|
||||
end
|
||||
|
||||
def basic_body_ids
|
||||
@basic_body_ids ||= begin
|
||||
basic_color_ids ||= Color.select([:id]).basic.map(&:id)
|
||||
PetType.select('DISTINCT body_id').
|
||||
where(color_id: basic_color_ids).map(&:body_id)
|
||||
end
|
||||
def modeling_hinted_done?
|
||||
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
|
||||
end
|
||||
|
||||
def predicted_body_ids
|
||||
@predicted_body_ids ||= if modeled_body_ids.include?(0)
|
||||
@predicted_body_ids ||= if modeling_hinted_done?
|
||||
# If we've manually set this item to no longer report as needing modeling,
|
||||
# predict that the current bodies are all of the compatible bodies.
|
||||
compatible_body_ids
|
||||
elsif compatible_body_ids.include?(0)
|
||||
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This
|
||||
# isn't folded into the case below, in case this item somehow got a
|
||||
# body-specific and non-body-specific asset. In all the cases I've seen
|
||||
# it, that indicates a glitched item, but this method chooses to reflect
|
||||
# behavior elsewhere in the app by saying that we can put this item on
|
||||
# anybody. (Heh. Any body.))
|
||||
modeled_body_ids
|
||||
elsif modeled_body_ids.size == 1
|
||||
compatible_body_ids
|
||||
elsif compatible_body_ids.size == 1
|
||||
# This might just be a species-specific item. Let's be conservative in
|
||||
# our prediction, though we'll revise it if we see another body ID.
|
||||
modeled_body_ids
|
||||
compatible_body_ids
|
||||
elsif compatible_body_ids.size == 0
|
||||
# If somehow we have this item, but not any modeling data for it (weird!),
|
||||
# consider it to fit all standard pet types until shown otherwise.
|
||||
PetType.basic.released_before(released_at_estimate).
|
||||
distinct.pluck(:body_id).sort
|
||||
else
|
||||
# If an item is worn by more than one body, then it must be wearable by
|
||||
# all bodies of the same color. (To my knowledge, anyway. I'm not aware
|
||||
# of any exceptions.) So, let's find those bodies by first finding those
|
||||
# colors.
|
||||
basic_modeled_body_ids, nonbasic_modeled_body_ids = modeled_body_ids.
|
||||
partition { |bi| basic_body_ids.include?(bi) }
|
||||
# First, find our compatible pet types, then pair each body ID with its
|
||||
# color. (As an optimization, we omit standard colors, other than the
|
||||
# basic colors. We also flatten the basic colors into the single color
|
||||
# ID "basic", so we can treat them specially.)
|
||||
compatible_pairs = compatible_pet_types.joins(:color).
|
||||
merge(Color.nonstandard.or(Color.basic)).
|
||||
distinct.pluck(
|
||||
Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id)
|
||||
|
||||
output = []
|
||||
if basic_modeled_body_ids.present?
|
||||
output += basic_body_ids
|
||||
# Group colors by body, to help us find bodies unique to certain colors.
|
||||
compatible_color_ids_by_body_id = {}.tap do |h|
|
||||
compatible_pairs.each do |(color_id, body_id)|
|
||||
h[body_id] ||= []
|
||||
h[body_id] << color_id
|
||||
end
|
||||
end
|
||||
if nonbasic_modeled_body_ids.present?
|
||||
nonbasic_modeled_color_ids = PetType.select('DISTINCT color_id').
|
||||
where(body_id: nonbasic_modeled_body_ids).
|
||||
map(&:color_id)
|
||||
output += PetType.select('DISTINCT body_id').
|
||||
where(color_id: nonbasic_modeled_color_ids).
|
||||
map(&:body_id)
|
||||
end
|
||||
output
|
||||
|
||||
# Find non-basic colors with at least one unique compatible body. (This
|
||||
# means we'll ignore e.g. the Maraquan Mynci, which has the same body as
|
||||
# the Blue Mynci, as not indicating Maraquan compatibility in general.)
|
||||
modelable_color_ids =
|
||||
compatible_color_ids_by_body_id.
|
||||
filter { |k, v| v.size == 1 && v.first != "basic" }.
|
||||
values.map(&:first).uniq
|
||||
|
||||
# We can model on basic pets (perhaps in addition to the above) if we
|
||||
# find at least one compatible basic body that doesn't *also* fit any of
|
||||
# the modelable colors we identified above.
|
||||
basic_is_modelable =
|
||||
compatible_color_ids_by_body_id.values.
|
||||
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
|
||||
|
||||
# Filter to pet types that match the colors that seem compatible.
|
||||
predicted_pet_types =
|
||||
(basic_is_modelable ? PetType.basic : PetType.none).
|
||||
or(PetType.where(color_id: modelable_color_ids))
|
||||
|
||||
# Only include species that were released when this item was. If we don't
|
||||
# know our creation date (we don't have it for some old records), assume
|
||||
# it's pretty old.
|
||||
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
|
||||
|
||||
# Get all body IDs for the pet types we decided are modelable.
|
||||
predicted_pet_types.distinct.pluck(:body_id).sort
|
||||
end
|
||||
end
|
||||
|
||||
def predicted_missing_body_ids
|
||||
@predicted_missing_body_ids ||= predicted_body_ids - modeled_body_ids
|
||||
@predicted_missing_body_ids ||= predicted_body_ids - compatible_body_ids
|
||||
end
|
||||
|
||||
def predicted_missing_standard_body_ids_by_species_id
|
||||
|
@ -382,9 +398,8 @@ class Item < ApplicationRecord
|
|||
end
|
||||
|
||||
def predicted_missing_nonstandard_body_pet_types
|
||||
PetType.joins(:color).
|
||||
where(body_id: predicted_missing_body_ids - basic_body_ids,
|
||||
colors: {standard: false})
|
||||
body_ids = predicted_missing_body_ids - PetType.basic_body_ids
|
||||
PetType.joins(:color).where(body_id: body_ids, colors: {standard: false})
|
||||
end
|
||||
|
||||
def predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
|
@ -409,12 +424,19 @@ class Item < ApplicationRecord
|
|||
body_ids_by_species_by_color
|
||||
end
|
||||
|
||||
def predicted_fully_modeled?
|
||||
def predicted_fully_modeled?(use_cached: true)
|
||||
return cached_predicted_fully_modeled? if use_cached
|
||||
predicted_missing_body_ids.empty?
|
||||
end
|
||||
|
||||
def predicted_modeled_ratio
|
||||
modeled_body_ids.size.to_f / predicted_body_ids.size
|
||||
compatible_body_ids.size.to_f / predicted_body_ids.size
|
||||
end
|
||||
|
||||
# We estimate the item's release time as either when we first saw it, or 2010
|
||||
# if it's so old that we don't have a record.
|
||||
def released_at_estimate
|
||||
created_at || Time.new(2010)
|
||||
end
|
||||
|
||||
def as_json(options={})
|
||||
|
@ -424,7 +446,9 @@ class Item < ApplicationRecord
|
|||
}.merge(options))
|
||||
end
|
||||
|
||||
def compatible_body_ids
|
||||
def compatible_body_ids(use_cached: true)
|
||||
return cached_compatible_body_ids if use_cached
|
||||
|
||||
swf_assets.map(&:body_id).uniq
|
||||
end
|
||||
|
||||
|
|
|
@ -117,7 +117,7 @@ class Item
|
|||
)\z
|
||||
}x
|
||||
def inferred_dyeworks_base_item
|
||||
name_match = name.match(DYEWORKS_NAME_PATTERN)
|
||||
name_match = (name || "").match(DYEWORKS_NAME_PATTERN)
|
||||
return nil if name_match.nil?
|
||||
|
||||
Item.find_by_name(name_match["base"])
|
||||
|
|
|
@ -132,6 +132,8 @@ class Item
|
|||
is_positive ? Filter.is_np : Filter.is_not_np
|
||||
when 'pb'
|
||||
is_positive ? Filter.is_pb : Filter.is_not_pb
|
||||
when 'modeled'
|
||||
is_positive ? Filter.is_modeled : Filter.is_not_modeled
|
||||
else
|
||||
raise_search_error "not_found.label", label: "is:#{value}"
|
||||
end
|
||||
|
@ -346,6 +348,14 @@ class Item
|
|||
self.new Item.is_not_pb, '-is:pb'
|
||||
end
|
||||
|
||||
def self.is_modeled
|
||||
self.new Item.is_modeled, 'is:modeled'
|
||||
end
|
||||
|
||||
def self.is_not_modeled
|
||||
self.new Item.is_not_modeled, '-is:modeled'
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Add quotes around the value, if needed.
|
||||
|
@ -367,7 +377,7 @@ class Item
|
|||
# If the real series name has been set in the database by support
|
||||
# staff, use that for the canonical filter text for this alt style.
|
||||
# Otherwise, represent this alt style by ID.
|
||||
if alt_style.has_real_series_name?
|
||||
if alt_style.real_series_name?
|
||||
series_name = alt_style.series_name.downcase
|
||||
color_name = alt_style.color.name.downcase
|
||||
species_name = alt_style.species.name.downcase
|
||||
|
|
|
@ -4,6 +4,9 @@ class ParentSwfAssetRelationship < ApplicationRecord
|
|||
belongs_to :parent, :polymorphic => true
|
||||
|
||||
belongs_to :swf_asset
|
||||
|
||||
after_save :update_parent_cached_fields
|
||||
after_destroy :update_parent_cached_fields
|
||||
|
||||
def item=(replacement)
|
||||
self.parent = replacement
|
||||
|
@ -16,4 +19,8 @@ class ParentSwfAssetRelationship < ApplicationRecord
|
|||
def pet_state=(replacement)
|
||||
self.parent = replacement
|
||||
end
|
||||
|
||||
def update_parent_cached_fields
|
||||
parent.try(:update_cached_fields)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,82 +1,20 @@
|
|||
require 'rocketamf_extensions/remote_gateway'
|
||||
require 'ostruct'
|
||||
|
||||
class Pet < ApplicationRecord
|
||||
NEOPETS_URL_ORIGIN = ENV['NEOPETS_URL_ORIGIN'] || 'https://www.neopets.com'
|
||||
GATEWAY_URL = NEOPETS_URL_ORIGIN + '/amfphp/gateway.php'
|
||||
GATEWAY = RocketAMFExtensions::RemoteGateway.new(GATEWAY_URL)
|
||||
CUSTOM_PET_SERVICE = GATEWAY.service('CustomPetService')
|
||||
PET_SERVICE = GATEWAY.service('PetService')
|
||||
|
||||
belongs_to :pet_type
|
||||
|
||||
attr_reader :items, :pet_state, :alt_style
|
||||
|
||||
scope :with_pet_type_color_ids, ->(color_ids) {
|
||||
joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
|
||||
}
|
||||
|
||||
def load!(timeout: nil)
|
||||
viewer_data = self.class.fetch_viewer_data(name, timeout:)
|
||||
use_viewer_data(viewer_data)
|
||||
raise ModelingDisabled unless Rails.configuration.modeling_enabled
|
||||
|
||||
viewer_data_hash = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
|
||||
use_modeling_snapshot(ModelingSnapshot.new(viewer_data_hash))
|
||||
end
|
||||
|
||||
def use_viewer_data(viewer_data)
|
||||
pet_data = viewer_data[:custom_pet]
|
||||
|
||||
raise UnexpectedDataFormat unless pet_data[:species_id]
|
||||
raise UnexpectedDataFormat unless pet_data[:color_id]
|
||||
raise UnexpectedDataFormat unless pet_data[:body_id]
|
||||
|
||||
has_alt_style = pet_data[:alt_style].present?
|
||||
|
||||
self.pet_type = PetType.find_or_initialize_by(
|
||||
species_id: pet_data[:species_id].to_i,
|
||||
color_id: pet_data[:color_id].to_i
|
||||
)
|
||||
|
||||
begin
|
||||
new_image_hash = Pet.fetch_image_hash(self.name)
|
||||
rescue => error
|
||||
Rails.logger.warn "Failed to load image hash: #{error.full_message}"
|
||||
end
|
||||
self.pet_type.image_hash = new_image_hash if new_image_hash.present?
|
||||
|
||||
# With an alt style, `body_id` in the biology data refers to the body ID of
|
||||
# the *alt* style, not the usual pet type. (We have `original_biology` for
|
||||
# *some* of the pet type's situation, but not it's body ID!)
|
||||
#
|
||||
# So, in the alt style case, don't update `body_id` - but if this is our
|
||||
# first time seeing this pet type and it doesn't *have* a `body_id` yet,
|
||||
# let's not be creating it without one. We'll need to model it without the
|
||||
# alt style first. (I don't bother with a clear error message though 😅)
|
||||
self.pet_type.body_id = pet_data[:body_id] unless has_alt_style
|
||||
if self.pet_type.body_id.nil?
|
||||
raise UnexpectedDataFormat,
|
||||
"can't process alt style on first occurrence of pet type"
|
||||
end
|
||||
|
||||
pet_state_biology = has_alt_style ? pet_data[:original_biology] :
|
||||
pet_data[:biology_by_zone]
|
||||
raise UnexpectedDataFormat if pet_state_biology.empty?
|
||||
pet_state_biology[0] = nil # remove effects if present
|
||||
@pet_state = self.pet_type.add_pet_state_from_biology! pet_state_biology
|
||||
|
||||
if has_alt_style
|
||||
raise UnexpectedDataFormat unless pet_data[:alt_color]
|
||||
raise UnexpectedDataFormat if pet_data[:biology_by_zone].empty?
|
||||
|
||||
@alt_style = AltStyle.find_or_initialize_by(id: pet_data[:alt_style].to_i)
|
||||
@alt_style.assign_attributes(
|
||||
color_id: pet_data[:alt_color].to_i,
|
||||
species_id: pet_data[:species_id].to_i,
|
||||
body_id: pet_data[:body_id].to_i,
|
||||
biology: pet_data[:biology_by_zone],
|
||||
)
|
||||
end
|
||||
|
||||
@items = Item.collection_from_pet_type_and_registries(self.pet_type,
|
||||
viewer_data[:object_info_registry], viewer_data[:object_asset_registry])
|
||||
def use_modeling_snapshot(snapshot)
|
||||
self.pet_type = snapshot.pet_type
|
||||
@pet_state = snapshot.pet_state
|
||||
@alt_style = snapshot.alt_style
|
||||
@items = snapshot.items
|
||||
end
|
||||
|
||||
def wardrobe_query
|
||||
|
@ -87,6 +25,7 @@ class Pet < ApplicationRecord
|
|||
pose: self.pet_state.pose,
|
||||
state: self.pet_state.id,
|
||||
objects: self.items.map(&:id),
|
||||
style: self.alt_style ? self.alt_style.id : nil,
|
||||
}.to_query
|
||||
end
|
||||
|
||||
|
@ -101,11 +40,8 @@ class Pet < ApplicationRecord
|
|||
|
||||
before_validation do
|
||||
pet_type.save!
|
||||
if @pet_state
|
||||
@pet_state.save!
|
||||
@pet_state.handle_assets!
|
||||
end
|
||||
|
||||
@pet_state.save! if @pet_state
|
||||
|
||||
if @items
|
||||
@items.each do |item|
|
||||
item.save! if item.changed?
|
||||
|
@ -124,60 +60,6 @@ class Pet < ApplicationRecord
|
|||
pet
|
||||
end
|
||||
|
||||
# NOTE: Ideally pet requests shouldn't take this long, but Neopets can be
|
||||
# slow sometimes! Since we're on the Falcon server, long timeouts shouldn't
|
||||
# slow down the rest of the request queue, like it used to be in the past.
|
||||
def self.fetch_viewer_data(name, timeout: 10)
|
||||
request = CUSTOM_PET_SERVICE.action('getViewerData').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:custom_pet][:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def self.fetch_metadata(name, timeout: 10)
|
||||
# If this is an image hash "pet name", it has no metadata.
|
||||
return nil if name.start_with?("@")
|
||||
|
||||
request = PET_SERVICE.action('getPet').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Given a pet's name, load its image hash, for use in `pets.neopets.com`
|
||||
# image URLs. (This corresponds to its current biology and items.)
|
||||
def self.fetch_image_hash(name, timeout: 10)
|
||||
# If this is an image hash "pet name", just take off the `@`!
|
||||
return name[1..] if name.start_with?("@")
|
||||
|
||||
metadata = fetch_metadata(name, timeout:)
|
||||
metadata[:hash]
|
||||
end
|
||||
|
||||
class PetNotFound < RuntimeError;end
|
||||
class DownloadError < RuntimeError;end
|
||||
class UnexpectedDataFormat < RuntimeError;end
|
||||
|
||||
private
|
||||
|
||||
# Send an AMFPHP request, re-raising errors as `Pet::DownloadError`.
|
||||
# Return the response body as a `HashWithIndifferentAccess`.
|
||||
def self.send_amfphp_request(request, timeout: 10)
|
||||
begin
|
||||
response = request.post(timeout: timeout, headers: {
|
||||
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
||||
})
|
||||
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
||||
raise DownloadError, e.message
|
||||
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
||||
raise DownloadError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
HashWithIndifferentAccess.new(response.messages[0].data.body)
|
||||
end
|
||||
class ModelingDisabled < RuntimeError;end
|
||||
end
|
||||
|
||||
|
|
104
app/models/pet/modeling_snapshot.rb
Normal file
|
@ -0,0 +1,104 @@
|
|||
# A representation of a Neopets::CustomPets viewer data response, translated
|
||||
# to DTI's database models!
|
||||
class Pet::ModelingSnapshot
|
||||
def initialize(viewer_data_hash)
|
||||
@custom_pet = viewer_data_hash[:custom_pet]
|
||||
@object_info_registry = viewer_data_hash[:object_info_registry]
|
||||
@object_asset_registry = viewer_data_hash[:object_asset_registry]
|
||||
end
|
||||
|
||||
def pet_type
|
||||
@pet_type ||= begin
|
||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:species_id]
|
||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:color_id]
|
||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:body_id]
|
||||
|
||||
@custom_pet => {species_id:, color_id:}
|
||||
PetType.find_or_initialize_by(species_id:, color_id:).tap do |pet_type|
|
||||
# Apply the pet's body ID to the pet type, unless it's wearing an alt
|
||||
# style, in which case ignore it, because it's the *alt style*'s body ID.
|
||||
# (This can theoretically cause a problem saving a new pet type when
|
||||
# there's an alt style too!)
|
||||
pet_type.body_id = @custom_pet[:body_id] unless @custom_pet[:alt_style]
|
||||
if pet_type.body_id.nil?
|
||||
raise Pet::UnexpectedDataFormat,
|
||||
"can't process alt style on first occurrence of pet type"
|
||||
end
|
||||
|
||||
# Try using this pet for the pet type's thumbnail, but don't worry
|
||||
# if it fails.
|
||||
begin
|
||||
pet_type.consider_pet_image(@custom_pet[:name])
|
||||
rescue => error
|
||||
Rails.logger.warn "Failed to load pet image: #{error.full_message}"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def pet_state
|
||||
@pet_state ||= begin
|
||||
swf_asset_ids = biology_assets.map(&:remote_id)
|
||||
pet_type.pet_states.find_or_initialize_by(swf_asset_ids:).tap do |pet_state|
|
||||
pet_state.swf_assets = biology_assets
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def alt_style
|
||||
@alt_style ||= begin
|
||||
return nil unless @custom_pet[:alt_style]
|
||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:alt_color]
|
||||
|
||||
id = @custom_pet[:alt_style].to_i
|
||||
AltStyle.find_or_initialize_by(id:).tap do |alt_style|
|
||||
alt_style.assign_attributes(
|
||||
color_id: @custom_pet[:alt_color].to_i,
|
||||
species_id: @custom_pet[:species_id].to_i,
|
||||
body_id: @custom_pet[:body_id].to_i,
|
||||
swf_assets: alt_style_assets,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def items
|
||||
@items ||= Item.collection_from_pet_type_and_registries(
|
||||
pet_type, @object_info_registry, @object_asset_registry
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def biology_assets
|
||||
@biology_assets ||= begin
|
||||
biology = @custom_pet[:alt_style].present? ?
|
||||
@custom_pet[:original_biology] :
|
||||
@custom_pet[:biology_by_zone]
|
||||
assets_from_biology(biology)
|
||||
end
|
||||
end
|
||||
|
||||
def item_assets_for(item_id)
|
||||
all_infos = @object_asset_registry.values
|
||||
infos = all_infos.select { |a| a[:obj_info_id].to_i == item_id.to_i }
|
||||
infos.map do |asset_data|
|
||||
remote_id = asset_data[:asset_id].to_i
|
||||
SwfAsset.find_or_initialize_by(type: "object", remote_id:).tap do |swf_asset|
|
||||
swf_asset.origin_pet_type = pet_type
|
||||
swf_asset.origin_object_data = asset_data
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def alt_style_assets
|
||||
raise Pet::UnexpectedDataFormat if @custom_pet[:biology_by_zone].empty?
|
||||
assets_from_biology(@custom_pet[:biology_by_zone])
|
||||
end
|
||||
|
||||
def assets_from_biology(biology)
|
||||
raise Pet::UnexpectedDataFormat if biology.empty?
|
||||
body_id = @custom_pet[:body_id].to_i
|
||||
biology.values.map { |b| SwfAsset.from_biology_data(body_id, b) }
|
||||
end
|
||||
end
|
|
@ -1,20 +1,29 @@
|
|||
class PetState < ApplicationRecord
|
||||
SwfAssetType = 'biology'
|
||||
|
||||
MAIN_POSES = %w(HAPPY_FEM HAPPY_MASC SAD_FEM SAD_MASC SICK_FEM SICK_MASC)
|
||||
|
||||
has_many :contributions, :as => :contributed,
|
||||
:inverse_of => :contributed # in case of duplicates being merged
|
||||
has_many :outfits
|
||||
has_many :parent_swf_asset_relationships, :as => :parent,
|
||||
:autosave => false
|
||||
has_many :parent_swf_asset_relationships, :as => :parent
|
||||
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
||||
|
||||
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
|
||||
|
||||
belongs_to :pet_type
|
||||
|
||||
delegate :color, to: :pet_type
|
||||
delegate :species_id, :species, :color_id, :color, to: :pet_type
|
||||
|
||||
alias_method :swf_asset_ids_from_association, :swf_asset_ids
|
||||
|
||||
attr_writer :parent_swf_asset_relationships_to_update
|
||||
|
||||
scope :glitched, -> { where(glitched: true) }
|
||||
scope :needs_labeling, -> { unlabeled.where(glitched: false) }
|
||||
scope :unlabeled, -> { with_pose("UNKNOWN") }
|
||||
scope :usable, -> { where(labeled: true, glitched: false) }
|
||||
|
||||
scope :newest, -> { order(created_at: :desc) }
|
||||
scope :newest_pet_type, -> { joins(:pet_type).merge(PetType.newest) }
|
||||
|
||||
# A simple ordering that tries to bring reliable pet states to the front.
|
||||
scope :emotion_order, -> {
|
||||
|
@ -71,105 +80,73 @@ class PetState < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def reassign_children_to!(main_pet_state)
|
||||
self.contributions.each do |contribution|
|
||||
contribution.contributed = main_pet_state
|
||||
contribution.save
|
||||
end
|
||||
self.outfits.each do |outfit|
|
||||
outfit.pet_state = main_pet_state
|
||||
outfit.save
|
||||
end
|
||||
ParentSwfAssetRelationship.where(ParentSwfAssetRelationship.arel_table[:parent_id].eq(self.id)).delete_all
|
||||
end
|
||||
|
||||
def reassign_duplicates!
|
||||
raise "This may only be applied to pet states that represent many duplicate entries" unless duplicate_ids
|
||||
pet_states = duplicate_ids.split(',').map do |id|
|
||||
PetState.find(id.to_i)
|
||||
end
|
||||
main_pet_state = pet_states.shift
|
||||
pet_states.each do |pet_state|
|
||||
pet_state.reassign_children_to!(main_pet_state)
|
||||
pet_state.destroy
|
||||
# TODO: More and more, wanting to refactor poses…
|
||||
def pose=(pose)
|
||||
case pose
|
||||
when "UNKNOWN"
|
||||
label_pose nil, nil, unconverted: nil, labeled: false
|
||||
when "HAPPY_MASC"
|
||||
label_pose 1, false
|
||||
when "HAPPY_FEM"
|
||||
label_pose 1, true
|
||||
when "SAD_MASC"
|
||||
label_pose 2, false
|
||||
when "SAD_FEM"
|
||||
label_pose 2, true
|
||||
when "SICK_MASC"
|
||||
label_pose 4, false
|
||||
when "SICK_FEM"
|
||||
label_pose 4, true
|
||||
when "UNCONVERTED"
|
||||
label_pose nil, nil, unconverted: true
|
||||
end
|
||||
end
|
||||
|
||||
def sort_swf_asset_ids!
|
||||
self.swf_asset_ids = swf_asset_ids_array.sort.join(',')
|
||||
def to_param
|
||||
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
|
||||
end
|
||||
|
||||
def swf_asset_ids
|
||||
self['swf_asset_ids']
|
||||
# Because our column is named `swf_asset_ids`, we need to ensure writes to
|
||||
# it go to the attribute, and not the thing ActiveRecord does of finding the
|
||||
# relevant `swf_assets`.
|
||||
# TODO: Consider renaming the column to `cached_swf_asset_ids`?
|
||||
def swf_asset_ids=(new_swf_asset_ids)
|
||||
write_attribute(:swf_asset_ids, new_swf_asset_ids)
|
||||
end
|
||||
|
||||
def swf_asset_ids_array
|
||||
swf_asset_ids.split(',').map(&:to_i)
|
||||
private
|
||||
|
||||
# A helper for the `pose=` method.
|
||||
def label_pose(mood_id, female, unconverted: false, labeled: true)
|
||||
self.labeled = labeled
|
||||
self.mood_id = mood_id
|
||||
self.female = female
|
||||
self.unconverted = unconverted
|
||||
end
|
||||
|
||||
def swf_asset_ids=(ids)
|
||||
self['swf_asset_ids'] = ids
|
||||
end
|
||||
|
||||
def handle_assets!
|
||||
@parent_swf_asset_relationships_to_update.each do |rel|
|
||||
rel.swf_asset.save!
|
||||
rel.save!
|
||||
end
|
||||
def self.last_updated_key
|
||||
PetState.maximum(:updated_at)
|
||||
end
|
||||
|
||||
def self.from_pet_type_and_biology_info(pet_type, info)
|
||||
swf_asset_ids = []
|
||||
info.each do |zone_id, asset_info|
|
||||
if zone_id.present? && asset_info
|
||||
swf_asset_ids << asset_info[:part_id].to_i
|
||||
end
|
||||
end
|
||||
swf_asset_ids_str = swf_asset_ids.sort.join(',')
|
||||
if pet_type.new_record?
|
||||
pet_state = self.new :swf_asset_ids => swf_asset_ids_str
|
||||
else
|
||||
pet_state = self.find_or_initialize_by(
|
||||
pet_type_id: pet_type.id,
|
||||
swf_asset_ids: swf_asset_ids_str
|
||||
)
|
||||
end
|
||||
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
|
||||
where(remote_id: swf_asset_ids)
|
||||
existing_swf_assets_by_id = {}
|
||||
existing_swf_assets.each do |swf_asset|
|
||||
existing_swf_assets_by_id[swf_asset.remote_id] = swf_asset
|
||||
end
|
||||
existing_relationships_by_swf_asset_id = {}
|
||||
unless pet_state.new_record?
|
||||
pet_state.parent_swf_asset_relationships.each do |relationship|
|
||||
existing_relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
|
||||
end
|
||||
end
|
||||
pet_state.pet_type = pet_type # save the second case from having to look it up by ID
|
||||
relationships = []
|
||||
info.each do |zone_id, asset_info|
|
||||
if zone_id.present? && asset_info
|
||||
swf_asset_id = asset_info[:part_id].to_i
|
||||
swf_asset = existing_swf_assets_by_id[swf_asset_id]
|
||||
unless swf_asset
|
||||
swf_asset = SwfAsset.new
|
||||
swf_asset.remote_id = swf_asset_id
|
||||
def self.all_supported_poses
|
||||
Rails.cache.fetch("PetState.all_supported_poses #{last_updated_key}") do
|
||||
{}.tap do |h|
|
||||
includes(:pet_type).find_each do |pet_state|
|
||||
h[pet_state.species_id] ||= {}
|
||||
h[pet_state.species_id][pet_state.color_id] ||= []
|
||||
h[pet_state.species_id][pet_state.color_id] << pet_state.pose
|
||||
end
|
||||
swf_asset.origin_biology_data = asset_info
|
||||
swf_asset.origin_pet_type = pet_type
|
||||
relationship = existing_relationships_by_swf_asset_id[swf_asset.id]
|
||||
unless relationship
|
||||
relationship ||= ParentSwfAssetRelationship.new
|
||||
relationship.parent = pet_state
|
||||
relationship.swf_asset_id = swf_asset.id
|
||||
end
|
||||
relationship.swf_asset = swf_asset
|
||||
relationships << relationship
|
||||
|
||||
h.values.map(&:values).flatten(1).each(&:uniq!).each(&:sort!)
|
||||
end
|
||||
end
|
||||
pet_state.parent_swf_asset_relationships_to_update = relationships
|
||||
pet_state
|
||||
end
|
||||
|
||||
def self.next_unlabeled_appearance
|
||||
# Rather than just getting the newest unlabeled pet state, prioritize the
|
||||
# newest *pet type*. This better matches the user's perception of what the
|
||||
# newest state is, because the Rainbow Pool UI is grouped by pet type!
|
||||
needs_labeling.newest_pet_type.newest.first
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -9,14 +9,13 @@ class PetType < ApplicationRecord
|
|||
has_many :pet_states
|
||||
has_many :pets
|
||||
|
||||
BasicHashes = YAML::load_file(Rails.root.join('config', 'basic_type_hashes.yml'))
|
||||
|
||||
scope :basic, -> { joins(:color).merge(Color.basic) }
|
||||
scope :matching_name, ->(color_name, species_name) {
|
||||
color = Color.find_by_name!(color_name)
|
||||
species = Species.find_by_name!(species_name)
|
||||
where(color_id: color.id, species_id: species.id)
|
||||
}
|
||||
scope :newest, -> { order(created_at: :desc) }
|
||||
scope :preferring_species, ->(species_id) {
|
||||
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
|
||||
}
|
||||
|
@ -28,6 +27,10 @@ class PetType < ApplicationRecord
|
|||
merge(Species.order(name: :asc)).
|
||||
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
|
||||
}
|
||||
scope :released_before, ->(time) {
|
||||
# We use DTI's creation timestamp as an estimate of when it was released.
|
||||
where('created_at <= ?', time)
|
||||
}
|
||||
|
||||
def self.random_basic_per_species(species_ids)
|
||||
random_pet_types = []
|
||||
|
@ -52,17 +55,15 @@ class PetType < ApplicationRecord
|
|||
# Otherwise, refer to the fallback YAML file (though, if we have our
|
||||
# basic image hashes set correctly, the fallbacks should just be an old
|
||||
# subset of the basic image hashes in the database.)
|
||||
basic_image_hash || self['image_hash'] || fallback_image_hash
|
||||
basic_image_hash || self['image_hash'] || 'deadbeef'
|
||||
end
|
||||
|
||||
def fallback_image_hash
|
||||
I18n.with_locale(I18n.default_locale) do
|
||||
if species && color && BasicHashes[species.name] && BasicHashes[species.name][color.name]
|
||||
BasicHashes[species.name][color.name]
|
||||
else
|
||||
return 'deadbeef'
|
||||
end
|
||||
end
|
||||
def consider_pet_image(pet_name)
|
||||
# If we already have a basic image hash, don't worry about it!
|
||||
return if basic_image_hash?
|
||||
|
||||
# Otherwise, use this as the new image hash for this pet type.
|
||||
self.image_hash = Neopets::CustomPets.fetch_image_hash(pet_name)
|
||||
end
|
||||
|
||||
def possibly_new_color
|
||||
|
@ -79,11 +80,6 @@ class PetType < ApplicationRecord
|
|||
species_human_name: possibly_new_species.human_name)
|
||||
end
|
||||
|
||||
def add_pet_state_from_biology!(biology)
|
||||
pet_state = PetState.from_pet_type_and_biology_info(self, biology)
|
||||
pet_state
|
||||
end
|
||||
|
||||
def canonical_pet_state
|
||||
# For consistency (randomness is always scary!), we use the PetType ID to
|
||||
# determine which gender to prefer, if it's not built into the color. That
|
||||
|
@ -120,6 +116,44 @@ class PetType < ApplicationRecord
|
|||
Item.appearances_for(item, self, ...)
|
||||
end
|
||||
|
||||
def to_param
|
||||
"#{possibly_new_color.to_param}-#{possibly_new_species.to_param}"
|
||||
end
|
||||
|
||||
def fully_labeled?
|
||||
num_missing_poses == 0
|
||||
end
|
||||
|
||||
def num_poses
|
||||
all_poses = pet_states.map(&:pose)
|
||||
PetState::MAIN_POSES.count { |pose| all_poses.include? pose }
|
||||
end
|
||||
|
||||
def num_missing_poses
|
||||
PetState::MAIN_POSES.count - num_poses
|
||||
end
|
||||
|
||||
def num_unlabeled_states
|
||||
pet_states.count { |ps| ps.pose == "UNKNOWN" }
|
||||
end
|
||||
|
||||
def reference
|
||||
PetType.where(species_id: species).basic.merge(Color.alphabetical).first
|
||||
end
|
||||
|
||||
def self.find_by_param!(param)
|
||||
raise ActiveRecord::RecordNotFound unless param.include?("-")
|
||||
color_param, _, species_param = param.rpartition("-")
|
||||
where(
|
||||
color_id: Color.param_to_id(color_param),
|
||||
species_id: Species.param_to_id(species_param),
|
||||
).first!
|
||||
end
|
||||
|
||||
def self.basic_body_ids
|
||||
PetType.basic.distinct.pluck(:body_id)
|
||||
end
|
||||
|
||||
def self.all_by_ids_or_children(ids, pet_states)
|
||||
pet_states_by_pet_type_id = {}
|
||||
pet_states.each do |pet_state|
|
||||
|
@ -139,7 +173,5 @@ class PetType < ApplicationRecord
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
class DownloadError < Exception;end
|
||||
end
|
||||
|
||||
|
|
|
@ -16,6 +16,10 @@ class Species < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
def to_param
|
||||
name? ? human_name : id.to_s
|
||||
end
|
||||
|
||||
# Given a list of body IDs, return a hash from body ID to Species.
|
||||
# (We assume that each body ID belongs to just one species; if not, which
|
||||
# species we return for that body ID is undefined.)
|
||||
|
@ -26,4 +30,8 @@ class Species < ApplicationRecord
|
|||
to_h { |s| [s.id, s] }
|
||||
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
|
||||
end
|
||||
|
||||
def self.param_to_id(param)
|
||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,8 +2,6 @@ require 'addressable/template'
|
|||
require 'async'
|
||||
require 'async/barrier'
|
||||
require 'async/semaphore'
|
||||
require 'fileutils'
|
||||
require 'uri'
|
||||
|
||||
class SwfAsset < ApplicationRecord
|
||||
# We use the `type` column to mean something other than what Rails means!
|
||||
|
@ -322,14 +320,6 @@ class SwfAsset < ApplicationRecord
|
|||
swf_asset
|
||||
end
|
||||
|
||||
def self.from_wardrobe_link_params(ids)
|
||||
where((
|
||||
arel_table[:remote_id].in(ids[:biology]).and(arel_table[:type].eq('biology'))
|
||||
).or(
|
||||
arel_table[:remote_id].in(ids[:object]).and(arel_table[:type].eq('object'))
|
||||
))
|
||||
end
|
||||
|
||||
# Given a list of SWF assets, ensure all of their manifests are loaded, with
|
||||
# fast concurrent execution!
|
||||
def self.preload_manifests(swf_assets)
|
||||
|
@ -373,6 +363,4 @@ class SwfAsset < ApplicationRecord
|
|||
# linked to it, meaning that it's probably wearable by all bodies.
|
||||
self.body_id = 0 if !@body_id_overridden && (!self.body_specific? || (!self.new_record? && self.body_id_changed?))
|
||||
end
|
||||
|
||||
class DownloadError < Exception;end
|
||||
end
|
||||
|
|
67
app/services/neopets/custom_pets.rb
Normal file
|
@ -0,0 +1,67 @@
|
|||
require 'rocketamf_extensions/remote_gateway'
|
||||
|
||||
module Neopets::CustomPets
|
||||
GATEWAY_URL =
|
||||
Addressable::URI.parse(Rails.configuration.neopets_origin) +
|
||||
'/amfphp/gateway.php'
|
||||
GATEWAY = RocketAMFExtensions::RemoteGateway.new(GATEWAY_URL)
|
||||
CUSTOM_PET_SERVICE = GATEWAY.service('CustomPetService')
|
||||
PET_SERVICE = GATEWAY.service('PetService')
|
||||
|
||||
class << self
|
||||
# NOTE: Ideally pet requests shouldn't take this long, but Neopets can be
|
||||
# slow sometimes! Since we're on the Falcon server, long timeouts shouldn't
|
||||
# slow down the rest of the request queue, like it used to be in the past.
|
||||
def fetch_viewer_data(name, timeout: 10)
|
||||
request = CUSTOM_PET_SERVICE.action('getViewerData').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:custom_pet][:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_metadata(name, timeout: 10)
|
||||
# If this is an image hash "pet name", it has no metadata.
|
||||
return nil if name.start_with?("@")
|
||||
|
||||
request = PET_SERVICE.action('getPet').request([name])
|
||||
send_amfphp_request(request).tap do |data|
|
||||
if data[:name].blank?
|
||||
raise PetNotFound, "Pet #{name.inspect} does not exist"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Given a pet's name, load its image hash, for use in `pets.neopets.com`
|
||||
# image URLs. (This corresponds to its current biology and items.)
|
||||
def fetch_image_hash(name, timeout: 10)
|
||||
# If this is an image hash "pet name", just take off the `@`!
|
||||
return name[1..] if name.start_with?("@")
|
||||
|
||||
metadata = fetch_metadata(name, timeout:)
|
||||
metadata[:hash]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Send an AMFPHP request, re-raising errors as `DownloadError`.
|
||||
# Return the response body as a `HashWithIndifferentAccess`.
|
||||
def send_amfphp_request(request, timeout: 10)
|
||||
begin
|
||||
response_data = request.post(timeout: timeout, headers: {
|
||||
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
||||
})
|
||||
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
||||
raise DownloadError, e.message
|
||||
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
||||
raise DownloadError, e.message, e.backtrace
|
||||
end
|
||||
|
||||
HashWithIndifferentAccess.new(response_data)
|
||||
end
|
||||
end
|
||||
|
||||
class PetNotFound < RuntimeError;end
|
||||
class DownloadError < RuntimeError;end
|
||||
end
|
|
@ -1,7 +1,7 @@
|
|||
require "addressable/template"
|
||||
require "async/http/internet/instance"
|
||||
|
||||
module NCMall
|
||||
module Neopets::NCMall
|
||||
# Share a pool of persistent connections, rather than reconnecting on
|
||||
# each request. (This library does that automatically!)
|
||||
INTERNET = Async::HTTP::Internet.instance
|
||||
|
@ -45,6 +45,37 @@ module NCMall
|
|||
uniq
|
||||
end
|
||||
|
||||
STYLING_STUDIO_URL = "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php"
|
||||
def self.load_styles(species_id:, neologin:)
|
||||
Sync do
|
||||
INTERNET.post(
|
||||
STYLING_STUDIO_URL,
|
||||
headers: [
|
||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||
["Content-Type", "application/x-www-form-urlencoded"],
|
||||
["Cookie", "neologin=#{neologin}"],
|
||||
["X-Requested-With", "XMLHttpRequest"],
|
||||
],
|
||||
body: {tab: 1, mode: "getStyles", species: species_id}.to_query,
|
||||
) do |response|
|
||||
if response.status != 200
|
||||
raise ResponseNotOK.new(response.status),
|
||||
"expected status 200 but got #{response.status} (#{STYLING_STUDIO_URL})"
|
||||
end
|
||||
|
||||
begin
|
||||
data = JSON.parse(response.read).deep_symbolize_keys
|
||||
|
||||
# HACK: styles is a hash, unless it's empty, in which case it's an
|
||||
# array? Weird. Normalize this by converting to hash.
|
||||
data.fetch(:styles).to_h.values
|
||||
rescue JSON::ParserError, KeyError
|
||||
raise UnexpectedResponseFormat
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def self.load_page_by_url(url)
|
||||
|
@ -76,11 +107,20 @@ module NCMall
|
|||
raise UnexpectedResponseFormat, "missing field object_data in NC page"
|
||||
end
|
||||
|
||||
object_data = nc_page["object_data"]
|
||||
|
||||
# NOTE: When there's no object data, it will be an empty array instead of
|
||||
# an empty hash. Weird API thing to work around!
|
||||
nc_page["object_data"] = {} if nc_page["object_data"] == []
|
||||
object_data = {} if object_data == []
|
||||
|
||||
items = nc_page["object_data"].values.map do |item_info|
|
||||
# Only the items in the `render` list are actually listed as directly for
|
||||
# sale in the shop. `object_data` might contain other items that provide
|
||||
# supporting information about them, but aren't actually for sale.
|
||||
visible_object_data = (nc_page["render"] || []).
|
||||
map { |id| object_data[id.to_s] }.
|
||||
filter(&:present?)
|
||||
|
||||
items = visible_object_data.map do |item_info|
|
||||
{
|
||||
id: item_info["id"],
|
||||
name: item_info["name"],
|
|
@ -2,7 +2,7 @@ require "async/http/internet/instance"
|
|||
|
||||
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
|
||||
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
|
||||
module NeoPass
|
||||
module Neopets::NeoPass
|
||||
# Share a pool of persistent connections, rather than reconnecting on
|
||||
# each request. (This library does that automatically!)
|
||||
INTERNET = Async::HTTP::Internet.instance
|
|
@ -1,76 +0,0 @@
|
|||
- title "NeoPass for DTI"
|
||||
|
||||
= image_tag 'about/neopass-header.png',
|
||||
alt: "Header image of three Neopets wearing NeoPass badges on lanyards",
|
||||
width: 800, height: 232
|
||||
|
||||
:markdown
|
||||
Hi, everyone! We've got big news coming up: we're partnering with Neopets to
|
||||
add "Login with NeoPass" and some other integrations to bring our sites a
|
||||
bit closer together! Here's what to expect and why.
|
||||
_(Posted: March 13, 2024)_
|
||||
|
||||
## Login with NeoPass
|
||||
|
||||
Over time, Neopets is planning to send more users our way, and we want them
|
||||
to have a smooth experience when they get here!
|
||||
|
||||
**So, new users will be able to click "Login with NeoPass" and use their
|
||||
existing Neopets account**, instead of creating a new DTI username and
|
||||
password. Existing DTI users can also link accounts if they want, too!
|
||||
|
||||
**All of this functionality is optional, and removable at any time!**
|
||||
Usernames and passwords will still work as before—and unlike official
|
||||
Neopets accounts that need long-term permanent linkage, we intend to offer
|
||||
both linking and unlinking, so you can always have options.
|
||||
|
||||
We also know that a _lot_ of the pain points in Neopets and DTI right now come
|
||||
from transferring info between our sites by hand. **It's possible this could
|
||||
set us up for other smoother experiences in the future, too!** (Nothing like
|
||||
that in the first release though—we've just been chatting with TNT about what
|
||||
might come next!)
|
||||
|
||||
## Links to NC Mall
|
||||
|
||||
We're also planning to add **a few links from DTI to the NC Mall**, which
|
||||
we'll do our best to make thoughtful and unobtrusive. There's two main
|
||||
reasons for this!
|
||||
|
||||
First off, when Neopets sends users our way, we don't want them to get
|
||||
confused and stuck here. Existing DTI users know their way around NC, but new
|
||||
users probably won't, so we'll add a couple hints for how to get their
|
||||
designs onto Neopets.com.
|
||||
|
||||
The second reason is: we believe Dress to Impress is a critical part of the
|
||||
Neopets economy, and we want TNT to be able to see that, too. We'll include
|
||||
**a lil referral code in the link** so TNT can know which shoppers came from
|
||||
DTI, and can evaluate accordingly. (We expect this to be important for us
|
||||
long-term!)
|
||||
|
||||
## Why now?
|
||||
|
||||
Dress to Impress has always been a **very small-staff volunteer project**, and
|
||||
it's been clear to everyone over the past few years that we're struggling to
|
||||
balance DTI with the rest of our lives 😖 Work and life and family have their
|
||||
own needs, and they've been increasing!
|
||||
|
||||
And so… there are reasons we're being careful talking about details right
|
||||
now, but the gist is: we're hoping that partnering with TNT will not only
|
||||
help us fill gaps in the customization user experience, but can also be part
|
||||
of **a more sustainable future for Dress to Impress long-term**. I hope we
|
||||
can tell you more about it soon!
|
||||
|
||||
I know full well, and I'm sure you do too, that partnerships between
|
||||
companies and fan projects can be complicated. I promise I'm doing my best to
|
||||
represent you all, focusing on securing what's right for the community, and
|
||||
keeping in mind the importance of autonomy! We'll keep DTI independent, only
|
||||
do things we believe genuinely serve everyone, and keep a critical eye as we
|
||||
go.
|
||||
|
||||
So, yeah! It's NeoPass time! We'll be working on this in the coming months,
|
||||
and I'll let you know more along the way. If you have questions or thoughts,
|
||||
please email me at <matchu@openneo.net>, and I'll do my best to listen and
|
||||
help!
|
||||
|
||||
Thanks as always, everyone. We'll talk more soon! 💖
|
||||
_—Matchu_
|
|
@ -1,4 +1,12 @@
|
|||
%li.alt-style
|
||||
= link_to alt_style.preview_image_url do
|
||||
= image_tag alt_style.thumbnail_url, class: 'alt-style-thumbnail'
|
||||
.alt-style-name= alt_style.name
|
||||
%li
|
||||
= link_to view_or_edit_alt_style_url(alt_style) do
|
||||
= image_tag alt_style.preview_image_url, class: "preview", loading: "lazy"
|
||||
.name
|
||||
%span= alt_style.series_name
|
||||
%span= alt_style.pet_name
|
||||
.info
|
||||
%p
|
||||
Added
|
||||
= time_tag alt_style.created_at,
|
||||
title: alt_style.created_at.to_formatted_s(:long_nst) do
|
||||
= time_with_only_month_if_old alt_style.created_at
|
37
app/views/alt_styles/edit.html.haml
Normal file
|
@ -0,0 +1,37 @@
|
|||
- title @alt_style.full_name
|
||||
- use_responsive_design
|
||||
|
||||
%ol.breadcrumbs
|
||||
%li= link_to "Alt Styles", alt_styles_path
|
||||
%li
|
||||
= link_to @alt_style.color.human_name,
|
||||
alt_styles_path(color: @alt_style.color.human_name)
|
||||
%li{"data-relation-to-prev": "sibling"}
|
||||
= link_to @alt_style.species.human_name,
|
||||
alt_styles_path(species: @alt_style.species.human_name)
|
||||
%li= @alt_style.series_name
|
||||
|
||||
= image_tag @alt_style.preview_image_url, class: "alt-style-preview"
|
||||
|
||||
= support_form_with model: @alt_style, class: "support-form" do |f|
|
||||
= f.errors
|
||||
|
||||
= f.fields do
|
||||
= f.field do
|
||||
= f.label :real_series_name, "Series"
|
||||
= f.text_field :real_series_name, autofocus: !@alt_style.real_series_name?,
|
||||
placeholder: AltStyle.placeholder_name
|
||||
|
||||
= f.field do
|
||||
= f.label :thumbnail_url, "Thumbnail"
|
||||
= f.thumbnail_input :thumbnail_url
|
||||
|
||||
= f.actions do
|
||||
= f.submit "Save changes"
|
||||
= f.go_to_next_field title: "If checked, takes you to the next unlabeled pet style, if any. Useful for labeling in bulk!" do
|
||||
= f.go_to_next_check_box "unlabeled-style"
|
||||
Then: Go to unlabeled style
|
||||
|
||||
- content_for :stylesheets do
|
||||
= stylesheet_link_tag "application/breadcrumbs", "application/support-form"
|
||||
= page_stylesheet_link_tag "alt_styles/edit"
|
|
@ -1,18 +1,46 @@
|
|||
- title "Styling Studio"
|
||||
- title "NC Pet Styles"
|
||||
- use_responsive_design
|
||||
|
||||
%p
|
||||
Here's all the new NC Pet Styles we have! They're available in the app too,
|
||||
by opening the emotion picker and clicking the "Styles" tab.
|
||||
%ul.breadcrumbs
|
||||
%li= link_to "Rainbow Pool", pet_types_path
|
||||
%li Pet Styles
|
||||
|
||||
%p
|
||||
If you have an Alt Style we don't, please model it by entering your pet's
|
||||
:markdown
|
||||
Pet Styles drastically change the appearance of your pet! They're [available
|
||||
in the NC Mall][1], or via "NC Trading". Some of them are "Nostalgic",
|
||||
meaning they're reminiscent of classic Neopets designs from long ago—and some
|
||||
are brand new!
|
||||
|
||||
Pet Styles only fit pets of the same species—but the *color* of the pet
|
||||
doesn't matter! A Blue Acara can wear the "Nostalgic Faerie Acara" Pet Style.
|
||||
|
||||
Only some items fit pets wearing Pet Styles: mostly Backgrounds, Foregrounds,
|
||||
and other items that aren't designed to fit a specific body shape.
|
||||
|
||||
If you have a Pet Style we don't, please model it by entering your pet's
|
||||
name on the homepage! Thank you! 💖
|
||||
|
||||
%p
|
||||
Also, heads-up: Because our system can only collect "item data" for normal
|
||||
wearable items, there's not a great way for us to get style tokens onto
|
||||
tradelists… this may change someday, but probably not soon, sorry!
|
||||
[1]: https://www.neopets.com/mall/stylingstudio/
|
||||
|
||||
- @alt_styles.group_by(&:species).each do |species, species_styles|
|
||||
%h2.alt-styles-header= species.human_name
|
||||
%ul.alt-styles-list= render partial: "alt_style", collection: species_styles
|
||||
= form_with url: alt_styles_path, method: :get,
|
||||
class: "rainbow-pool-filters" do |f|
|
||||
%fieldset
|
||||
%legend Filter by:
|
||||
= f.select :series, @all_series_names,
|
||||
selected: @series_name, include_blank: "Style…"
|
||||
= f.select :color, @all_color_names,
|
||||
selected: @color&.human_name, include_blank: "Color…"
|
||||
= f.select :species, @all_species_names,
|
||||
selected: @species&.human_name, include_blank: "Species…"
|
||||
= f.submit "Go", name: nil
|
||||
|
||||
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
||||
|
||||
%ul.rainbow-pool-list= render @alt_styles
|
||||
|
||||
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
||||
|
||||
- content_for :stylesheets do
|
||||
= stylesheet_link_tag "application/breadcrumbs"
|
||||
= stylesheet_link_tag "application/rainbow-pool"
|
||||
= page_stylesheet_link_tag "alt_styles/index"
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
%outfit-viewer
|
||||
- html_options = {} unless defined? html_options
|
||||
= content_tag "outfit-viewer", **html_options do
|
||||
.loading-indicator= render partial: "hanger_spinner"
|
||||
|
||||
%label.play-pause-button{title: "Pause/play animations"}
|
||||
|
@ -21,6 +22,6 @@
|
|||
- if swf_asset.canvas_movie?
|
||||
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
|
||||
- elsif swf_asset.image_url.present?
|
||||
= image_tag swf_asset.image_url, alt: ""
|
||||
= image_tag swf_asset.image_url, alt: "", loading: "lazy"
|
||||
- else
|
||||
/ No movie or image available for SWF asset: #{swf_asset.url}
|
||||
/ No movie or image available for SWF asset: #{swf_asset.url}
|
7
app/views/application/support_form/_errors.html.haml
Normal file
|
@ -0,0 +1,7 @@
|
|||
- if form.object.errors.any?
|
||||
%section.errors
|
||||
Could not save:
|
||||
|
||||
%ul
|
||||
- form.object.errors.each do |error|
|
||||
%li= error.full_message
|
|
@ -0,0 +1,4 @@
|
|||
= form.field("data-type": "radio", **options) do
|
||||
%fieldset
|
||||
%legend= legend
|
||||
%ul= content
|
|
@ -0,0 +1,5 @@
|
|||
- url = form.object.send(method)
|
||||
.thumbnail-input
|
||||
- if url.present?
|
||||
= image_tag url, alt: "Thumbnail"
|
||||
= form.url_field method
|
|
@ -151,9 +151,8 @@
|
|||
= stylesheet_link_tag 'https://ajax.googleapis.com/ajax/libs/jqueryui/1.9.0/themes/south-street/jquery-ui.css'
|
||||
|
||||
- content_for :javascripts do
|
||||
= include_javascript_libraries :jquery, :jquery_tmpl
|
||||
= javascript_include_tag 'ajax_auth', 'lib/jquery.ui', 'lib/jquery.jgrowl',
|
||||
defer: true
|
||||
= javascript_include_tag 'jquery', 'jquery.tmpl', 'jquery.ui',
|
||||
'jquery.jgrowl', defer: true
|
||||
|
||||
- content_for :javascripts_body do
|
||||
= javascript_include_tag 'closet_hangers/index', defer: true
|
||||
|
@ -167,4 +166,4 @@
|
|||
%meta{
|
||||
name: "trade-matches-wants",
|
||||
value: @items.select(&:wanted?).map(&:id).join(",")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,27 +7,28 @@
|
|||
%p= twl '.instructions',
|
||||
:edit_petpage_link_url => 'https://www.neopets.com/edithomepage.phtml'
|
||||
|
||||
= form_tag petpage_user_closet_hangers_path(@user), :method => :get, :id => 'petpage-closet-lists' do
|
||||
= hidden_field_tag 'filter', '1'
|
||||
- @closet_lists_by_owned.each do |owned, closet_lists|
|
||||
%div
|
||||
%h4= closet_lists_group_name(:you, owned)
|
||||
%ul
|
||||
- closet_lists.each do |closet_list|
|
||||
%li
|
||||
- unless @closet_lists_by_owned.values.all?(&:empty?)
|
||||
= form_tag petpage_user_closet_hangers_path(@user), :method => :get, :id => 'petpage-closet-lists' do
|
||||
= hidden_field_tag 'filter', '1'
|
||||
- @closet_lists_by_owned.each do |owned, closet_lists|
|
||||
%div
|
||||
%h4= closet_lists_group_name(:you, owned)
|
||||
%ul
|
||||
- closet_lists.each do |closet_list|
|
||||
%li
|
||||
= label_tag do
|
||||
= check_box_tag "lists[#{closet_list.id}]", '1', petpage_closet_list_checked(closet_list, owned)
|
||||
= closet_list.name
|
||||
%li.unlisted
|
||||
= label_tag do
|
||||
= check_box_tag "lists[#{closet_list.id}]", '1', petpage_closet_list_checked(closet_list, owned)
|
||||
= closet_list.name
|
||||
%li.unlisted
|
||||
= label_tag do
|
||||
= check_box_tag "groups[#{owned}]", '1', petpage_group_checked(owned)
|
||||
= t 'closet_lists.unlisted_name'
|
||||
= submit_tag t('.submit')
|
||||
= check_box_tag "groups[#{owned}]", '1', petpage_group_checked(owned)
|
||||
= t 'closet_lists.unlisted_name'
|
||||
= submit_tag t('.submit')
|
||||
|
||||
%textarea#petpage-output
|
||||
= '' + render('petpage_content',
|
||||
:lists_by_owned => @visible_closet_lists_by_owned,
|
||||
:unlisted_hangers_by_owned => @visible_unlisted_closet_hangers_by_owned)
|
||||
|
||||
= include_javascript_libraries :jquery
|
||||
= javascript_include_tag 'closet_hangers/petpage'
|
||||
- content_for :stylesheets do
|
||||
= stylesheet_link_tag 'closet_hangers/petpage'
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
- contributed = contribution.contributed
|
||||
= content_tag_for :li, contribution do
|
||||
%li
|
||||
%span.point-value= contribution.point_value
|
||||
= t 'contributions.contribution.description_html',
|
||||
:user_link => link_to(contribution.user.name,
|
||||
|
|
|
@ -42,10 +42,7 @@
|
|||
= f.submit 'Save donation details'
|
||||
|
||||
- content_for :javascripts do
|
||||
= include_javascript_libraries :jquery
|
||||
|
||||
- content_for :javascripts_body do
|
||||
= javascript_include_tag 'fundraising/donations/show', defer: true
|
||||
= javascript_include_tag 'jquery', 'fundraising/donations/show', defer: true
|
||||
|
||||
- content_for :stylesheets do
|
||||
= page_stylesheet_link_tag 'fundraising/donations/show'
|
||||
|
|
|
@ -46,6 +46,8 @@
|
|||
= link_to t('items.show.closet_hangers.button'),
|
||||
user_closet_hangers_path(current_user),
|
||||
class: 'user-lists-form-opener'
|
||||
- if support_staff?
|
||||
= link_to "Edit", edit_item_path(item)
|
||||
|
||||
- if user_signed_in?
|
||||
= form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do
|
||||
|
@ -100,4 +102,4 @@
|
|||
'data-is-current' => current_subpage == 'trades_seeking'
|
||||
|
||||
- content_for :javascripts do
|
||||
= javascript_include_tag 'items/item_header', defer: true
|
||||
= javascript_include_tag 'items/item_header', async: true
|
||||
|
|
58
app/views/items/edit.html.haml
Normal file
|
@ -0,0 +1,58 @@
|
|||
- title "Editing \"#{@item.name}\""
|
||||
- use_responsive_design
|
||||
|
||||
%h1#title Editing "#{@item.name}"
|
||||
|
||||
:markdown
|
||||
Heads up: the modeling process controls some of these fields by default! If
|
||||
you change something, but it doesn't match what we're seeing on Neopets.com,
|
||||
it will probably be reverted automatically when someone models it.
|
||||
|
||||
= support_form_with model: @item, class: "support-form" do |f|
|
||||
= f.errors
|
||||
|
||||
= f.fields do
|
||||
= f.field do
|
||||
= f.label :name
|
||||
= f.text_field :name
|
||||
|
||||
= f.field do
|
||||
= f.label :thumbnail_url, "Thumbnail"
|
||||
= f.thumbnail_input :thumbnail_url
|
||||
|
||||
= f.field do
|
||||
= f.label :description
|
||||
= f.text_field :description
|
||||
|
||||
= f.radio_fieldset "Item kind" do
|
||||
= f.radio_field title: "NC items generally have a rarity value of 500.\nPaintbrush items generally contain a special message in the description." do
|
||||
= f.radio_button :is_manually_nc, false
|
||||
Automatic: Based on rarity and description
|
||||
= f.radio_field title: "Use this when Neopets releases an NC item, but labels the rarity as something other than 500, usually by mistake." do
|
||||
= f.radio_button :is_manually_nc, true
|
||||
Manually NC: From the NC Mall, but not r500
|
||||
|
||||
= f.radio_fieldset "Modeling status" do
|
||||
= f.radio_field title: "If we fit two or more species of a standard color, assume we also fit the other standard-color pets that were released at the time.\nRepeat for special colors like Baby and Maraquan." do
|
||||
= f.radio_button :modeling_status_hint, ""
|
||||
Automatic: Fits 2+ species → Should fit all
|
||||
= f.radio_field title: "Use this when e.g. there simply is no Acara version of the item." do
|
||||
= f.radio_button :modeling_status_hint, "done"
|
||||
Done: Neopets.com is missing some models
|
||||
= f.radio_field title: "Use this when e.g. this fits the Blue Vandagyre even though it's a Maraquan item.\nBehaves identically to Done, but helps us remember why we did this!" do
|
||||
= f.radio_button :modeling_status_hint, "glitchy"
|
||||
Glitchy: Neopets.com has <em>too many</em> models
|
||||
|
||||
= f.radio_fieldset "Body fit" do
|
||||
= f.radio_field title: "When an asset in a zone like Background is modeled, assume it fits all pets the same, and assign it body ID \#0.\nOtherwise, assume it fits only the kind of pet it was modeled on." do
|
||||
= f.radio_button :explicitly_body_specific, false
|
||||
Automatic: Some zones fit all species
|
||||
= f.radio_field title: "Use this when an item uses a generally-universal zone like Static, but is body-specific regardless. \"Encased in Ice\" is one example.\nThis prevents these uncommon items from breaking every time they're modeled." do
|
||||
= f.radio_button :explicitly_body_specific, true
|
||||
Body-specific: Fits all species differently
|
||||
|
||||
= f.actions do
|
||||
= f.submit "Save changes"
|
||||
|
||||
- content_for :stylesheets do
|
||||
= page_stylesheet_link_tag "application/support-form"
|