Compare commits
No commits in common. "main" and "simpler-item-previews" have entirely different histories.
main
...
simpler-it
2
.gitignore
vendored
|
@ -4,8 +4,6 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.env.*
|
||||
/spec/examples.txt
|
||||
/.yardoc
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run the linter, and all our tests.
|
||||
yarn lint --max-warnings=0 --fix && bin/rake test spec
|
||||
yarn lint --max-warnings=0 --fix
|
||||
|
|
1
.rspec
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
|
@ -1 +1 @@
|
|||
3.3.5
|
||||
3.3.4
|
||||
|
|
32
Gemfile
|
@ -1,10 +1,10 @@
|
|||
source 'https://rubygems.org'
|
||||
ruby '3.3.5'
|
||||
ruby '3.3.4'
|
||||
|
||||
gem 'rails', '~> 7.2', '>= 7.2.1'
|
||||
gem 'rails', '~> 7.1', '>= 7.1.3.4'
|
||||
|
||||
# The HTTP server running the Rails instance.
|
||||
gem 'falcon', '~> 0.48.0'
|
||||
gem 'falcon', '~> 0.43.0'
|
||||
|
||||
# Our database is MySQL, in both development and production.
|
||||
gem 'mysql2', '~> 0.5.5'
|
||||
|
@ -19,7 +19,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
|
|||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
gem 'jsbundling-rails', '~> 1.1'
|
||||
gem 'turbo-rails', '~> 2.0'
|
||||
|
||||
# For authentication.
|
||||
|
@ -61,15 +61,15 @@ gem "httparty", "~> 0.22.0"
|
|||
gem "addressable", "~> 2.8"
|
||||
|
||||
# For advanced batching of many HTTP requests.
|
||||
gem "async", "~> 2.17", require: false
|
||||
gem "async-http", "~> 0.75.0", require: false
|
||||
gem "async", "~> 2.6", require: false
|
||||
gem "async-http", "~> 0.61.0", require: false
|
||||
gem "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
gem 'web-console', '~> 4.2'
|
||||
end
|
||||
gem 'web-console', '~> 4.2', group: :development
|
||||
|
||||
# TODO: Review our use of content_tag_for etc and uninstall this!
|
||||
gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', '~> 1.16', require: false
|
||||
|
@ -87,13 +87,5 @@ gem "sentry-rails", "~> 5.12"
|
|||
gem "shell", "~> 0.8.1"
|
||||
|
||||
# For workspace autocomplete.
|
||||
group :development do
|
||||
gem "solargraph", "~> 0.50.0"
|
||||
gem "solargraph-rails", "~> 1.1"
|
||||
end
|
||||
|
||||
# For automated tests.
|
||||
group :development, :test do
|
||||
gem "rspec-rails", "~> 7.0"
|
||||
gem "webmock", "~> 3.24", group: :test
|
||||
end
|
||||
gem "solargraph", "~> 0.50.0", group: :development
|
||||
gem "solargraph-rails", "~> 1.1", group: :development
|
||||
|
|
152
Gemfile.lock
|
@ -81,30 +81,29 @@ GEM
|
|||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
ast (2.4.2)
|
||||
async (2.17.0)
|
||||
async (2.16.1)
|
||||
console (~> 1.26)
|
||||
fiber-annotation
|
||||
io-event (~> 1.6, >= 1.6.5)
|
||||
async-container (0.18.3)
|
||||
async (~> 2.10)
|
||||
async-http (0.75.0)
|
||||
async (>= 2.10.2)
|
||||
async-pool (~> 0.7)
|
||||
io-endpoint (~> 0.11)
|
||||
io-stream (~> 0.4)
|
||||
protocol-http (~> 0.30)
|
||||
protocol-http1 (~> 0.20)
|
||||
protocol-http2 (~> 0.18)
|
||||
traces (>= 0.10)
|
||||
async-container (0.16.13)
|
||||
async
|
||||
async-io
|
||||
async-http (0.61.0)
|
||||
async (>= 1.25)
|
||||
async-io (>= 1.28)
|
||||
async-pool (>= 0.2)
|
||||
protocol-http (~> 0.25.0)
|
||||
protocol-http1 (~> 0.16.0)
|
||||
protocol-http2 (~> 0.15.0)
|
||||
traces (>= 0.10.0)
|
||||
async-http-cache (0.4.4)
|
||||
async-http (~> 0.56)
|
||||
async-io (1.43.2)
|
||||
async
|
||||
async-pool (0.8.1)
|
||||
async (>= 1.25)
|
||||
metrics
|
||||
traces
|
||||
async-service (0.12.0)
|
||||
async
|
||||
async-container (~> 0.16)
|
||||
attr_required (1.0.2)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
|
@ -119,6 +118,7 @@ GEM
|
|||
bindex (0.8.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
build-environment (1.13.0)
|
||||
builder (3.3.0)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
|
@ -128,15 +128,9 @@ GEM
|
|||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
|
@ -156,22 +150,21 @@ GEM
|
|||
activemodel
|
||||
erubi (1.13.0)
|
||||
execjs (2.9.1)
|
||||
falcon (0.48.2)
|
||||
falcon (0.43.0)
|
||||
async
|
||||
async-container (~> 0.18)
|
||||
async-http (~> 0.75)
|
||||
async-http-cache (~> 0.4)
|
||||
async-service (~> 0.10)
|
||||
async-container (~> 0.16.0)
|
||||
async-http (~> 0.57)
|
||||
async-http-cache (~> 0.4.0)
|
||||
async-io (~> 1.22)
|
||||
build-environment (~> 1.13)
|
||||
bundler
|
||||
localhost (~> 1.1)
|
||||
openssl (~> 3.0)
|
||||
process-metrics (~> 0.2)
|
||||
protocol-http (~> 0.31)
|
||||
protocol-rack (~> 0.7)
|
||||
samovar (~> 2.3)
|
||||
faraday (2.12.0)
|
||||
process-metrics (~> 0.2.0)
|
||||
protocol-rack (~> 0.1)
|
||||
samovar (~> 2.1)
|
||||
faraday (2.11.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
json
|
||||
logger
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
|
@ -188,20 +181,17 @@ GEM
|
|||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
hashdiff (1.1.2)
|
||||
hashie (5.0.0)
|
||||
http_accept_language (2.1.1)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.6)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.7.2)
|
||||
io-endpoint (0.13.1)
|
||||
io-event (1.6.5)
|
||||
io-stream (0.4.1)
|
||||
irb (1.14.1)
|
||||
irb (1.14.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
|
@ -226,7 +216,7 @@ GEM
|
|||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
localhost (1.3.1)
|
||||
logger (1.6.1)
|
||||
logger (1.6.0)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
|
@ -237,7 +227,7 @@ GEM
|
|||
net-smtp
|
||||
mapping (1.1.1)
|
||||
marcel (1.0.4)
|
||||
memory_profiler (1.1.0)
|
||||
memory_profiler (1.0.2)
|
||||
metrics (0.10.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
|
@ -248,7 +238,7 @@ GEM
|
|||
mysql2 (0.5.6)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.16)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
|
@ -287,22 +277,21 @@ GEM
|
|||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.5.0)
|
||||
parser (3.3.4.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
process-metrics (0.3.0)
|
||||
process-metrics (0.2.1)
|
||||
console (~> 1.8)
|
||||
json (~> 2)
|
||||
samovar (~> 2.1)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.37.0)
|
||||
protocol-http1 (0.27.0)
|
||||
protocol-hpack (1.5.0)
|
||||
protocol-http (0.25.0)
|
||||
protocol-http1 (0.16.1)
|
||||
protocol-http (~> 0.22)
|
||||
protocol-http2 (0.19.1)
|
||||
protocol-http2 (0.15.1)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.18)
|
||||
protocol-rack (0.10.0)
|
||||
protocol-http (~> 0.37)
|
||||
protocol-rack (0.6.0)
|
||||
protocol-http (~> 0.23)
|
||||
rack (>= 1.0)
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
|
@ -374,43 +363,30 @@ GEM
|
|||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
record_tag_helper (1.0.1)
|
||||
actionview (>= 5)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.10)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.7)
|
||||
rspec-core (3.13.2)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.3)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.2)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.0.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.1)
|
||||
rubocop (1.66.1)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rubocop (1.65.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rubocop-ast (>= 1.32.2, < 2.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.3)
|
||||
rubocop-ast (1.32.1)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
samovar (2.3.0)
|
||||
|
@ -467,6 +443,7 @@ GEM
|
|||
sprockets (>= 3.0.0)
|
||||
stackprof (0.2.26)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
|
@ -476,17 +453,18 @@ GEM
|
|||
temple (0.10.3)
|
||||
terser (1.2.3)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
thor (1.3.2)
|
||||
thor (1.3.1)
|
||||
thread-local (1.1.0)
|
||||
tilt (2.4.0)
|
||||
timeout (0.4.1)
|
||||
traces (0.13.1)
|
||||
turbo-rails (2.0.10)
|
||||
turbo-rails (2.0.6)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
unicode-display_width (2.6.0)
|
||||
unicode-display_width (2.5.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
validate_url (1.0.15)
|
||||
|
@ -503,17 +481,13 @@ GEM
|
|||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.24.0)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
webrick (1.8.2)
|
||||
webrick (1.8.1)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.1)
|
||||
yard (0.9.37)
|
||||
zeitwerk (2.6.18)
|
||||
yard (0.9.36)
|
||||
zeitwerk (2.6.17)
|
||||
|
||||
PLATFORMS
|
||||
ruby
|
||||
|
@ -521,18 +495,17 @@ PLATFORMS
|
|||
DEPENDENCIES
|
||||
RocketAMF!
|
||||
addressable (~> 2.8)
|
||||
async (~> 2.17)
|
||||
async-http (~> 0.75.0)
|
||||
async (~> 2.6)
|
||||
async-http (~> 0.61.0)
|
||||
bootsnap (~> 1.16)
|
||||
debug (~> 1.9.2)
|
||||
devise (~> 4.9, >= 4.9.2)
|
||||
devise-encryptable (~> 0.2.0)
|
||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||
falcon (~> 0.48.0)
|
||||
falcon (~> 0.43.0)
|
||||
haml (~> 6.1, >= 6.1.1)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
httparty (~> 0.22.0)
|
||||
jsbundling-rails (~> 1.3)
|
||||
jsbundling-rails (~> 1.1)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
memory_profiler (~> 1.0)
|
||||
mysql2 (~> 0.5.5)
|
||||
|
@ -543,11 +516,11 @@ DEPENDENCIES
|
|||
parallel (~> 1.23)
|
||||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 3.1)
|
||||
rails (~> 7.2, >= 7.2.1)
|
||||
rails (~> 7.1, >= 7.1.3.4)
|
||||
rails-i18n (~> 7.0, >= 7.0.7)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
react-rails (~> 2.7, >= 2.7.1)
|
||||
rspec-rails (~> 7.0)
|
||||
record_tag_helper (~> 1.0, >= 1.0.1)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
|
@ -561,11 +534,10 @@ DEPENDENCIES
|
|||
thread-local (~> 1.1)
|
||||
turbo-rails (~> 2.0)
|
||||
web-console (~> 4.2)
|
||||
webmock (~> 3.24)
|
||||
will_paginate (~> 4.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.3.5p100
|
||||
ruby 3.3.4p94
|
||||
|
||||
BUNDLED WITH
|
||||
2.5.18
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
//= link_tree ../images
|
||||
//= link_tree ../javascripts .js
|
||||
//= link_tree ../../../vendor/javascript .js
|
||||
//= link_tree ../stylesheets .css
|
||||
//= link_directory ../fonts .otf
|
||||
//= link_tree ../builds
|
||||
|
|
BIN
app/assets/fonts/Delicious-Heavy.otf
Normal file
Before Width: | Height: | Size: 7.9 KiB |
Before Width: | Height: | Size: 23 KiB |
BIN
app/assets/images/about/neopass-header.png
Normal file
After Width: | Height: | Size: 172 KiB |
BIN
app/assets/images/about/neopass-survey.png
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
app/assets/images/about/neopass-survey@2x.png
Normal file
After Width: | Height: | Size: 22 KiB |
BIN
app/assets/images/broken_item_thumbnail.gif
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
app/assets/images/emoticons/grin.gif
Normal file
After Width: | Height: | Size: 585 B |
BIN
app/assets/images/emoticons/tongue.gif
Normal file
After Width: | Height: | Size: 601 B |
BIN
app/assets/images/grid.png
Normal file
After Width: | Height: | Size: 206 B |
BIN
app/assets/images/image_mode_icon.png
Normal file
After Width: | Height: | Size: 516 B |
BIN
app/assets/images/image_mode_preview.png
Normal file
After Width: | Height: | Size: 127 KiB |
BIN
app/assets/images/loading_current_outfit.gif
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/loading_outfit_pane.gif
Normal file
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/outfits/default.png
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
app/assets/images/outfits/medium_default.png
Normal file
After Width: | Height: | Size: 10 KiB |
BIN
app/assets/images/outfits/small_default.png
Normal file
After Width: | Height: | Size: 5.1 KiB |
BIN
app/assets/images/outfits/small_loading.gif
Normal file
After Width: | Height: | Size: 38 KiB |
Before Width: | Height: | Size: 2.8 KiB |
20
app/assets/javascripts/ajax_auth.js
Normal file
|
@ -0,0 +1,20 @@
|
|||
(function () {
|
||||
var CSRFProtection;
|
||||
var token = $('meta[name="csrf-token"]').attr("content");
|
||||
if (token) {
|
||||
CSRFProtection = function (xhr, settings) {
|
||||
var sendToken =
|
||||
typeof settings.useCSRFProtection === "undefined" || // default to true
|
||||
settings.useCSRFProtection;
|
||||
if (sendToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
CSRFProtection = $.noop;
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: CSRFProtection,
|
||||
});
|
||||
})();
|
8
app/assets/javascripts/closet_hangers/petpage.js
Normal file
|
@ -0,0 +1,8 @@
|
|||
(function () {
|
||||
function setChecked() {
|
||||
var el = $(this);
|
||||
el.closest("li").toggleClass("checked", el.is(":checked"));
|
||||
}
|
||||
|
||||
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
|
||||
})();
|
|
@ -1,5 +1,7 @@
|
|||
document.addEventListener("change", ({ target }) => {
|
||||
if (target.matches('select[name="closet_list[visibility]"]')) {
|
||||
target.closest("form").setAttribute("data-list-visibility", target.value);
|
||||
target
|
||||
.closest("form")
|
||||
.setAttribute("data-list-visibility", target.value);
|
||||
}
|
||||
});
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
(function () {
|
||||
$("span.choose-outfit select").change(function (e) {
|
||||
var select = $(this);
|
||||
select.closest("li").find("input[type=text]").val(select.val());
|
||||
});
|
||||
(function() {
|
||||
$('span.choose-outfit select').change(function(e) {
|
||||
var select = $(this);
|
||||
select.closest('li').find('input[type=text]').val(select.val());
|
||||
});
|
||||
})();
|
||||
|
|
|
@ -1,115 +1,76 @@
|
|||
// When the species face picker changes, update and submit the main picker form.
|
||||
document.addEventListener("change", (e) => {
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form",
|
||||
);
|
||||
const mainSpeciesField = mainPickerForm.querySelector(
|
||||
"[name='preview[species_id]']",
|
||||
);
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
// If the preview frame fails to load, try a full pageload.
|
||||
document.addEventListener("turbo:frame-missing", (e) => {
|
||||
if (!e.target.matches("#item-preview")) return;
|
||||
|
||||
e.detail.visit(e.detail.response.url);
|
||||
e.preventDefault();
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form");
|
||||
const mainSpeciesField =
|
||||
mainPickerForm.querySelector("[name='preview[species_id]']");
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
class SpeciesColorPicker extends HTMLElement {
|
||||
#internals;
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePicker extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePickerOptions extends HTMLElement {
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
|
||||
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
|
||||
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
|
||||
class MeasuredContainer extends HTMLElement {
|
||||
static observedAttributes = ["style"];
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// When `--natural-width` gets morphed away by Turbo, measure it again!
|
||||
if (this.style.getPropertyValue("--natural-width") === "") {
|
||||
this.#measure();
|
||||
}
|
||||
}
|
||||
|
||||
#measure() {
|
||||
// Find our `<measured-content>` child, and set our natural width as
|
||||
// `var(--natural-width)` in the context of our CSS styles.
|
||||
const content = this.querySelector("measured-content");
|
||||
if (content == null) {
|
||||
throw new Error(`<measured-container> must contain a <measured-content>`);
|
||||
}
|
||||
this.style.setProperty("--natural-width", content.offsetWidth + "px");
|
||||
}
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
||||
customElements.define("measured-container", MeasuredContainer);
|
||||
|
|
|
@ -1,33 +0,0 @@
|
|||
class MagicMagnifier extends HTMLElement {
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#attachLens(), 0);
|
||||
this.addEventListener("mousemove", this.#onMouseMove);
|
||||
}
|
||||
|
||||
#attachLens() {
|
||||
const lens = document.createElement("magic-magnifier-lens");
|
||||
lens.inert = true;
|
||||
lens.useContent(this.children);
|
||||
this.appendChild(lens);
|
||||
}
|
||||
|
||||
#onMouseMove(e) {
|
||||
const lens = this.querySelector("magic-magnifier-lens");
|
||||
const rect = this.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
this.style.setProperty("--magic-magnifier-x", x + "px");
|
||||
this.style.setProperty("--magic-magnifier-y", y + "px");
|
||||
}
|
||||
}
|
||||
|
||||
class MagicMagnifierLens extends HTMLElement {
|
||||
useContent(contentNodes) {
|
||||
for (const contentNode of contentNodes) {
|
||||
this.appendChild(contentNode.cloneNode(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("magic-magnifier", MagicMagnifier);
|
||||
customElements.define("magic-magnifier-lens", MagicMagnifierLens);
|
|
@ -104,9 +104,13 @@ class OutfitLayer extends HTMLElement {
|
|||
this.#setStatus("loading");
|
||||
this.#sendMessageToIframe({ type: "requestStatus" });
|
||||
window.addEventListener("message", (m) => this.#onMessage(m));
|
||||
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
||||
this.iframe.addEventListener("error", () =>
|
||||
this.#setStatus("error"),
|
||||
);
|
||||
} else {
|
||||
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
||||
throw new Error(
|
||||
`<outfit-layer> must contain an <img> or <iframe> tag`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -131,7 +135,8 @@ class OutfitLayer extends HTMLElement {
|
|||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
|
||||
`<outfit-layer> got unexpected message: ` +
|
||||
JSON.stringify(data),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,253 +1,272 @@
|
|||
(function () {
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
|
||||
for (const [key, value] of new URLSearchParams(query_string).entries()) {
|
||||
PetQuery[key] = value;
|
||||
}
|
||||
$.each(query_string.substr(1).split("&"), function () {
|
||||
var split_piece = this.split("=");
|
||||
if (split_piece.length == 2) {
|
||||
PetQuery[split_piece[0]] = split_piece[1];
|
||||
}
|
||||
});
|
||||
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
var image_url = petImage("cpn/" + PetQuery.name, 1);
|
||||
if (PetQuery.name.startsWith("@")) {
|
||||
image_url = petImage("cp/" + PetQuery.name.substr(1), 1);
|
||||
}
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: image_url,
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadNotable() {
|
||||
// TODO: add HTTPS to notables
|
||||
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
|
||||
// var notables = response.notables;
|
||||
// var i = Math.floor(Math.random() * notables.length);
|
||||
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
|
||||
// if(!Preview.Job.current) {
|
||||
// Preview.Job.fallback.setAsCurrent();
|
||||
// }
|
||||
// });
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
loadFeature();
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
} else {
|
||||
loadNotable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
loadFeature();
|
||||
|
||||
function getImageSrc() {
|
||||
if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
function getImageSrc() {
|
||||
if (key.substr(0, 3) === "a:-") {
|
||||
// lol lazy code for prank image :P
|
||||
// TODO: HTTPS?
|
||||
return (
|
||||
"https://swfimages.impress.openneo.net" +
|
||||
"/biology/000/000/0-2/" +
|
||||
key.substr(2) +
|
||||
"/300x300.png"
|
||||
);
|
||||
} else if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
if (name.startsWith("@")) {
|
||||
// This is an image hash "pet name".
|
||||
Preview.Job.apply(this, [name.substr(1), "cp"]);
|
||||
} else {
|
||||
// This is a normal pet name.
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
}
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
})();
|
||||
|
|
|
@ -1,46 +0,0 @@
|
|||
class SupportOutfitViewer extends HTMLElement {
|
||||
#internals = this.attachInternals();
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("mouseenter", this.#onMouseEnter, { capture: true });
|
||||
this.addEventListener("mouseleave", this.#onMouseLeave, { capture: true });
|
||||
this.addEventListener("click", this.#onClick);
|
||||
this.#internals.states.add("ready");
|
||||
}
|
||||
|
||||
// When a row is hovered, highlight its corresponding outfit viewer layer.
|
||||
#onMouseEnter(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.setAttribute("highlighted", "");
|
||||
}
|
||||
}
|
||||
|
||||
// When a row is unhovered, unhighlight its corresponding outfit viewer layer.
|
||||
#onMouseLeave(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.removeAttribute("highlighted");
|
||||
}
|
||||
}
|
||||
|
||||
// When clicking a row, redirect the click to the first link.
|
||||
#onClick(e) {
|
||||
const row = e.target.closest("tr");
|
||||
if (row == null) return;
|
||||
|
||||
row.querySelector("[data-field=links] a").click();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("support-outfit-viewer", SupportOutfitViewer);
|
|
@ -1,110 +1,208 @@
|
|||
var DEBUG = document.location.search.substr(0, 6) == "?debug";
|
||||
|
||||
function petThumbnailUrl(pet_name) {
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
}
|
||||
|
||||
/* Needed items form */
|
||||
(function () {
|
||||
var UI = {};
|
||||
UI.form = $("#needed-items-form");
|
||||
UI.alert = $("#needed-items-alert");
|
||||
UI.pet_name_field = $("#needed-items-pet-name-field");
|
||||
UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
|
||||
UI.pet_header = $("#needed-items-pet-header");
|
||||
UI.reload = $("#needed-items-reload");
|
||||
UI.pet_items = $("#needed-items-pet-items");
|
||||
UI.item_template = $("#item-template");
|
||||
|
||||
var current_request = { abort: function () {} };
|
||||
function sendRequest(options) {
|
||||
current_request = $.ajax(options);
|
||||
}
|
||||
|
||||
function cancelRequest() {
|
||||
if (DEBUG) console.log("Canceling request", current_request);
|
||||
current_request.abort();
|
||||
}
|
||||
|
||||
/* Pet */
|
||||
|
||||
var last_successful_pet_name = null;
|
||||
|
||||
function loadPet(pet_name) {
|
||||
// If there is a request in progress, kill it. Our new pet request takes
|
||||
// priority, and, if I submit a name while the previous name is loading, I
|
||||
// don't want to process both responses.
|
||||
cancelRequest();
|
||||
|
||||
sendRequest({
|
||||
url: UI.form.attr("action") + ".json",
|
||||
dataType: "json",
|
||||
data: { name: pet_name },
|
||||
error: petError,
|
||||
success: function (data) {
|
||||
petSuccess(data, pet_name);
|
||||
},
|
||||
complete: petComplete,
|
||||
});
|
||||
|
||||
UI.form.removeClass("failed").addClass("loading-pet");
|
||||
}
|
||||
|
||||
function petComplete() {
|
||||
UI.form.removeClass("loading-pet");
|
||||
}
|
||||
|
||||
function petError(xhr) {
|
||||
UI.alert.text(xhr.responseText);
|
||||
UI.form.addClass("failed");
|
||||
}
|
||||
|
||||
function petSuccess(data, pet_name) {
|
||||
last_successful_pet_name = pet_name;
|
||||
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
|
||||
UI.pet_header.empty();
|
||||
$("#needed-items-pet-header-template")
|
||||
.tmpl({ pet_name: pet_name })
|
||||
.appendTo(UI.pet_header);
|
||||
loadItems(data.query);
|
||||
}
|
||||
|
||||
/* Items */
|
||||
|
||||
function loadItems(query) {
|
||||
UI.form.addClass("loading-items");
|
||||
sendRequest({
|
||||
url: "/items/needed.json",
|
||||
dataType: "json",
|
||||
data: query,
|
||||
success: itemsSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
function itemsSuccess(items) {
|
||||
if (DEBUG) {
|
||||
// The dev server is missing lots of data, so sends me 2000+ needed
|
||||
// items. We don't need that many for styling, so limit it to 100 to make
|
||||
// my browser happier.
|
||||
items = items.slice(0, 100);
|
||||
}
|
||||
|
||||
UI.pet_items.empty();
|
||||
UI.item_template.tmpl(items).appendTo(UI.pet_items);
|
||||
|
||||
UI.form.removeClass("loading-items").addClass("loaded");
|
||||
}
|
||||
|
||||
UI.form.submit(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(UI.pet_name_field.val());
|
||||
});
|
||||
|
||||
UI.reload.click(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(last_successful_pet_name);
|
||||
});
|
||||
})();
|
||||
|
||||
/* Bulk pets form */
|
||||
(function () {
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
|
||||
$(document.body).addClass("js");
|
||||
$(document.body).addClass("js");
|
||||
|
||||
function petThumbnailUrl(pet_name) {
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
}
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
beforeSend: (xhr) => {
|
||||
const token = document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
},
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
})();
|
||||
|
|
30
app/assets/stylesheets/_items.sass
Normal file
|
@ -0,0 +1,30 @@
|
|||
@import "partials/campaign-progress"
|
||||
|
||||
body.items-index, body.items-show, body.items-needed, body.item_trades
|
||||
+campaign-progress
|
||||
|
||||
text-align: center
|
||||
|
||||
.item-search-form
|
||||
display: flex
|
||||
gap: .5em
|
||||
justify-content: center
|
||||
|
||||
input[type=text]
|
||||
font-size: 125%
|
||||
width: 15em
|
||||
flex: 0 1 auto
|
||||
|
||||
h1
|
||||
margin-bottom: 1em
|
||||
img
|
||||
height: 80px
|
||||
margin-bottom: -0.5em
|
||||
width: 80px
|
||||
a
|
||||
text-decoration: none
|
||||
span
|
||||
text-decoration: underline
|
||||
&:hover span
|
||||
text-decoration: none
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
@import "partials/icon"
|
||||
@import "partials/clean/constants"
|
||||
@import "partials/clean/mixins"
|
||||
@import fonts
|
||||
@import url("https://fonts.googleapis.com/css?family=Droid+Sans:400,700")
|
||||
@import url("https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic")
|
||||
@import url("https://fonts.googleapis.com/css?family=Calligraffitti")
|
||||
|
||||
/* Reset
|
||||
|
||||
|
@ -32,6 +36,9 @@ body
|
|||
a[href]
|
||||
color: $link-color
|
||||
|
||||
p
|
||||
font-family: $text-font
|
||||
|
||||
input, button, select
|
||||
font:
|
||||
family: inherit
|
||||
|
@ -74,7 +81,7 @@ $container_width: 800px
|
|||
input, button, select, label
|
||||
cursor: pointer
|
||||
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
|
||||
border-radius: 3px
|
||||
background: #fff
|
||||
border: 1px solid $input-border-color
|
||||
|
@ -83,15 +90,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
|
|||
&:focus, &:active
|
||||
color: inherit
|
||||
|
||||
select:has(option[value='']:checked)
|
||||
color: #666
|
||||
|
||||
option[value='']
|
||||
color: #666
|
||||
|
||||
option:not([value=''])
|
||||
color: $text-color
|
||||
|
||||
textarea
|
||||
font: inherit
|
||||
|
||||
|
@ -252,3 +250,23 @@ dd
|
|||
margin: 0 .5em
|
||||
.current
|
||||
font-weight: bold
|
||||
|
||||
/* Fonts
|
||||
|
||||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
src: local("Delicious"), font-url("Delicious-Roman.otf")
|
||||
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-weight: bold
|
||||
src: local("Delicious"), font-url("Delicious-Bold.otf")
|
||||
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-style: italic
|
||||
src: local("Delicious"), font-url("Delicious-Italic.otf")
|
||||
|
||||
|
|
|
@ -3,20 +3,10 @@ body.use-responsive-design
|
|||
max-width: 100%
|
||||
padding-inline: 1rem
|
||||
box-sizing: border-box
|
||||
padding-top: 0
|
||||
|
||||
#main-nav
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
#home-link, #userbar
|
||||
position: static
|
||||
|
||||
#home-link
|
||||
padding-inline: .5rem
|
||||
margin-inline: -.5rem
|
||||
margin-right: auto
|
||||
margin-left: 1rem
|
||||
padding-inline: 0
|
||||
|
||||
#userbar
|
||||
margin-left: auto
|
||||
text-align: right
|
||||
margin-right: 1rem
|
||||
|
|
18
app/assets/stylesheets/alt_styles/_index.sass
Normal file
|
@ -0,0 +1,18 @@
|
|||
body.alt_styles-index
|
||||
.alt-styles-header
|
||||
margin-top: 1em
|
||||
margin-bottom: .5em
|
||||
|
||||
.alt-styles-list
|
||||
list-style: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1.5em
|
||||
|
||||
.alt-style
|
||||
text-align: center
|
||||
width: 80px
|
||||
|
||||
.alt-style-thumbnail
|
||||
width: 80px
|
||||
height: 80px
|
|
@ -1,4 +0,0 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
|
@ -1,3 +0,0 @@
|
|||
.rainbow-pool-list
|
||||
.name span
|
||||
display: inline-block
|
|
@ -8,10 +8,16 @@
|
|||
|
||||
@import partials/jquery.jgrowl
|
||||
|
||||
@import alt_styles/index
|
||||
@import closet_hangers/index
|
||||
@import closet_hangers/petpage
|
||||
@import closet_lists/form
|
||||
@import neopets_page_import_tasks/new
|
||||
@import contributions/index
|
||||
@import items
|
||||
@import items/index
|
||||
@import items/show
|
||||
@import item_trades/index
|
||||
@import outfits/index
|
||||
@import outfits/new
|
||||
@import pets/bulk
|
||||
|
|
|
@ -1,23 +0,0 @@
|
|||
#title:has(+ .breadcrumbs)
|
||||
margin-bottom: .125em
|
||||
|
||||
.breadcrumbs
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: row
|
||||
margin-block: .5em
|
||||
font-size: .85em
|
||||
|
||||
li
|
||||
display: flex
|
||||
|
||||
li:not(:first-child)
|
||||
&::before
|
||||
margin-inline: .35em
|
||||
content: "→"
|
||||
|
||||
&[data-relation-to-prev=sibling]::before
|
||||
content: "+"
|
||||
|
||||
&[data-relation-to-prev=menu]::before
|
||||
content: "-"
|
|
@ -1,47 +0,0 @@
|
|||
magic-magnifier
|
||||
position: relative
|
||||
|
||||
// Only show the lens when we are hovering, and the magnifier's X and Y
|
||||
// coordinates are set. (This ensures the component is running, and has
|
||||
// received a mousemove event, instead of defaulting to (0, 0).)
|
||||
magic-magnifier-lens
|
||||
display: none
|
||||
|
||||
&:hover
|
||||
@container style(--magic-magnifier-x) and style(--magic-magnifier-y)
|
||||
magic-magnifier-lens
|
||||
display: block
|
||||
|
||||
magic-magnifier-lens
|
||||
width: var(--magic-magnifier-lens-width, 100px)
|
||||
height: var(--magic-magnifier-lens-height, 100px)
|
||||
overflow: hidden
|
||||
border-radius: 100%
|
||||
|
||||
background: white
|
||||
border: 2px solid black
|
||||
box-shadow: 3px 3px 3px rgba(0, 0, 0, .5)
|
||||
|
||||
position: absolute
|
||||
left: var(--magic-magnifier-x, 0px)
|
||||
top: var(--magic-magnifier-y, 0px)
|
||||
|
||||
> *
|
||||
// Translations are applied in the opposite of the order they're specified.
|
||||
// So, here's what we're doing:
|
||||
//
|
||||
// 1. Translate the content left by --magic-magnifier-x and up by
|
||||
// --magic-magnifier-y, to align the target location with the lens's
|
||||
// top-right corner.
|
||||
// 2. Zoom in by --magic-magnifier-scale.
|
||||
// 3. Translate the content right by half of --magic-magnifier-lens-width,
|
||||
// and down by half of --magic-magnifier-lens-height, to align the
|
||||
// target location with the lens's center.
|
||||
//
|
||||
// Note that it *is* possible to specify transforms relative to the center,
|
||||
// rather than the top-left corner—this is in fact the default!—but that
|
||||
// gets confusing fast with scale in play. I think this is easier to reason
|
||||
// about with the top-left corner in terms of math, and center it after the
|
||||
// fact.
|
||||
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
|
||||
transform-origin: left top
|
|
@ -1,125 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We only apply
|
||||
// the delay here, not on the base styles, because fading *out* on load should
|
||||
// be instant.
|
||||
//
|
||||
// This is implemented as a mixin, so that the item page can leverage the same
|
||||
// loading state when loading a new preview altogether. Once CSS container
|
||||
// style queries gain wider support, maybe use that instead.
|
||||
=outfit-viewer-loading
|
||||
cursor: wait
|
||||
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
|
||||
// If the outfit *starts* in loading state, still delay the fade-in.
|
||||
@starting-style
|
||||
opacity: 0
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
// These are default widths, expected to often be overridden.
|
||||
width: 300px
|
||||
height: 300px
|
||||
|
||||
// There's no useful text in here, but double-clicking the play/pause
|
||||
// button can cause a weird selection state. Disable text selection.
|
||||
user-select: none
|
||||
-webkit-user-select: none
|
||||
|
||||
outfit-layer
|
||||
display: block
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
// We disable pointer-events most importantly for the iframes, which
|
||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
||||
// inside of its own document. But also, the context menus for these
|
||||
// elements are kinda actively misleading, too!
|
||||
pointer-events: none
|
||||
|
||||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
bottom: 0px
|
||||
right: 4px
|
||||
padding: 8px
|
||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
||||
|
||||
opacity: 0
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: none
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
.play-pause-toggle
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
&:has(outfit-layer:state(loading))
|
||||
+outfit-viewer-loading
|
||||
|
||||
// If a layer has the `[highlighted]` attribute, it's brought to the front,
|
||||
// and other layers are grayed out and blurred. We use this in the support
|
||||
// outfit viewer, when you hover over a layer.
|
||||
&:has(outfit-layer[highlighted])
|
||||
outfit-layer[highlighted]
|
||||
z-index: 999
|
||||
|
||||
// Filter everything behind the bottom-most highlighted layer, using a
|
||||
// backdrop filter. This gives us the best visual consistency by applying
|
||||
// effects to the entire backdrop, instead of each layer and then
|
||||
// re-compositing them.
|
||||
backdrop-filter: grayscale(1) brightness(2) blur(1px)
|
||||
& ~ outfit-layer[highlighted]
|
||||
backdrop-filter: none
|
|
@ -1,74 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-filters
|
||||
margin-block: .5em
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
legend
|
||||
display: contents
|
||||
font-weight: bold
|
||||
|
||||
select
|
||||
width: 16ch
|
||||
|
||||
.rainbow-pool-list
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
--preview-base-width: 150px
|
||||
|
||||
> li
|
||||
width: var(--preview-base-width)
|
||||
max-width: calc(50% - .25em)
|
||||
min-width: 150px
|
||||
box-sizing: border-box
|
||||
text-align: center
|
||||
|
||||
a
|
||||
display: block
|
||||
border-radius: 1em
|
||||
padding: .5em
|
||||
text-decoration: none
|
||||
background: white
|
||||
&:hover
|
||||
outline: 1px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
|
||||
.preview
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 1 / 1
|
||||
margin-bottom: -1em
|
||||
|
||||
.name
|
||||
background: inherit
|
||||
padding: .25em .5em
|
||||
border-radius: .5em
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.info
|
||||
font-size: .85em
|
||||
p
|
||||
margin-block: .25em
|
||||
|
||||
.rainbow-pool-pagination
|
||||
margin-block: .5em
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
|
||||
.rainbow-pool-no-results
|
||||
margin-block: 1em
|
||||
text-align: center
|
||||
font-style: italic
|
|
@ -1,102 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.support-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1em
|
||||
align-items: flex-start
|
||||
|
||||
.fields
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
width: 100%
|
||||
|
||||
> li
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
max-width: 60ch
|
||||
|
||||
> label, > .field_with_errors label
|
||||
display: block
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
> label
|
||||
color: $error-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
&[data-type=radio]
|
||||
ul
|
||||
list-style-type: none
|
||||
|
||||
&[data-type=radio-grid] // Set the `--num-columns` property to configure!
|
||||
max-width: none
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: grid
|
||||
grid-template-columns: repeat(var(--num-columns, 1), 1fr)
|
||||
gap: .25em
|
||||
|
||||
li
|
||||
display: flex
|
||||
align-items: stretch // Give the bubbles equal heights!
|
||||
|
||||
label
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding: .5em 1em
|
||||
border: 1px solid $soft-border-color
|
||||
border-radius: 1em
|
||||
flex: 1 1 auto
|
||||
|
||||
input
|
||||
margin: 0
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-color: $module-border-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
width: 100%
|
||||
min-width: 10ch
|
||||
box-sizing: border-box
|
||||
|
||||
.thumbnail-input
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
|
||||
img
|
||||
width: 40px
|
||||
height: 40px
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
|
||||
legend
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
display: contents
|
||||
|
||||
.actions
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1em
|
||||
|
||||
.go-to-next
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
font-size: .85em
|
||||
font-style: italic
|
58
app/assets/stylesheets/closet_hangers/_petpage.sass
Normal file
|
@ -0,0 +1,58 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
body.closet_hangers-petpage
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&.checked
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
|
@ -1,57 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
|
@ -1,17 +0,0 @@
|
|||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
font-weight: bold;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Bold.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
font-style: italic;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
|
||||
}
|
14
app/assets/stylesheets/fonts.css.sass
Normal file
|
@ -0,0 +1,14 @@
|
|||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
src: local("Delicious"), font-url("Delicious-Roman.otf")
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-weight: bold
|
||||
src: local("Delicious"), font-url("Delicious-Bold.otf")
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-style: italic
|
||||
src: local("Delicious"), font-url("Delicious-Italic.otf")
|
29
app/assets/stylesheets/item_trades/_index.sass
Normal file
|
@ -0,0 +1,29 @@
|
|||
@import "../partials/item_header"
|
||||
|
||||
body.item_trades-index
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
.item-subpage-title
|
||||
text-align: left
|
||||
margin-bottom: .5em
|
||||
|
||||
.trades-table
|
||||
text-align: left
|
||||
width: 100%
|
||||
table-layout: fixed
|
||||
|
||||
th, td
|
||||
&:nth-child(1), &:nth-child(2)
|
||||
width: 15ch
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
.trade-list-names
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&:not(:last-child)::after
|
||||
content: ", "
|
|
@ -1,28 +0,0 @@
|
|||
@import "../partials/item_header"
|
||||
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
.item-subpage-title
|
||||
text-align: left
|
||||
margin-bottom: .5em
|
||||
|
||||
.trades-table
|
||||
text-align: left
|
||||
width: 100%
|
||||
table-layout: fixed
|
||||
|
||||
th, td
|
||||
&:nth-child(1), &:nth-child(2)
|
||||
width: 15ch
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
.trade-list-names
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&:not(:last-child)::after
|
||||
content: ", "
|
25
app/assets/stylesheets/items/_index.sass
Normal file
|
@ -0,0 +1,25 @@
|
|||
=main_unit
|
||||
float: left
|
||||
width: 49%
|
||||
h2
|
||||
font-size: 125%
|
||||
|
||||
body.items-index
|
||||
form
|
||||
margin-bottom: 2em
|
||||
|
||||
#search-info
|
||||
+main_unit
|
||||
padding-right: 1%
|
||||
dl
|
||||
text-align: left
|
||||
dd
|
||||
margin-bottom: 1em
|
||||
|
||||
#species-search-links
|
||||
+main_unit
|
||||
padding-left: 1%
|
||||
img
|
||||
height: 80px
|
||||
width: 80px
|
||||
|
350
app/assets/stylesheets/items/_show.sass
Normal file
|
@ -0,0 +1,350 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
body.items-show
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
#item-contributors
|
||||
+subtle-banner
|
||||
clear: both
|
||||
margin:
|
||||
bottom: 0
|
||||
top: 2em
|
||||
|
||||
header
|
||||
display: inline
|
||||
font-weight: bold
|
||||
margin-right: .25em
|
||||
|
||||
footer
|
||||
display: inline
|
||||
|
||||
ul
|
||||
display: inline
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&::after
|
||||
content: ", "
|
||||
|
||||
&:last-child::after
|
||||
content: "."
|
||||
|
||||
.nc-icon
|
||||
height: 16px
|
||||
width: 16px
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
overflow: hidden
|
||||
margin: 0 auto
|
||||
|
||||
// There's no useful text in here, but double-clicking the play/pause
|
||||
// button can cause a weird selection state. Disable text selection.
|
||||
user-select: none
|
||||
-webkit-user-select: none
|
||||
|
||||
outfit-layer
|
||||
display: block
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
// We disable pointer-events most importantly for the iframes, which
|
||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
||||
// inside of its own document. But also, the context menus for these
|
||||
// elements are kinda actively misleading, too!
|
||||
pointer-events: none
|
||||
|
||||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
bottom: 0px
|
||||
right: 4px
|
||||
padding: 8px
|
||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
||||
|
||||
opacity: 0
|
||||
transition: opacity .5s
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: none
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
.play-pause-toggle
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
.error-indicator
|
||||
font-size: 85%
|
||||
color: $error-color
|
||||
margin-top: .25em
|
||||
margin-bottom: .5em
|
||||
display: none
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. (We only
|
||||
// apply the delay here, because fading *out* on load should be instant.)
|
||||
// We are loading when the <turbo-frame> is busy, or when at least one layer
|
||||
// is loading.
|
||||
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
|
||||
cursor: wait
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
border: 2px solid red
|
||||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
||||
form[data-is-valid="false"]
|
||||
select
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
// If JS is enabled, but auto-loading isn't ready yet (script loading or
|
||||
// failed?), hide the submit button for .75sec, to give it time to load.
|
||||
@media (scripting: enabled)
|
||||
input[type=submit]
|
||||
position: absolute
|
||||
margin-left: .5em
|
||||
opacity: 0
|
||||
animation: fade-in .25s forwards
|
||||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
species-face-picker
|
||||
display: block
|
||||
position: relative
|
||||
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
|
||||
padding: 10px // leave enough room for the zoomed-in selected face
|
||||
margin-top: -10px
|
||||
overflow: auto
|
||||
|
||||
species-face-picker-options
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
|
||||
img
|
||||
width: 50px
|
||||
height: 50px
|
||||
transition: all 0.2s
|
||||
|
||||
// Calm down the default color, just a smidge! There's a lot of color
|
||||
// on this page already, y'know?
|
||||
opacity: .9
|
||||
filter: saturate(90%)
|
||||
|
||||
label
|
||||
display: flex
|
||||
overflow: hidden
|
||||
transition: all 0.2s
|
||||
position: relative
|
||||
line-height: 1
|
||||
|
||||
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
|
||||
// Chakra UI's styling system to generate them! (The colors are from their
|
||||
// color palette, too.)
|
||||
&:has(input:checked)
|
||||
border-radius: 6px
|
||||
z-index: 1
|
||||
background: #9AE6B4
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
|
||||
transform: scale(1.1)
|
||||
|
||||
&:has(input:focus)
|
||||
background: #BEE3F8
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
|
||||
transform: scale(1.2)
|
||||
|
||||
input[type=radio]
|
||||
position: absolute
|
||||
left: -10000px
|
||||
top: auto
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
&:checked + img
|
||||
opacity: 1
|
||||
filter: saturate(110%)
|
||||
|
||||
&:disabled + img
|
||||
opacity: .6
|
||||
filter: saturate(0%)
|
||||
|
||||
label:has(input[type=radio]:disabled)
|
||||
cursor: not-allowed
|
||||
|
||||
noscript
|
||||
position: absolute
|
||||
inset: 0
|
||||
background: rgba(white, .75)
|
||||
z-index: 1
|
||||
cursor: auto
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
text-align: center
|
||||
|
||||
&:has(species-face-picker-options[inert])
|
||||
cursor: wait
|
||||
|
||||
.item-preview-meta-info
|
||||
display: grid
|
||||
grid-template-columns: 1fr auto
|
||||
gap: .5em
|
||||
align-items: center
|
||||
|
||||
.item-zones-info
|
||||
h3
|
||||
display: inline
|
||||
font: inherit
|
||||
font-weight: bold
|
||||
&:after
|
||||
content: ": "
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: inline
|
||||
|
||||
li
|
||||
display: inline
|
||||
&:not(:last-of-type):after
|
||||
content: ", "
|
||||
|
||||
.no-zones
|
||||
font-style: italic
|
||||
opacity: .85
|
||||
|
||||
.zone-species-info
|
||||
font-style: italic
|
||||
text-decoration: underline dotted
|
||||
|
||||
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
|
||||
.item-html5-info
|
||||
display: flex
|
||||
align-items: center
|
||||
border: 1px solid
|
||||
border-radius: .375em
|
||||
padding: 4px 8px
|
||||
min-height: 30px
|
||||
box-sizing: border-box
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
|
||||
|
||||
&[data-status=converted]
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
svg:nth-of-type(2)
|
||||
margin-right: -4px // spacing hacks!
|
||||
|
||||
&[data-status=unconverted]
|
||||
background: $warning-bg-color
|
||||
color: #975A16
|
||||
gap: .25em // spacing hacks!
|
||||
|
||||
svg:first-of-type
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
svg:nth-of-type(2)
|
||||
width: 20px
|
||||
height: 20px
|
||||
|
||||
#item-preview
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
|
||||
@media (min-width: 600px)
|
||||
display: grid
|
||||
grid-template-areas: "viewer faces" "picker meta"
|
||||
gap: .5em
|
||||
|
||||
outfit-viewer
|
||||
grid-area: viewer
|
||||
width: 350px
|
||||
height: 350px
|
||||
|
||||
species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
grid-area: faces
|
||||
max-height: 350px
|
||||
margin: -10px
|
||||
|
||||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
|
@ -1,23 +0,0 @@
|
|||
=main_unit
|
||||
float: left
|
||||
width: 49%
|
||||
h2
|
||||
font-size: 125%
|
||||
|
||||
form
|
||||
margin-bottom: 2em
|
||||
|
||||
#search-info
|
||||
+main_unit
|
||||
padding-right: 1%
|
||||
dl
|
||||
text-align: left
|
||||
dd
|
||||
margin-bottom: 1em
|
||||
|
||||
#species-search-links
|
||||
+main_unit
|
||||
padding-left: 1%
|
||||
img
|
||||
height: 80px
|
||||
width: 80px
|
|
@ -1,309 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
@import "../application/outfit-viewer"
|
||||
|
||||
#container
|
||||
width: 900px // A bit more generous to the preview area!
|
||||
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
#item-contributors
|
||||
+subtle-banner
|
||||
clear: both
|
||||
margin:
|
||||
bottom: 0
|
||||
top: 2em
|
||||
|
||||
header
|
||||
display: inline
|
||||
font-weight: bold
|
||||
margin-right: .25em
|
||||
|
||||
footer
|
||||
display: inline
|
||||
|
||||
ul
|
||||
display: inline
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&::after
|
||||
content: ", "
|
||||
|
||||
&:last-child::after
|
||||
content: "."
|
||||
|
||||
.nc-icon
|
||||
height: 16px
|
||||
width: 16px
|
||||
|
||||
.preview-area
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
|
||||
.customize-more
|
||||
position: absolute
|
||||
top: 1em
|
||||
right: 1em
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
text-decoration: none
|
||||
|
||||
background: #EDF2F7
|
||||
padding-inline: .75em
|
||||
border-radius: .375em
|
||||
min-height: 2rem
|
||||
min-width: 2rem
|
||||
box-sizing: border-box
|
||||
|
||||
.customize-more-label
|
||||
width: 0
|
||||
overflow: hidden
|
||||
transition: width .25s
|
||||
white-space: nowrap
|
||||
--natural-width: auto
|
||||
|
||||
measured-content
|
||||
padding-right: .5em
|
||||
|
||||
&:hover, &:focus
|
||||
// Expand the label to its natural width. If the JS ran to tell us
|
||||
// what it is in px, we can use that for a smooth transition. If not,
|
||||
// okay, we just pop out to `auto`, which CSS can't make smooth.
|
||||
.customize-more-label
|
||||
width: var(--natural-width)
|
||||
|
||||
outfit-viewer
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
|
||||
.error-indicator
|
||||
font-size: 85%
|
||||
color: $error-color
|
||||
margin-top: .25em
|
||||
margin-bottom: .5em
|
||||
display: none
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We are
|
||||
// loading when the <turbo-frame> is busy, or when at least one layer
|
||||
// is loading.
|
||||
//
|
||||
// We only apply the delay here, not on the base styles, because fading
|
||||
// *out* on load should be instant.
|
||||
#item-preview[busy] outfit-viewer
|
||||
+outfit-viewer-loading
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
border: 2px solid red
|
||||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
||||
form[data-is-valid="false"]
|
||||
select
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
// If JS is enabled, but auto-loading isn't ready yet (script loading or
|
||||
// failed?), hide the submit button for .75sec, to give it time to load.
|
||||
@media (scripting: enabled)
|
||||
input[type=submit]
|
||||
position: absolute
|
||||
margin-left: .5em
|
||||
opacity: 0
|
||||
animation: fade-in .25s forwards
|
||||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
species-face-picker
|
||||
display: block
|
||||
position: relative
|
||||
margin-top: -10px
|
||||
|
||||
species-face-picker-options
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
isolation: isolate // avoid z-index conflicts between pets and noscript
|
||||
overflow: auto
|
||||
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
|
||||
padding: 10px // leave enough room for the zoomed-in selected face
|
||||
|
||||
img
|
||||
width: 54px
|
||||
height: 54px
|
||||
transition: all 0.2s
|
||||
|
||||
// Calm down the default color, just a smidge! There's a lot of color
|
||||
// on this page already, y'know?
|
||||
opacity: .9
|
||||
filter: saturate(90%)
|
||||
|
||||
label
|
||||
display: flex
|
||||
overflow: hidden
|
||||
transition: all 0.2s
|
||||
position: relative
|
||||
line-height: 1
|
||||
|
||||
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
|
||||
// Chakra UI's styling system to generate them! (The colors are from their
|
||||
// color palette, too.)
|
||||
&:has(input:checked)
|
||||
border-radius: 6px
|
||||
z-index: 1
|
||||
background: #9AE6B4
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
|
||||
transform: scale(1.1)
|
||||
|
||||
&:has(input:focus)
|
||||
background: #BEE3F8
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
|
||||
transform: scale(1.2)
|
||||
|
||||
input[type=radio]
|
||||
position: absolute
|
||||
left: -10000px
|
||||
top: auto
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
&:checked + img
|
||||
opacity: 1
|
||||
filter: saturate(110%)
|
||||
|
||||
&:disabled + img
|
||||
opacity: .6
|
||||
filter: saturate(0%)
|
||||
|
||||
label:has(input[type=radio]:disabled)
|
||||
cursor: not-allowed
|
||||
|
||||
noscript
|
||||
position: absolute
|
||||
inset: 0
|
||||
padding: 1em
|
||||
background: rgba(white, .8)
|
||||
z-index: 1
|
||||
cursor: auto
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
text-align: center
|
||||
|
||||
&:has(species-face-picker-options[inert])
|
||||
cursor: wait
|
||||
|
||||
.item-preview-meta-info
|
||||
display: grid
|
||||
grid-template-columns: 1fr auto
|
||||
gap: .5em
|
||||
align-items: center
|
||||
|
||||
.item-zones-info
|
||||
h3
|
||||
display: inline
|
||||
font: inherit
|
||||
font-weight: bold
|
||||
&:after
|
||||
content: ": "
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: inline
|
||||
|
||||
li
|
||||
display: inline
|
||||
&:not(:last-of-type):after
|
||||
content: ", "
|
||||
|
||||
.no-zones
|
||||
font-style: italic
|
||||
opacity: .85
|
||||
|
||||
.zone-species-info
|
||||
font-style: italic
|
||||
text-decoration: underline dotted
|
||||
|
||||
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
|
||||
.item-html5-info
|
||||
display: flex
|
||||
align-items: center
|
||||
border: 1px solid
|
||||
border-radius: .375em
|
||||
padding: 4px 8px
|
||||
min-height: 30px
|
||||
box-sizing: border-box
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
|
||||
|
||||
&[data-status=converted]
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
svg:nth-of-type(2)
|
||||
margin-right: -4px // spacing hacks!
|
||||
|
||||
&[data-status=unconverted]
|
||||
background: $warning-bg-color
|
||||
color: #975A16
|
||||
gap: .25em // spacing hacks!
|
||||
|
||||
svg:first-of-type
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
svg:nth-of-type(2)
|
||||
width: 20px
|
||||
height: 20px
|
||||
|
||||
#item-preview
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
|
||||
@media (min-width: 700px)
|
||||
display: grid
|
||||
grid-template-areas: "viewer faces" "picker meta"
|
||||
gap: .5em
|
||||
|
||||
.preview-area
|
||||
grid-area: viewer
|
||||
outfit-viewer
|
||||
width: 380px
|
||||
height: 380px
|
||||
|
||||
species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
grid-area: faces
|
||||
species-face-picker-options
|
||||
max-height: 380px
|
||||
|
||||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
|
@ -1,28 +0,0 @@
|
|||
@import "partials/campaign-progress"
|
||||
|
||||
body
|
||||
+campaign-progress
|
||||
text-align: center
|
||||
|
||||
.item-search-form
|
||||
display: flex
|
||||
gap: .5em
|
||||
justify-content: center
|
||||
|
||||
input[type=text]
|
||||
font-size: 125%
|
||||
width: 15em
|
||||
flex: 0 1 auto
|
||||
|
||||
h1
|
||||
margin-bottom: 1em
|
||||
img
|
||||
height: 80px
|
||||
margin-bottom: -0.5em
|
||||
width: 80px
|
||||
a
|
||||
text-decoration: none
|
||||
span
|
||||
text-decoration: underline
|
||||
&:hover span
|
||||
text-decoration: none
|
|
@ -7,8 +7,9 @@ body.outfits-new
|
|||
#pet-not-found
|
||||
display: none
|
||||
|
||||
.announcement
|
||||
border: 1px solid $module-border-color
|
||||
.neopass-announcement
|
||||
border: 1px solid #cd8400
|
||||
color: #764a00
|
||||
padding: .5em
|
||||
display: grid
|
||||
grid-template-areas: "thumbnail content"
|
||||
|
@ -23,6 +24,9 @@ body.outfits-new
|
|||
p:last-of-type
|
||||
margin-bottom: 0
|
||||
|
||||
a
|
||||
color: #be7a00
|
||||
|
||||
#outfit-forms
|
||||
+clearfix
|
||||
+module
|
||||
|
@ -78,57 +82,85 @@ body.outfits-new
|
|||
font-size: 175%
|
||||
select
|
||||
font-size: 120%
|
||||
#description, #top-contributors
|
||||
float: left
|
||||
#description
|
||||
margin-right: 2%
|
||||
width: 64%
|
||||
#top-contributors
|
||||
border: 1px solid $input-border-color
|
||||
margin-top: 1em
|
||||
padding: 1%
|
||||
width: 30%
|
||||
ol
|
||||
margin-left: 2em
|
||||
padding-left: 1em
|
||||
> a
|
||||
font-size: 80%
|
||||
display: block
|
||||
text-align: right
|
||||
#how-can-i-help, #i-found-something
|
||||
+module
|
||||
float: left
|
||||
padding: 1%
|
||||
width: 46%
|
||||
h2
|
||||
font-style: italic
|
||||
input, button
|
||||
font-size: 115%
|
||||
input[type=text]
|
||||
border-color: $module-border-color
|
||||
width: 12em
|
||||
#how-can-i-help
|
||||
margin-right: 1%
|
||||
#i-found-something
|
||||
margin-left: 1%
|
||||
a
|
||||
float: right
|
||||
font-size: 87.5%
|
||||
margin-top: 1em
|
||||
$section-count: 3
|
||||
$section-border-width: 1px
|
||||
$section-padding: 0.5em
|
||||
$section-width: 100% / $section-count
|
||||
// (A - (B-1)*C) / B
|
||||
#sections
|
||||
display: grid
|
||||
grid-template-columns: 1fr 1fr 1fr
|
||||
+clearfix
|
||||
display: table
|
||||
list-style: none
|
||||
margin-top: 1em
|
||||
li
|
||||
display: grid
|
||||
grid-template-areas: "header image" "info image" "form form"
|
||||
grid-template-rows: auto auto auto
|
||||
row-gap: .5em
|
||||
padding: 0.5em
|
||||
&:not(:first-child)
|
||||
border-left: 1px solid $module-border-color
|
||||
h3
|
||||
grid-area: header
|
||||
margin-bottom: 0
|
||||
margin-bottom: .25em
|
||||
li
|
||||
border-left:
|
||||
color: $module-border-color
|
||||
style: solid
|
||||
width: $section-border-width
|
||||
display: table-cell
|
||||
padding: $section-padding
|
||||
position: relative
|
||||
width: $section-width
|
||||
&:first-child
|
||||
border-left: 0
|
||||
div
|
||||
grid-area: info
|
||||
color: $soft-text-color
|
||||
font-size: 75%
|
||||
margin-left: 1em
|
||||
z-index: 2
|
||||
strong
|
||||
h4, input
|
||||
font-size: 116%
|
||||
a:has(img)
|
||||
grid-area: image
|
||||
h4, input[type=text]
|
||||
color: inherit
|
||||
h4 a
|
||||
background: #ffffc0
|
||||
img
|
||||
opacity: 0.75
|
||||
+opacity(0.75)
|
||||
float: right
|
||||
margin-left: .5em
|
||||
&:hover
|
||||
opacity: 1
|
||||
+opacity(1)
|
||||
p
|
||||
line-height: 1.5
|
||||
min-height: 4.5em
|
||||
margin-bottom: 0
|
||||
form
|
||||
grid-area: form
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
font-size: .85em
|
||||
margin-left: 1em
|
||||
margin-right: .5em
|
||||
|
||||
input[type=text], input[type=search]
|
||||
// TODO: It doesn't make sense to me that this is the right style? I
|
||||
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
|
||||
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
|
||||
// which I would have *thought* is the same as this, but isn't! Idk!
|
||||
width: 100%
|
||||
|
||||
#whats-new
|
||||
margin-bottom: 1em
|
||||
|
@ -297,3 +329,4 @@ body.outfits-new
|
|||
#latest-contribution-created-at
|
||||
color: $soft-text-color
|
||||
margin-left: .5em
|
||||
|
||||
|
|
29
app/assets/stylesheets/partials/_blue.sass
Normal file
|
@ -0,0 +1,29 @@
|
|||
// Used internally:
|
||||
|
||||
$background_color: #0b61a4
|
||||
$module_border_color: #033e6b
|
||||
$module_background_color: #66a3d2
|
||||
|
||||
$input_hover_border_color: #ff9200
|
||||
$input_focus_border_color: #fff
|
||||
|
||||
$loud_button_background_color: #ff9200
|
||||
$loud_button_border_color: #ffad40
|
||||
$loud_button_color: #a65f00
|
||||
$loud_button_focus_border_color: #000
|
||||
|
||||
// Used by Blueprint:
|
||||
|
||||
$font_color: #fff
|
||||
|
||||
$header_color: inherit
|
||||
|
||||
$link_color: inherit
|
||||
$link_hover_color: inherit
|
||||
$link_focus_color: inherit
|
||||
$link_active_color: inherit
|
||||
$link_visited_color: inherit
|
||||
|
||||
$error_color: inherit
|
||||
$error_bg_color: #e14f1c
|
||||
$error_border_color: #cd0a0a
|
|
@ -1,7 +1,6 @@
|
|||
@import "clean/mixins"
|
||||
|
||||
=context-button
|
||||
+awesome-button
|
||||
+awesome-button-color(#aaaaaa)
|
||||
+opacity(0.9)
|
||||
font-size: 80%
|
||||
|
||||
|
|
|
@ -67,21 +67,14 @@
|
|||
background: #FEEBC8
|
||||
color: #7B341E
|
||||
|
||||
.support-form
|
||||
grid-area: support
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
.user-lists-info
|
||||
grid-area: lists
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
display: flex
|
||||
gap: 1em
|
||||
|
||||
a::after
|
||||
content: " ›"
|
||||
.user-lists-form-opener
|
||||
&::after
|
||||
content: " ›"
|
||||
|
||||
.user-lists-form
|
||||
background: $background-color
|
||||
|
|
|
@ -18,8 +18,9 @@ $error-color: #8a1f11
|
|||
$error-bg-color: #fbe3e4
|
||||
$error-border-color: #fbc2c4
|
||||
|
||||
$header-font: Delicious, system-ui, sans-serif
|
||||
$main-font: system-ui, sans-serif
|
||||
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
|
||||
$main-font: "Droid Sans", Helvetica, Arial, Verdana, sans-serif
|
||||
$text-font: "Droid Serif", Georgia, "Times New Roman", Times, serif
|
||||
|
||||
$object-img-size: 80px
|
||||
$object-width: 100px
|
||||
|
|
|
@ -1,15 +0,0 @@
|
|||
support-outfit-viewer
|
||||
margin-block: 1em
|
||||
|
||||
.fields li[data-type=radio-grid]
|
||||
--num-columns: 3
|
||||
|
||||
.reference-link
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding-inline: .5em
|
||||
|
||||
img
|
||||
height: 2em
|
||||
width: auto
|
|
@ -1,78 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/context_button"
|
||||
|
||||
support-outfit-viewer
|
||||
display: flex
|
||||
gap: 2em
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
|
||||
outfit-viewer
|
||||
flex: 0 0 auto
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
|
||||
.outfit-viewer-controls
|
||||
margin-block: .5em
|
||||
isolation: isolate // Avoid z-index weirdness with our buttons vs the lens
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
font-size: .85em
|
||||
|
||||
fieldset
|
||||
display: contents
|
||||
|
||||
legend
|
||||
font-weight: bold
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
label
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
|
||||
input[type=radio]
|
||||
margin: 0
|
||||
|
||||
[type=submit]
|
||||
+context-button
|
||||
|
||||
> table
|
||||
flex: 0 0 auto
|
||||
border-collapse: collapse
|
||||
table-layout: fixed
|
||||
border-radius: .5em
|
||||
|
||||
th, td
|
||||
border: 1px solid $module-border-color
|
||||
font-size: .85em
|
||||
padding: .25em .5em
|
||||
text-align: left
|
||||
|
||||
> tbody
|
||||
[data-field=links]
|
||||
ul
|
||||
list-style-type: none
|
||||
display: flex
|
||||
gap: .5em
|
||||
|
||||
// Once the component is ready, add some hints about potential interactions.
|
||||
&:state(ready)
|
||||
> table
|
||||
> tbody > tr
|
||||
cursor: zoom-in
|
||||
&:hover
|
||||
background: $module-bg-color
|
||||
|
||||
magic-magnifier
|
||||
--magic-magnifier-lens-width: 100px
|
||||
--magic-magnifier-lens-height: 100px
|
||||
--magic-magnifier-scale: 2.5
|
||||
|
||||
magic-magnifier-lens
|
||||
z-index: 2 // Be above things by default, but not by much!
|
|
@ -1,8 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-list
|
||||
--preview-base-width: 200px
|
||||
margin-bottom: 2em
|
||||
|
||||
.glitched
|
||||
cursor: help
|
|
@ -2,8 +2,70 @@
|
|||
@import "../partials/clean/mixins"
|
||||
|
||||
body.pets-bulk
|
||||
#bulk-pets-form
|
||||
#needed-items-form, #bulk-pets-form
|
||||
text-align: center
|
||||
|
||||
#needed-items-form
|
||||
#needed-items-pet
|
||||
border-top: 1px solid $soft-border-color
|
||||
display: none
|
||||
margin-top: 1em
|
||||
padding-top: 1em
|
||||
|
||||
h4
|
||||
font-size: 150%
|
||||
margin-bottom: .5em
|
||||
|
||||
#needed-items-reload
|
||||
+inline-block
|
||||
font-size: 12px
|
||||
margin-left: 1em
|
||||
vertical-align: middle
|
||||
|
||||
#needed-items-alert
|
||||
display: none
|
||||
margin-top: .5em
|
||||
|
||||
#needed-items-pet-thumbnail
|
||||
height: 50px
|
||||
width: 50px
|
||||
|
||||
#needed-items-pet-items
|
||||
li.owned
|
||||
background: $module-bg-color
|
||||
border: 1px solid $module-border-color
|
||||
|
||||
.object-owned
|
||||
color: $soft-text-color
|
||||
display: block
|
||||
font-size: 75%
|
||||
font-style: italic
|
||||
padding-bottom: .25em
|
||||
|
||||
&.loading-pet, &.loading-items
|
||||
#needed-items-pet-name-field
|
||||
background:
|
||||
image: image-url("loading.gif")
|
||||
position: center right
|
||||
repeat: no-repeat
|
||||
|
||||
#needed-items-pet-items
|
||||
+opacity(.50)
|
||||
|
||||
&.loading-pet
|
||||
#needed-items-pet h4
|
||||
+opacity(.50)
|
||||
|
||||
&.loaded
|
||||
#needed-items-pet
|
||||
display: block
|
||||
|
||||
&.failed
|
||||
#needed-items-alert
|
||||
display: block
|
||||
|
||||
#bulk-pets-form
|
||||
border-top: 1px solid $module-border-color
|
||||
margin-top: 12px
|
||||
padding-top: 12px
|
||||
|
||||
|
|
|
@ -4,6 +4,9 @@
|
|||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: min(100vw, 100vh);
|
||||
height: min(100vw, 100vh);
|
||||
|
||||
/* HACK: `calc` isn't needed, but works around a bug in our asset pipeline,
|
||||
* where libsass is trying to preprocess it. (We're not SASS tho?) */
|
||||
width: calc(min(100vw, 100vh));
|
||||
height: calc(min(100vw, 100vh));
|
||||
}
|
||||
|
|
|
@ -1,40 +1,21 @@
|
|||
class AltStylesController < ApplicationController
|
||||
before_action :support_staff_only, except: [:index]
|
||||
|
||||
def index
|
||||
@all_alt_styles = AltStyle.includes(:species, :color)
|
||||
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
|
||||
order(:species_id, :color_id)
|
||||
|
||||
@all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name)
|
||||
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name)
|
||||
if params[:species_id]
|
||||
@species = Species.find(params[:species_id])
|
||||
@alt_styles = @alt_styles.merge(@species.alt_styles)
|
||||
end
|
||||
|
||||
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort
|
||||
@all_color_names = @all_colors.map(&:human_name)
|
||||
@all_species_names = @all_species.map(&:human_name)
|
||||
|
||||
@series_name = params[:series]
|
||||
@color = find_color
|
||||
@species = find_species
|
||||
|
||||
@alt_styles = @all_alt_styles.includes(:swf_assets)
|
||||
@alt_styles.where!(series_name: @series_name) if @series_name.present?
|
||||
@alt_styles.merge!(@color.alt_styles) if @color
|
||||
@alt_styles.merge!(@species.alt_styles) if @species
|
||||
|
||||
# We're using the HTML5 image for our preview, so make sure we have all the
|
||||
# We're going to link to the HTML5 image URL, so make sure we have all the
|
||||
# manifests ready!
|
||||
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@alt_styles = @alt_styles.
|
||||
by_creation_date.order(:color_id, :species_id, :series_name).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
render
|
||||
}
|
||||
format.html { render }
|
||||
format.json {
|
||||
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).
|
||||
sort_by(&:full_name)
|
||||
render json: @alt_styles.as_json(
|
||||
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
|
||||
only: [:id, :species_id, :color_id, :body_id, :series_name,
|
||||
:adjective_name, :thumbnail_url],
|
||||
include: {
|
||||
|
@ -49,56 +30,4 @@ class AltStylesController < ApplicationController
|
|||
}
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
end
|
||||
|
||||
def update
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
|
||||
if @alt_style.update(alt_style_params)
|
||||
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def alt_style_params
|
||||
params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
|
||||
end
|
||||
|
||||
def find_color
|
||||
if params[:color]
|
||||
Color.find_by(name: params[:color])
|
||||
end
|
||||
end
|
||||
|
||||
def find_species
|
||||
if params[:species_id]
|
||||
Species.find_by(id: params[:species_id])
|
||||
elsif params[:species]
|
||||
Species.find_by(name: params[:species])
|
||||
end
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-style"
|
||||
next_unlabeled_style_path
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_style_path
|
||||
unlabeled_style = AltStyle.unlabeled.newest.first
|
||||
if unlabeled_style
|
||||
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -2,10 +2,12 @@ require 'async'
|
|||
require 'async/container'
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
include FragmentLocalization
|
||||
|
||||
protect_from_forgery
|
||||
|
||||
helper_method :current_user, :support_staff?, :user_signed_in?
|
||||
|
||||
helper_method :current_user, :user_signed_in?
|
||||
|
||||
before_action :set_locale
|
||||
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
|
@ -21,12 +23,9 @@ class ApplicationController < ActionController::Base
|
|||
|
||||
class AccessDenied < StandardError; end
|
||||
rescue_from AccessDenied, with: :on_access_denied
|
||||
|
||||
rescue_from Async::Stop, Async::Container::Terminate,
|
||||
with: :on_request_stopped
|
||||
|
||||
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
|
||||
|
||||
def authenticate_user!
|
||||
redirect_to(new_auth_user_session_path) unless user_signed_in?
|
||||
end
|
||||
|
@ -46,15 +45,15 @@ class ApplicationController < ActionController::Base
|
|||
def user_signed_in?
|
||||
auth_user_signed_in?
|
||||
end
|
||||
|
||||
|
||||
def infer_locale
|
||||
return params[:locale] if valid_locale?(params[:locale])
|
||||
return cookies[:locale] if valid_locale?(cookies[:locale])
|
||||
Rails.logger.debug "Preferred languages: #{http_accept_language.user_preferred_languages}"
|
||||
http_accept_language.language_region_compatible_from(I18n.available_locales.map(&:to_s)) ||
|
||||
http_accept_language.language_region_compatible_from(I18n.public_locales.map(&:to_s)) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
|
||||
def not_found(record_name='record')
|
||||
raise ActionController::RoutingError.new("#{record_name} not found")
|
||||
end
|
||||
|
@ -68,11 +67,6 @@ class ApplicationController < ActionController::Base
|
|||
status: :internal_server_error
|
||||
end
|
||||
|
||||
def on_db_timeout
|
||||
render file: 'public/503.html', layout: false,
|
||||
status: :service_unavailable
|
||||
end
|
||||
|
||||
def redirect_back!(default=:back)
|
||||
redirect_to(params[:return_to] || default)
|
||||
end
|
||||
|
@ -82,7 +76,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def valid_locale?(locale)
|
||||
locale && I18n.available_locales.include?(locale.to_sym)
|
||||
locale && I18n.usable_locales.include?(locale.to_sym)
|
||||
end
|
||||
|
||||
def configure_permitted_parameters
|
||||
|
@ -110,13 +104,5 @@ class ApplicationController < ActionController::Base
|
|||
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
|
||||
return_to || root_path
|
||||
end
|
||||
|
||||
def support_staff?
|
||||
current_user&.support_staff?
|
||||
end
|
||||
|
||||
def support_staff_only
|
||||
raise AccessDenied, "Support staff only" unless support_staff?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
class ItemsController < ApplicationController
|
||||
before_action :set_query
|
||||
before_action :support_staff_only, except: [:index, :show, :sources]
|
||||
rescue_from Item::Search::Error, :with => :search_error
|
||||
|
||||
def index
|
||||
|
@ -29,12 +28,6 @@ class ItemsController < ApplicationController
|
|||
render json: {
|
||||
items: @items.as_json(
|
||||
methods: [:nc?, :pb?, :owned?, :wanted?],
|
||||
include: {
|
||||
restricted_zones: {
|
||||
only: [:id, :depth, :label],
|
||||
methods: [:is_commonly_used_by_items],
|
||||
},
|
||||
},
|
||||
),
|
||||
appearances: load_appearances.as_json(
|
||||
include: {
|
||||
|
@ -113,18 +106,24 @@ class ItemsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@item = Item.find params[:id]
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def update
|
||||
@item = Item.find params[:id]
|
||||
if @item.update(item_params)
|
||||
flash[:notice] = "\"#{@item.name}\" successfully saved!"
|
||||
redirect_to @item
|
||||
else
|
||||
render action: "edit", layout: "application", status: :bad_request
|
||||
def needed
|
||||
if params[:color] && params[:species]
|
||||
@pet_type = PetType.find_by_color_id_and_species_id(
|
||||
params[:color],
|
||||
params[:species]
|
||||
)
|
||||
end
|
||||
|
||||
unless @pet_type
|
||||
raise ActiveRecord::RecordNotFound, 'Pet type not found'
|
||||
end
|
||||
|
||||
@items = @pet_type.needed_items.order(:name)
|
||||
assign_closeted!(@items)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { @pet_name = params[:name] ; render :layout => 'application' }
|
||||
format.json { render :json => @items }
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -180,15 +179,6 @@ class ItemsController < ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def item_params
|
||||
params.require(:item).permit(
|
||||
:name, :thumbnail_url, :description, :modeling_status_hint,
|
||||
:is_manually_nc, :explicitly_body_specific,
|
||||
).tap do |p|
|
||||
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
|
||||
end
|
||||
end
|
||||
|
||||
def assign_closeted!(items)
|
||||
current_user.assign_closeted_to_items!(items) if user_signed_in?
|
||||
end
|
||||
|
@ -240,8 +230,7 @@ class ItemsController < ApplicationController
|
|||
@item.compatible_pet_types.
|
||||
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
|
||||
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
|
||||
preferring_simple.first ||
|
||||
PetType.matching_name("Blue", "Acara").first!
|
||||
preferring_simple.first
|
||||
end
|
||||
|
||||
def validate_preview
|
||||
|
|
|
@ -47,24 +47,29 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def new
|
||||
@colors = Color.alphabetical
|
||||
@colors = Color.funny.alphabetical
|
||||
@species = Species.alphabetical
|
||||
|
||||
newest_items = Item.newest.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
# HACK: Skip this in development, because it's slow!
|
||||
unless Rails.env.development?
|
||||
newest_items = Item.newest.
|
||||
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
|
||||
.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
|
||||
@species_count = Species.count
|
||||
|
|
|
@ -1,56 +0,0 @@
|
|||
class PetStatesController < ApplicationController
|
||||
before_action :support_staff_only
|
||||
before_action :find_pet_state
|
||||
before_action :preload_assets
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @pet_state.update(pet_state_params)
|
||||
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def find_pet_state
|
||||
@pet_type = PetType.find_by_param!(params[:pet_type_name])
|
||||
@pet_state = @pet_type.pet_states.find(params[:id])
|
||||
@reference_pet_type = @pet_type.reference
|
||||
end
|
||||
|
||||
def preload_assets
|
||||
SwfAsset.preload_manifests @pet_state.swf_assets
|
||||
end
|
||||
|
||||
def pet_state_params
|
||||
params.require(:pet_state).permit(:pose, :glitched)
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-appearance"
|
||||
next_unlabeled_appearance_path
|
||||
else
|
||||
@pet_type
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_appearance_path
|
||||
unlabeled_appearance =
|
||||
PetState.next_unlabeled_appearance(after_id: params[:after])
|
||||
|
||||
if unlabeled_appearance
|
||||
edit_pet_type_pet_state_path(
|
||||
unlabeled_appearance.pet_type,
|
||||
unlabeled_appearance,
|
||||
next: "unlabeled-appearance"
|
||||
)
|
||||
else
|
||||
@pet_type
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,111 +1,10 @@
|
|||
class PetTypesController < ApplicationController
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@species_names = Species.order(:name).map(&:human_name)
|
||||
@color_names = Color.order(:name).map(&:human_name)
|
||||
|
||||
if params[:species].present?
|
||||
@selected_species = Species.find_by!(name: params[:species])
|
||||
@selected_species_name = @selected_species.human_name
|
||||
end
|
||||
if params[:color].present?
|
||||
@selected_color = Color.find_by!(name: params[:color])
|
||||
@selected_color_name = @selected_color.human_name
|
||||
end
|
||||
@selected_order =
|
||||
if @selected_species.present? || @selected_color.present?
|
||||
:alphabetical
|
||||
else
|
||||
:newest
|
||||
end
|
||||
|
||||
@pet_types = PetType.
|
||||
includes(:color, :species, :pet_states).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
|
||||
@pet_types.where!(species_id: @selected_species) if @selected_species
|
||||
@pet_types.where!(color_id: @selected_color) if @selected_color
|
||||
if @selected_order == :newest
|
||||
@pet_types.order!(created_at: :desc)
|
||||
elsif @selected_order == :alphabetical
|
||||
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
|
||||
end
|
||||
|
||||
if @selected_species && @selected_color && @pet_types.size == 1
|
||||
redirect_to @pet_types.first
|
||||
end
|
||||
|
||||
if support_staff?
|
||||
@counts = {
|
||||
total: PetState.count,
|
||||
glitched: PetState.glitched.count,
|
||||
needs_labeling: PetState.needs_labeling.count,
|
||||
usable: PetState.usable.count,
|
||||
}
|
||||
@unlabeled_appearance = PetState.next_unlabeled_appearance
|
||||
end
|
||||
}
|
||||
|
||||
format.json {
|
||||
if stale?(etag: PetState.last_updated_key)
|
||||
render json: {
|
||||
species: Species.order(:name).all,
|
||||
colors: Color.order(:name).all,
|
||||
supported_poses: PetState.all_supported_poses,
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@pet_type = find_pet_type
|
||||
@pet_type = PetType.
|
||||
where(species_id: params[:species_id]).
|
||||
where(color_id: params[:color_id]).
|
||||
first
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pet_states = group_pet_states @pet_type.pet_states
|
||||
end
|
||||
format.json { render json: @pet_type }
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# The API-ish route uses IDs, but the human-facing route uses names.
|
||||
def find_pet_type
|
||||
if params[:species_id] && params[:color_id]
|
||||
PetType.find_by!(
|
||||
species_id: params[:species_id],
|
||||
color_id: params[:color_id],
|
||||
)
|
||||
elsif params[:name]
|
||||
PetType.find_by_param!(params[:name])
|
||||
else
|
||||
raise "expected params: species_id and color_id, or name"
|
||||
end
|
||||
end
|
||||
|
||||
# The `canonical` pet states are the main ones we want to show: the most
|
||||
# canonical state for each pose. The `other` pet states are, the others!
|
||||
#
|
||||
# If no main poses are available, then we just make all the poses
|
||||
# "canonical", and show the whole mish-mash!
|
||||
def group_pet_states(pet_states)
|
||||
pose_groups = pet_states.emotion_order.group_by(&:pose)
|
||||
main_groups =
|
||||
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
other_groups =
|
||||
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
|
||||
if main_groups.empty?
|
||||
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
|
||||
end
|
||||
|
||||
canonical = main_groups.map(&:first).sort_by(&:pose)
|
||||
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
|
||||
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
|
||||
|
||||
{canonical:, other:}
|
||||
render json: @pet_type
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
class PetsController < ApplicationController
|
||||
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
|
||||
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
|
||||
rescue_from Pet::PetNotFound, with: :pet_not_found
|
||||
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
|
||||
rescue_from Pet::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
||||
|
||||
def load
|
||||
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
||||
# Uncomment this to temporarily disable modeling for most users.
|
||||
# return modeling_disabled unless user_signed_in? && current_user.admin?
|
||||
|
||||
raise Pet::PetNotFound unless params[:name]
|
||||
@pet = Pet.load(params[:name])
|
||||
points = contribute(current_user, @pet)
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
path = destination + "?" + @pet.wardrobe_query
|
||||
path = destination + @pet.wardrobe_query
|
||||
redirect_to path
|
||||
end
|
||||
|
||||
|
@ -35,8 +38,9 @@ class PetsController < ApplicationController
|
|||
|
||||
def destination
|
||||
case (params[:destination] || params[:origin])
|
||||
when 'wardrobe' then wardrobe_path
|
||||
else root_path
|
||||
when 'wardrobe' then wardrobe_path + '?'
|
||||
when 'needed_items' then needed_items_path + '?'
|
||||
else root_path + '#'
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -45,6 +49,12 @@ class PetsController < ApplicationController
|
|||
:status => :not_found
|
||||
end
|
||||
|
||||
def asset_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
pet_load_error :long_message => t('pets.load.asset_download_error'),
|
||||
:status => :gateway_timeout
|
||||
end
|
||||
|
||||
def pet_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
Rails.logger.warn e.backtrace.join("\n")
|
||||
|
|
|
@ -12,20 +12,13 @@ class SwfAssetsController < ApplicationController
|
|||
helpers.image_url("favicon.png"),
|
||||
@swf_asset.image_url,
|
||||
*@swf_asset.canvas_movie_sprite_urls,
|
||||
|
||||
# For images, `images.neopets.com` is a generally safe host to load
|
||||
# from (shouldn't be a vulnerable site or exfiltration vector), and
|
||||
# doing this can help make this header a *lot* shorter, which helps
|
||||
# our nginx reverse proxy (and probably some clients) handle it. (For
|
||||
# example, see asset `667993` for "Engulfed in Flames Effect".)
|
||||
hosts: ["https://images.neopets.com"],
|
||||
)
|
||||
}
|
||||
|
||||
policy.script_src -> {
|
||||
src_list(
|
||||
helpers.javascript_url("easeljs.min"),
|
||||
helpers.javascript_url("tweenjs.min"),
|
||||
helpers.javascript_url("lib/easeljs.min"),
|
||||
helpers.javascript_url("lib/tweenjs.min"),
|
||||
helpers.javascript_url("swf_assets/show"),
|
||||
@swf_asset.canvas_movie_library_url,
|
||||
)
|
||||
|
@ -45,14 +38,7 @@ class SwfAssetsController < ApplicationController
|
|||
|
||||
private
|
||||
|
||||
def src_list(*urls, hosts: [])
|
||||
urls.
|
||||
# Ignore any `nil`s that might arise
|
||||
filter(&:present?).
|
||||
# Remove query strings from URLs (they're invalid in CSPs)
|
||||
map { |url| url.sub(/\?.*\z/, "") }.
|
||||
# For the given `hosts`, remove all their specific URLs, and just list
|
||||
# the host itself.
|
||||
reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
|
||||
def src_list(*urls)
|
||||
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
module AltStylesHelper
|
||||
def view_or_edit_alt_style_url(alt_style)
|
||||
if support_staff?
|
||||
edit_alt_style_path alt_style
|
||||
else
|
||||
wardrobe_path(
|
||||
species: alt_style.species_id,
|
||||
color: alt_style.color_id,
|
||||
style: alt_style.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,4 +1,6 @@
|
|||
module ApplicationHelper
|
||||
include FragmentLocalization
|
||||
|
||||
def absolute_url(path_or_url)
|
||||
if path_or_url.include?('://') # already an absolute URL
|
||||
path_or_url
|
||||
|
@ -99,12 +101,6 @@ module ApplicationHelper
|
|||
"matchu@openneo.net"
|
||||
end
|
||||
|
||||
EDIT_ICON_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></g>'.html_safe
|
||||
def edit_icon(alt: "Edit")
|
||||
content_tag :svg, EDIT_ICON_SVG_SOURCE, alt:, class: "icon",
|
||||
viewBox: "0 0 24 24", style: "width: 1em; height: 1em"
|
||||
end
|
||||
|
||||
# SVG icon source from Chakra UI!
|
||||
EXTERNAL_LINK_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></g>'.html_safe
|
||||
def external_link_icon
|
||||
|
@ -127,6 +123,10 @@ module ApplicationHelper
|
|||
!@hide_home_link
|
||||
end
|
||||
|
||||
def support_staff?
|
||||
user_signed_in? && current_user.support_staff?
|
||||
end
|
||||
|
||||
def impress_2020_meta_tags
|
||||
origin = Rails.configuration.impress_2020_origin
|
||||
support_secret = Rails.application.credentials.dig(
|
||||
|
@ -142,9 +142,20 @@ module ApplicationHelper
|
|||
end
|
||||
end
|
||||
|
||||
JAVASCRIPT_LIBRARIES = {
|
||||
:jquery => 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js',
|
||||
:jquery_tmpl => 'https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js',
|
||||
}
|
||||
|
||||
def include_javascript_libraries(*library_names)
|
||||
raw(library_names.inject('') do |html, name|
|
||||
html + javascript_include_tag(JAVASCRIPT_LIBRARIES[name], defer: true)
|
||||
end)
|
||||
end
|
||||
|
||||
def locale_options
|
||||
current_locale_is_public = false
|
||||
options = I18n.available_locales.map do |available_locale|
|
||||
options = I18n.public_locales.map do |available_locale|
|
||||
current_locale_is_public = true if I18n.locale == available_locale
|
||||
# Include fallbacks data on the tag. Right now it's used in blog
|
||||
# localization, but may conceivably be used for something else later.
|
||||
|
@ -159,6 +170,13 @@ module ApplicationHelper
|
|||
|
||||
options
|
||||
end
|
||||
|
||||
def localized_cache(key={}, &block)
|
||||
localized_key = localize_fragment_key(key, locale)
|
||||
# TODO: The digest feature is handy, but it's not compatible with how we
|
||||
# check for fragments existence in the controller, so skip it for now.
|
||||
cache(localized_key, skip_digest: true, &block)
|
||||
end
|
||||
|
||||
def auth_user_sign_in_path_with_return_to
|
||||
new_auth_user_session_path :return_to => request.fullpath
|
||||
|
@ -213,10 +231,6 @@ module ApplicationHelper
|
|||
@hide_title_header = true
|
||||
end
|
||||
|
||||
def hide_after(last_day, &block)
|
||||
yield if Date.today <= last_day
|
||||
end
|
||||
|
||||
def use_responsive_design
|
||||
@use_responsive_design = true
|
||||
add_body_class "use-responsive-design"
|
||||
|
|
|
@ -14,30 +14,19 @@ module ItemsHelper
|
|||
}
|
||||
|
||||
Sizes = {
|
||||
face: 1, # 50x50
|
||||
face_3x: 6, # 150x150
|
||||
|
||||
thumb: 2, # 150x150
|
||||
full: 4, # 300x300
|
||||
large: 5, # 500x500
|
||||
xlarge: 7, # 640x640
|
||||
|
||||
zoom: 3, # 80x80
|
||||
autocrop: 9, # <varies>
|
||||
}
|
||||
|
||||
SizeUpgrades = {
|
||||
face: :face_3x,
|
||||
thumb: :full,
|
||||
full: :xlarge,
|
||||
face: 1,
|
||||
thumb: 2,
|
||||
zoom: 3,
|
||||
full: 4,
|
||||
face_2x: 6,
|
||||
}
|
||||
end
|
||||
|
||||
def pet_type_image_url(pet_type, emotion: :happy, size: :face)
|
||||
PetTypeImage::Template.expand(
|
||||
hash: pet_type.basic_image_hash || pet_type.image_hash,
|
||||
emotion: PetTypeImage::Emotions.fetch(emotion),
|
||||
size: PetTypeImage::Sizes.fetch(size),
|
||||
emotion: PetTypeImage::Emotions[emotion],
|
||||
size: PetTypeImage::Sizes[size],
|
||||
).to_s
|
||||
end
|
||||
|
||||
|
@ -257,10 +246,8 @@ module ItemsHelper
|
|||
|
||||
def pet_type_image(pet_type, emotion, size, **options)
|
||||
src = pet_type_image_url(pet_type, emotion:, size:)
|
||||
|
||||
size_2x = PetTypeImage::SizeUpgrades[size]
|
||||
srcset = if size_2x
|
||||
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
|
||||
srcset = if size == :face
|
||||
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
|
||||
end
|
||||
|
||||
image_tag(src, srcset:, **options)
|
||||
|
|
|
@ -1,4 +1,9 @@
|
|||
module OutfitsHelper
|
||||
LAST_DAY_OF_NEOPASS_ANNOUNCEMENT = Date.parse("2024-05-05")
|
||||
def show_neopass_announcement?
|
||||
Date.today <= LAST_DAY_OF_NEOPASS_ANNOUNCEMENT
|
||||
end
|
||||
|
||||
def destination_tag(value)
|
||||
hidden_field_tag 'destination', value, :id => nil
|
||||
end
|
||||
|
@ -64,28 +69,5 @@ module OutfitsHelper
|
|||
options = {:spellcheck => false, :id => nil}.merge(options)
|
||||
text_field_tag 'name', nil, options
|
||||
end
|
||||
|
||||
def outfit_viewer(...)
|
||||
render partial: "outfit_viewer",
|
||||
locals: parse_outfit_viewer_options(...)
|
||||
end
|
||||
|
||||
def support_outfit_viewer(...)
|
||||
render partial: "support_outfit_viewer",
|
||||
locals: parse_outfit_viewer_options(...)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def parse_outfit_viewer_options(
|
||||
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
|
||||
)
|
||||
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
|
||||
|
||||
if outfit.nil?
|
||||
raise ArgumentError, "outfit viewer must have outfit or pet state"
|
||||
end
|
||||
|
||||
{outfit:, preferred_image_format:, html_options:}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,41 +0,0 @@
|
|||
module PetStatesHelper
|
||||
def pose_name(pose)
|
||||
case pose
|
||||
when "HAPPY_FEM"
|
||||
"Happy (Feminine)"
|
||||
when "HAPPY_MASC"
|
||||
"Happy (Masculine)"
|
||||
when "SAD_FEM"
|
||||
"Sad (Feminine)"
|
||||
when "SAD_MASC"
|
||||
"Sad (Masculine)"
|
||||
when "SICK_FEM"
|
||||
"Sick (Feminine)"
|
||||
when "SICK_MASC"
|
||||
"Sick (Masculine)"
|
||||
when "UNCONVERTED"
|
||||
"Unconverted"
|
||||
else
|
||||
"Not labeled yet"
|
||||
end
|
||||
end
|
||||
|
||||
POSE_OPTIONS = %w(HAPPY_FEM SAD_FEM SICK_FEM HAPPY_MASC SAD_MASC SICK_MASC
|
||||
UNCONVERTED UNKNOWN)
|
||||
def pose_options
|
||||
POSE_OPTIONS
|
||||
end
|
||||
|
||||
def useful_pet_state_path(pet_type, pet_state)
|
||||
if support_staff?
|
||||
edit_pet_type_pet_state_path(pet_type, pet_state)
|
||||
else
|
||||
wardrobe_path(
|
||||
color: pet_type.color_id,
|
||||
species: pet_type.species_id,
|
||||
pose: pet_state.pose,
|
||||
state: pet_state.id,
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,16 +0,0 @@
|
|||
module PetTypesHelper
|
||||
def moon_progress(num, total)
|
||||
nearest_quarter = (4.0 * num / total).round / 4.0
|
||||
if nearest_quarter >= 1
|
||||
"🌕️"
|
||||
elsif nearest_quarter >= 0.75
|
||||
"🌔"
|
||||
elsif nearest_quarter >= 0.5
|
||||
"🌓"
|
||||
elsif nearest_quarter >= 0.25
|
||||
"🌒"
|
||||
else
|
||||
"🌑"
|
||||
end
|
||||
end
|
||||
end
|
|
@ -1,64 +0,0 @@
|
|||
module SupportFormHelper
|
||||
class SupportFormBuilder < ActionView::Helpers::FormBuilder
|
||||
attr_reader :template
|
||||
delegate :capture, :check_box_tag, :concat, :content_tag,
|
||||
:hidden_field_tag, :params, :render,
|
||||
to: :template, private: true
|
||||
|
||||
def errors
|
||||
render partial: "application/support_form/errors", locals: {form: self}
|
||||
end
|
||||
|
||||
def fields(&block)
|
||||
content_tag(:ul, class: "fields", &block)
|
||||
end
|
||||
|
||||
def field(**options, &block)
|
||||
content_tag(:li, **options, &block)
|
||||
end
|
||||
|
||||
def radio_fieldset(legend, **options, &block)
|
||||
render partial: "application/support_form/radio_fieldset",
|
||||
locals: {form: self, legend:, options:, content: capture(&block)}
|
||||
end
|
||||
|
||||
def radio_field(**options, &block)
|
||||
content_tag(:li) do
|
||||
content_tag(:label, **options, &block)
|
||||
end
|
||||
end
|
||||
|
||||
def radio_grid_fieldset(*args, &block)
|
||||
radio_fieldset(*args, "data-type": "radio-grid", &block)
|
||||
end
|
||||
|
||||
def thumbnail_input(method)
|
||||
render partial: "application/support_form/thumbnail_input",
|
||||
locals: {form: self, method:}
|
||||
end
|
||||
|
||||
def actions(&block)
|
||||
content_tag(:section, class: "actions", &block)
|
||||
end
|
||||
|
||||
def go_to_next_field(after: nil, **options, &block)
|
||||
content_tag(:label, class: "go-to-next", **options) do
|
||||
concat hidden_field_tag(:after, after) if after
|
||||
yield
|
||||
end
|
||||
end
|
||||
|
||||
def go_to_next_check_box(value)
|
||||
check_box_tag "next", value, checked: params[:next] == value
|
||||
end
|
||||
end
|
||||
|
||||
def support_form_with(**options, &block)
|
||||
form_with(
|
||||
builder: SupportFormBuilder,
|
||||
**options,
|
||||
class: ["support-form", options[:class]],
|
||||
&block
|
||||
)
|
||||
end
|
||||
end
|
|
@ -1,6 +1,5 @@
|
|||
import "@hotwired/turbo-rails";
|
||||
|
||||
document.addEventListener("change", (e) => {
|
||||
if (!e.target.matches("#locale")) return;
|
||||
document.getElementById("locale-form").submit();
|
||||
document.getElementById("locale").addEventListener("change", function () {
|
||||
document.getElementById("locale-form").submit();
|
||||
});
|
||||
|
|
|
@ -7,8 +7,8 @@ const rootNode = document.querySelector("#wardrobe-2020-root");
|
|||
// TODO: Use the new React 18 APIs instead!
|
||||
// eslint-disable-next-line react/no-deprecated
|
||||
ReactDOM.render(
|
||||
<AppProvider>
|
||||
<WardrobePage />
|
||||
</AppProvider>,
|
||||
rootNode,
|
||||
<AppProvider>
|
||||
<WardrobePage />
|
||||
</AppProvider>,
|
||||
rootNode,
|
||||
);
|
||||
|
|
|
@ -2,12 +2,12 @@ import React from "react";
|
|||
import * as Sentry from "@sentry/react";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
import {
|
||||
ChakraProvider,
|
||||
Box,
|
||||
css as resolveCSS,
|
||||
extendTheme,
|
||||
useColorMode,
|
||||
useTheme,
|
||||
ChakraProvider,
|
||||
Box,
|
||||
css as resolveCSS,
|
||||
extendTheme,
|
||||
useColorMode,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { mode } from "@chakra-ui/theme-tools";
|
||||
import { ApolloProvider } from "@apollo/client";
|
||||
|
@ -20,15 +20,15 @@ import apolloClient from "./apolloClient";
|
|||
const reactQueryClient = new QueryClient();
|
||||
|
||||
let theme = extendTheme({
|
||||
styles: {
|
||||
global: (props) => ({
|
||||
body: {
|
||||
background: mode("gray.50", "gray.800")(props),
|
||||
color: mode("green.800", "green.50")(props),
|
||||
transition: "all 0.25s",
|
||||
},
|
||||
}),
|
||||
},
|
||||
styles: {
|
||||
global: (props) => ({
|
||||
body: {
|
||||
background: mode("gray.50", "gray.800")(props),
|
||||
color: mode("green.800", "green.50")(props),
|
||||
transition: "all 0.25s",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
// Capture the global styles function from our theme, but remove it from the
|
||||
|
@ -43,60 +43,60 @@ const globalStyles = theme.styles.global;
|
|||
theme.styles.global = {};
|
||||
|
||||
export default function AppProvider({ children }) {
|
||||
React.useEffect(() => setupLogging(), []);
|
||||
React.useEffect(() => setupLogging(), []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ChakraProvider resetCSS={false} theme={theme}>
|
||||
<ScopedCSSReset>{children}</ScopedCSSReset>
|
||||
</ChakraProvider>
|
||||
</ApolloProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<QueryClientProvider client={reactQueryClient}>
|
||||
<ApolloProvider client={apolloClient}>
|
||||
<ChakraProvider resetCSS={false} theme={theme}>
|
||||
<ScopedCSSReset>{children}</ScopedCSSReset>
|
||||
</ChakraProvider>
|
||||
</ApolloProvider>
|
||||
</QueryClientProvider>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
function setupLogging() {
|
||||
Sentry.init({
|
||||
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
|
||||
autoSessionTracking: true,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
beforeNavigate: (context) => ({
|
||||
...context,
|
||||
// Assume any path segment starting with a digit is an ID, and replace
|
||||
// it with `:id`. This will help group related routes in Sentry stats.
|
||||
// NOTE: I'm a bit uncertain about the timing on this for tracking
|
||||
// client-side navs... but we now only track first-time
|
||||
// pageloads, and it definitely works correctly for them!
|
||||
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
|
||||
}),
|
||||
Sentry.init({
|
||||
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
|
||||
autoSessionTracking: true,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
beforeNavigate: (context) => ({
|
||||
...context,
|
||||
// Assume any path segment starting with a digit is an ID, and replace
|
||||
// it with `:id`. This will help group related routes in Sentry stats.
|
||||
// NOTE: I'm a bit uncertain about the timing on this for tracking
|
||||
// client-side navs... but we now only track first-time
|
||||
// pageloads, and it definitely works correctly for them!
|
||||
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
|
||||
}),
|
||||
|
||||
// We have a _lot_ of location changes that don't actually signify useful
|
||||
// navigations, like in the wardrobe page. It could be useful to trace
|
||||
// them with better filtering someday, but frankly we don't use the perf
|
||||
// features besides Web Vitals right now, and those only get tracked on
|
||||
// first-time pageloads, anyway. So, don't track client-side navs!
|
||||
startTransactionOnLocationChange: false,
|
||||
}),
|
||||
],
|
||||
denyUrls: [
|
||||
// Don't log errors that were probably triggered by extensions and not by
|
||||
// our own app. (Apparently Sentry's setting to ignore browser extension
|
||||
// errors doesn't do this anywhere near as consistently as I'd expect?)
|
||||
//
|
||||
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
|
||||
/^chrome-extension:\/\//,
|
||||
/^moz-extension:\/\//,
|
||||
],
|
||||
// We have a _lot_ of location changes that don't actually signify useful
|
||||
// navigations, like in the wardrobe page. It could be useful to trace
|
||||
// them with better filtering someday, but frankly we don't use the perf
|
||||
// features besides Web Vitals right now, and those only get tracked on
|
||||
// first-time pageloads, anyway. So, don't track client-side navs!
|
||||
startTransactionOnLocationChange: false,
|
||||
}),
|
||||
],
|
||||
denyUrls: [
|
||||
// Don't log errors that were probably triggered by extensions and not by
|
||||
// our own app. (Apparently Sentry's setting to ignore browser extension
|
||||
// errors doesn't do this anywhere near as consistently as I'd expect?)
|
||||
//
|
||||
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
|
||||
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
|
||||
/^chrome-extension:\/\//,
|
||||
/^moz-extension:\/\//,
|
||||
],
|
||||
|
||||
// Since we're only tracking first-page loads and not navigations, 100%
|
||||
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
// Since we're only tracking first-page loads and not navigations, 100%
|
||||
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
|
||||
tracesSampleRate: 1.0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -112,308 +112,308 @@ function setupLogging() {
|
|||
* the selector `:where(.chakra-css-reset) h1` is lower specificity.
|
||||
*/
|
||||
function ScopedCSSReset({ children }) {
|
||||
// Get the current theme and color mode.
|
||||
//
|
||||
// NOTE: The theme object returned by `useTheme` has some extensions that are
|
||||
// necessary for the code below, but aren't present in the theme config
|
||||
// returned by `extendTheme`! That's why we use this here instead of `theme`.
|
||||
const liveTheme = useTheme();
|
||||
const colorMode = useColorMode();
|
||||
// Get the current theme and color mode.
|
||||
//
|
||||
// NOTE: The theme object returned by `useTheme` has some extensions that are
|
||||
// necessary for the code below, but aren't present in the theme config
|
||||
// returned by `extendTheme`! That's why we use this here instead of `theme`.
|
||||
const liveTheme = useTheme();
|
||||
const colorMode = useColorMode();
|
||||
|
||||
// Resolve the theme's global styles into CSS objects for Emotion.
|
||||
const globalStylesCSS = resolveCSS(
|
||||
globalStyles({ theme: liveTheme, colorMode }),
|
||||
)(liveTheme);
|
||||
// Resolve the theme's global styles into CSS objects for Emotion.
|
||||
const globalStylesCSS = resolveCSS(
|
||||
globalStyles({ theme: liveTheme, colorMode }),
|
||||
)(liveTheme);
|
||||
|
||||
// Prepend our special scope selector to the global styles.
|
||||
const scopedGlobalStylesCSS = {};
|
||||
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
|
||||
// The `body` selector is where typography etc rules go, but `body` isn't
|
||||
// actually *inside* our scoped element! Instead, ignore the `body` part,
|
||||
// and just apply it to the scoping element itself.
|
||||
if (selector.trim() === "body") {
|
||||
selector = "";
|
||||
}
|
||||
// Prepend our special scope selector to the global styles.
|
||||
const scopedGlobalStylesCSS = {};
|
||||
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
|
||||
// The `body` selector is where typography etc rules go, but `body` isn't
|
||||
// actually *inside* our scoped element! Instead, ignore the `body` part,
|
||||
// and just apply it to the scoping element itself.
|
||||
if (selector.trim() === "body") {
|
||||
selector = "";
|
||||
}
|
||||
|
||||
const scopedSelector =
|
||||
":where(.chakra-css-reset, .chakra-portal) " + selector;
|
||||
scopedGlobalStylesCSS[scopedSelector] = rules;
|
||||
}
|
||||
const scopedSelector =
|
||||
":where(.chakra-css-reset, .chakra-portal) " + selector;
|
||||
scopedGlobalStylesCSS[scopedSelector] = rules;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="chakra-css-reset">{children}</Box>
|
||||
<Global
|
||||
styles={css`
|
||||
/* Chakra's default global styles, placed here so we can override
|
||||
return (
|
||||
<>
|
||||
<Box className="chakra-css-reset">{children}</Box>
|
||||
<Global
|
||||
styles={css`
|
||||
/* Chakra's default global styles, placed here so we can override
|
||||
* the actual _global_ styles in the theme to be empty. That way,
|
||||
* it only affects Chakra stuff, not all elements! */
|
||||
${scopedGlobalStylesCSS}
|
||||
${scopedGlobalStylesCSS}
|
||||
|
||||
/* Chakra's CSS reset, copy-pasted and rescoped! */
|
||||
/* Chakra's CSS reset, copy-pasted and rescoped! */
|
||||
:where(.chakra-css-reset, .chakra-portal) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
border-width: 0;
|
||||
border-style: solid;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
main {
|
||||
display: block;
|
||||
}
|
||||
|
||||
hr {
|
||||
border-top-width: 1px;
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
hr {
|
||||
border-top-width: 1px;
|
||||
box-sizing: content-box;
|
||||
height: 0;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
pre,
|
||||
code,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
a {
|
||||
background-color: transparent;
|
||||
color: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
abbr[title] {
|
||||
border-bottom: none;
|
||||
text-decoration: underline;
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
|
||||
b,
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
b,
|
||||
strong {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub,
|
||||
sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
img {
|
||||
border-style: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
font-family: inherit;
|
||||
font-size: 100%;
|
||||
line-height: 1.15;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
button,
|
||||
input {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
button,
|
||||
select {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
button::-moz-focus-inner,
|
||||
[type="button"]::-moz-focus-inner,
|
||||
[type="reset"]::-moz-focus-inner,
|
||||
[type="submit"]::-moz-focus-inner {
|
||||
border-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
fieldset {
|
||||
padding: 0.35em 0.75em 0.625em;
|
||||
}
|
||||
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
legend {
|
||||
box-sizing: border-box;
|
||||
color: inherit;
|
||||
display: table;
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
textarea {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
[type="checkbox"],
|
||||
[type="radio"] {
|
||||
box-sizing: border-box;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
[type="number"]::-webkit-inner-spin-button,
|
||||
[type="number"]::-webkit-outer-spin-button {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
[type="search"] {
|
||||
-webkit-appearance: textfield;
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
[type="search"]::-webkit-search-decoration {
|
||||
-webkit-appearance: none !important;
|
||||
}
|
||||
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
::-webkit-file-upload-button {
|
||||
-webkit-appearance: button;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
details {
|
||||
display: block;
|
||||
}
|
||||
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
template {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body,
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
body,
|
||||
blockquote,
|
||||
dl,
|
||||
dd,
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6,
|
||||
hr,
|
||||
figure,
|
||||
p,
|
||||
pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
button {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
fieldset {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
ol,
|
||||
ul {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
button,
|
||||
[role="button"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button::-moz-focus-inner {
|
||||
border: 0 !important;
|
||||
}
|
||||
button::-moz-focus-inner {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
h5,
|
||||
h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
button,
|
||||
input,
|
||||
optgroup,
|
||||
select,
|
||||
textarea {
|
||||
padding: 0;
|
||||
line-height: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
}
|
||||
img,
|
||||
svg,
|
||||
video,
|
||||
canvas,
|
||||
audio,
|
||||
iframe,
|
||||
embed,
|
||||
object {
|
||||
display: block;
|
||||
}
|
||||
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
img,
|
||||
video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
select::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
905
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal file
|
@ -0,0 +1,905 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useToken,
|
||||
Wrap,
|
||||
WrapItem,
|
||||
Flex,
|
||||
} from "@chakra-ui/react";
|
||||
import { WarningTwoIcon } from "@chakra-ui/icons";
|
||||
import gql from "graphql-tag";
|
||||
import { useQuery } from "@apollo/client";
|
||||
|
||||
function SpeciesFacesPicker({
|
||||
selectedSpeciesId,
|
||||
selectedColorId,
|
||||
compatibleBodies,
|
||||
couldProbablyModelMoreData,
|
||||
onChange,
|
||||
isLoading,
|
||||
}) {
|
||||
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
|
||||
// data, which is part of the bundle and loads super-fast. For other colors,
|
||||
// we load in all the faces of that color, falling back to basic colors when
|
||||
// absent!
|
||||
//
|
||||
// TODO: Could we move this into our `build-cached-data` script, and just do
|
||||
// the query all the time, and have Apollo happen to satisfy it fast?
|
||||
// The semantics of returning our colorful random set could be weird…
|
||||
const selectedColorIsBasic = colorIsBasic(selectedColorId);
|
||||
const {
|
||||
loading: loadingGQL,
|
||||
error,
|
||||
data,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query SpeciesFacesPicker($selectedColorId: ID!) {
|
||||
color(id: $selectedColorId) {
|
||||
id
|
||||
appliedToAllCompatibleSpecies {
|
||||
id
|
||||
neopetsImageHash
|
||||
species {
|
||||
id
|
||||
}
|
||||
body {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
{
|
||||
variables: { selectedColorId },
|
||||
skip: selectedColorId == null || selectedColorIsBasic,
|
||||
onError: (e) => console.error(e),
|
||||
},
|
||||
);
|
||||
|
||||
const allBodiesAreCompatible = compatibleBodies.some(
|
||||
(body) => body.id === "0",
|
||||
);
|
||||
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
|
||||
|
||||
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
|
||||
|
||||
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
|
||||
const providedSpeciesFace = speciesFacesFromData.find(
|
||||
(f) => f.species.id === defaultSpeciesFace.speciesId,
|
||||
);
|
||||
if (providedSpeciesFace) {
|
||||
return {
|
||||
...defaultSpeciesFace,
|
||||
colorId: selectedColorId,
|
||||
bodyId: providedSpeciesFace.body.id,
|
||||
// If this species/color pair exists, but without an image hash, then
|
||||
// we want to provide a face so that it's enabled, but use the fallback
|
||||
// image even though it's wrong, so that it looks like _something_.
|
||||
neopetsImageHash:
|
||||
providedSpeciesFace.neopetsImageHash ||
|
||||
defaultSpeciesFace.neopetsImageHash,
|
||||
};
|
||||
} else {
|
||||
return defaultSpeciesFace;
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Wrap spacing="0" justify="center">
|
||||
{allSpeciesFaces.map((speciesFace) => (
|
||||
<WrapItem key={speciesFace.speciesId}>
|
||||
<SpeciesFaceOption
|
||||
speciesId={speciesFace.speciesId}
|
||||
speciesName={speciesFace.speciesName}
|
||||
colorId={speciesFace.colorId}
|
||||
neopetsImageHash={speciesFace.neopetsImageHash}
|
||||
isSelected={speciesFace.speciesId === selectedSpeciesId}
|
||||
// If the face color doesn't match the current color, this is a
|
||||
// fallback face for an invalid species/color pair.
|
||||
isValid={
|
||||
speciesFace.colorId === selectedColorId || selectedColorIsBasic
|
||||
}
|
||||
bodyIsCompatible={
|
||||
allBodiesAreCompatible ||
|
||||
compatibleBodyIds.includes(speciesFace.bodyId)
|
||||
}
|
||||
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||
onChange={onChange}
|
||||
isLoading={isLoading || loadingGQL}
|
||||
/>
|
||||
</WrapItem>
|
||||
))}
|
||||
</Wrap>
|
||||
{error && (
|
||||
<Flex
|
||||
color="yellow.500"
|
||||
fontSize="xs"
|
||||
marginTop="1"
|
||||
textAlign="center"
|
||||
width="100%"
|
||||
align="flex-start"
|
||||
justify="center"
|
||||
>
|
||||
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
|
||||
<Box>
|
||||
Error loading this color's pet photos.
|
||||
<br />
|
||||
Check your connection and try again.
|
||||
</Box>
|
||||
</Flex>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
const SpeciesFaceOption = React.memo(
|
||||
({
|
||||
speciesId,
|
||||
speciesName,
|
||||
colorId,
|
||||
neopetsImageHash,
|
||||
isSelected,
|
||||
bodyIsCompatible,
|
||||
isValid,
|
||||
couldProbablyModelMoreData,
|
||||
onChange,
|
||||
isLoading,
|
||||
}) => {
|
||||
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
||||
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
||||
const focusBorderColor = "blue.400";
|
||||
const focusBackgroundColor = "blue.100";
|
||||
const [
|
||||
selectedBorderColorValue,
|
||||
selectedBackgroundColorValue,
|
||||
focusBorderColorValue,
|
||||
focusBackgroundColorValue,
|
||||
] = useToken("colors", [
|
||||
selectedBorderColor,
|
||||
selectedBackgroundColor,
|
||||
focusBorderColor,
|
||||
focusBackgroundColor,
|
||||
]);
|
||||
const xlShadow = useToken("shadows", "xl");
|
||||
|
||||
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
||||
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||
|
||||
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
||||
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
||||
const emotionId = isHappy ? "1" : "2";
|
||||
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
||||
|
||||
let disabledExplanation = null;
|
||||
if (isLoading) {
|
||||
// If we're still loading, don't try to explain anything yet!
|
||||
} else if (!isValid) {
|
||||
disabledExplanation = "(Can't be this color)";
|
||||
} else if (!bodyIsCompatible) {
|
||||
disabledExplanation = couldProbablyModelMoreData
|
||||
? "(Item needs models)"
|
||||
: "(Not compatible)";
|
||||
}
|
||||
|
||||
const tooltipLabel = (
|
||||
<div style={{ textAlign: "center" }}>
|
||||
{speciesName}
|
||||
{disabledExplanation && (
|
||||
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
||||
{disabledExplanation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
||||
// elements like Box helps with render performance!
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<DeferredTooltip
|
||||
label={tooltipLabel}
|
||||
placement="top"
|
||||
gutter={-10}
|
||||
// We track hover and focus state manually for the tooltip, so that
|
||||
// keyboard nav to switch between options causes the tooltip to
|
||||
// follow. (By default, the tooltip appears on the first tab focus,
|
||||
// but not when you _change_ options!)
|
||||
isOpen={labelIsHovered || inputIsFocused}
|
||||
>
|
||||
<label
|
||||
style={{ cursor }}
|
||||
onMouseEnter={() => setLabelIsHovered(true)}
|
||||
onMouseLeave={() => setLabelIsHovered(false)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
aria-label={speciesName}
|
||||
name="species-faces-picker"
|
||||
value={speciesId}
|
||||
checked={isSelected}
|
||||
// It's possible to get this selected via the SpeciesColorPicker,
|
||||
// even if this would normally be disabled. If so, make this
|
||||
// option enabled, so keyboard users can focus and change it.
|
||||
disabled={isDisabled && !isSelected}
|
||||
onChange={() => onChange({ speciesId, colorId })}
|
||||
onFocus={() => setInputIsFocused(true)}
|
||||
onBlur={() => setInputIsFocused(false)}
|
||||
className={css`
|
||||
/* Copied from Chakra's <VisuallyHidden /> */
|
||||
border: 0px;
|
||||
clip: rect(0px, 0px, 0px, 0px);
|
||||
height: 1px;
|
||||
width: 1px;
|
||||
margin: -1px;
|
||||
padding: 0px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
position: absolute;
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={css`
|
||||
overflow: hidden;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
|
||||
input:checked + & {
|
||||
background: ${selectedBackgroundColorValue};
|
||||
border-radius: 6px;
|
||||
box-shadow:
|
||||
${xlShadow},
|
||||
${selectedBorderColorValue} 0 0 2px 2px;
|
||||
transform: scale(1.2);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
input:focus + & {
|
||||
background: ${focusBackgroundColorValue};
|
||||
box-shadow:
|
||||
${xlShadow},
|
||||
${focusBorderColorValue} 0 0 0 3px;
|
||||
}
|
||||
`}
|
||||
>
|
||||
<CrossFadeImage
|
||||
src={`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
||||
srcSet={
|
||||
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
||||
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
||||
}
|
||||
alt={speciesName}
|
||||
width={55}
|
||||
height={55}
|
||||
data-is-loading={isLoading}
|
||||
data-is-disabled={isDisabled}
|
||||
className={css`
|
||||
filter: saturate(90%);
|
||||
opacity: 0.9;
|
||||
transition: all 0.2s;
|
||||
|
||||
&[data-is-disabled="true"] {
|
||||
filter: saturate(0%);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&[data-is-loading="true"] {
|
||||
animation: 0.8s linear 0s infinite alternate none running
|
||||
pulse;
|
||||
}
|
||||
|
||||
input:checked + * &[data-body-is-disabled="false"] {
|
||||
opacity: 1;
|
||||
filter: saturate(110%);
|
||||
}
|
||||
|
||||
input:checked + * &[data-body-is-disabled="true"] {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from {
|
||||
opacity: 0.5;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Alt text for when the image fails to load! We hide it
|
||||
* while still loading though! */
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
&:-moz-loading {
|
||||
visibility: hidden;
|
||||
}
|
||||
&:-moz-broken {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
</DeferredTooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* CrossFadeImage is like <img>, but listens for successful load events, and
|
||||
* fades from the previous image to the new image once it loads.
|
||||
*
|
||||
* We treat `src` as a unique key representing the image's identity, but we
|
||||
* also carry along the rest of the props during the fade, like `srcSet` and
|
||||
* `className`.
|
||||
*/
|
||||
function CrossFadeImage(incomingImageProps) {
|
||||
const [prevImageProps, setPrevImageProps] = React.useState(null);
|
||||
const [currentImageProps, setCurrentImageProps] = React.useState(null);
|
||||
|
||||
const incomingImageIsCurrentImage =
|
||||
incomingImageProps.src === currentImageProps?.src;
|
||||
|
||||
const onLoadNextImage = () => {
|
||||
setPrevImageProps(currentImageProps);
|
||||
setCurrentImageProps(incomingImageProps);
|
||||
};
|
||||
|
||||
// The main trick to this component is using React's `key` feature! When
|
||||
// diffing the rendered tree, if React sees two nodes with the same `key`, it
|
||||
// treats them as the same node and makes the prop changes to match.
|
||||
//
|
||||
// We usually use this in `.map`, to make sure that adds/removes in a list
|
||||
// don't cause our children to shift around and swap their React state or DOM
|
||||
// nodes with each other.
|
||||
//
|
||||
// But here, we use `key` to get React to transition the same <img> DOM node
|
||||
// between 3 different states!
|
||||
//
|
||||
// The image starts its life as the last in the list, from
|
||||
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
|
||||
// as the `key`.
|
||||
//
|
||||
// When it loads, we update the state so that this `key` now belongs to the
|
||||
// _second_ node, from `currentImageProps`. React will see this and make the
|
||||
// correct transition for us: it sets opacity to 0, sets z-index to 2,
|
||||
// removes aria-hidden, and removes the `onLoad` handler.
|
||||
//
|
||||
// Then, when another image is ready to show, we update the state so that
|
||||
// this key now belongs to the _first_ node, from `prevImageProps` (and the
|
||||
// second node is showing something new). React sees this, and makes the
|
||||
// transition back to invisibility, but without the `onLoad` handler this
|
||||
// time! (And transitions the current image into view, like it did for this
|
||||
// one.)
|
||||
//
|
||||
// Finally, when yet _another_ image is ready to show, we stop rendering any
|
||||
// images with this key anymore, and so React unmounts the image entirely.
|
||||
//
|
||||
// Thanks, React, for handling our multiple overlapping transitions through
|
||||
// this little state machine! This could have been a LOT harder to write,
|
||||
// whew!
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
display: grid;
|
||||
grid-template-areas: "shared-overlapping-area";
|
||||
isolation: isolate; /* Avoid z-index conflicts with parent! */
|
||||
|
||||
> div {
|
||||
grid-area: shared-overlapping-area;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
`}
|
||||
>
|
||||
{prevImageProps && (
|
||||
<div
|
||||
key={prevImageProps.src}
|
||||
className={css`
|
||||
z-index: 3;
|
||||
opacity: 0;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img {...prevImageProps} aria-hidden />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentImageProps && (
|
||||
<div
|
||||
key={currentImageProps.src}
|
||||
className={css`
|
||||
z-index: 2;
|
||||
opacity: 1;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img
|
||||
{...currentImageProps}
|
||||
// If the current image _is_ the incoming image, we'll allow
|
||||
// new props to come in and affect it. But if it's a new image
|
||||
// incoming, we want to stick to the last props the current
|
||||
// image had! (This matters for e.g. `bodyIsCompatible`
|
||||
// becoming true in `SpeciesFaceOption` and restoring color,
|
||||
// before the new color's image loads in.)
|
||||
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!incomingImageIsCurrentImage && (
|
||||
<div
|
||||
key={incomingImageProps.src}
|
||||
className={css`
|
||||
z-index: 1;
|
||||
opacity: 0;
|
||||
`}
|
||||
>
|
||||
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||
<img
|
||||
{...incomingImageProps}
|
||||
aria-hidden
|
||||
onLoad={onLoadNextImage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
|
||||
* true before mounting it, and unmounts it after closing.
|
||||
*
|
||||
* This can drastically improve render performance when there are lots of
|
||||
* tooltip targets to re-render… but it comes with some limitations, like the
|
||||
* extra requirement to control `isOpen`, and some additional DOM structure!
|
||||
*/
|
||||
function DeferredTooltip({ children, isOpen, ...props }) {
|
||||
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setShouldShowToolip(true);
|
||||
} else {
|
||||
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<div
|
||||
className={css`
|
||||
position: relative;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
{shouldShowTooltip && (
|
||||
<Tooltip isOpen={isOpen} {...props}>
|
||||
<div
|
||||
className={css`
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
pointer-events: none;
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
// HACK: I'm just hardcoding all this, rather than connecting up to the
|
||||
// database and adding a loading state. Tbh I'm not sure it's a good idea
|
||||
// to load this dynamically until we have SSR to make it come in fast!
|
||||
// And it's not so bad if this gets out of sync with the database,
|
||||
// because the SpeciesColorPicker will still be usable!
|
||||
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
|
||||
|
||||
export function colorIsBasic(colorId) {
|
||||
return ["8", "34", "61", "84"].includes(colorId);
|
||||
}
|
||||
|
||||
const DEFAULT_SPECIES_FACES = [
|
||||
{
|
||||
speciesName: "Acara",
|
||||
speciesId: "1",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "93",
|
||||
neopetsImageHash: "obxdjm88",
|
||||
},
|
||||
{
|
||||
speciesName: "Aisha",
|
||||
speciesId: "2",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "106",
|
||||
neopetsImageHash: "n9ozx4z5",
|
||||
},
|
||||
{
|
||||
speciesName: "Blumaroo",
|
||||
speciesId: "3",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "47",
|
||||
neopetsImageHash: "kfonqhdc",
|
||||
},
|
||||
{
|
||||
speciesName: "Bori",
|
||||
speciesId: "4",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "84",
|
||||
neopetsImageHash: "sc2hhvhn",
|
||||
},
|
||||
{
|
||||
speciesName: "Bruce",
|
||||
speciesId: "5",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "146",
|
||||
neopetsImageHash: "wqz8xn4t",
|
||||
},
|
||||
{
|
||||
speciesName: "Buzz",
|
||||
speciesId: "6",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "250",
|
||||
neopetsImageHash: "jc9klfxm",
|
||||
},
|
||||
{
|
||||
speciesName: "Chia",
|
||||
speciesId: "7",
|
||||
colorId: colors.RED,
|
||||
bodyId: "212",
|
||||
neopetsImageHash: "4lrb4n3f",
|
||||
},
|
||||
{
|
||||
speciesName: "Chomby",
|
||||
speciesId: "8",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "74",
|
||||
neopetsImageHash: "bdml26md",
|
||||
},
|
||||
{
|
||||
speciesName: "Cybunny",
|
||||
speciesId: "9",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "94",
|
||||
neopetsImageHash: "xl6msllv",
|
||||
},
|
||||
{
|
||||
speciesName: "Draik",
|
||||
speciesId: "10",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "132",
|
||||
neopetsImageHash: "bob39shq",
|
||||
},
|
||||
{
|
||||
speciesName: "Elephante",
|
||||
speciesId: "11",
|
||||
colorId: colors.RED,
|
||||
bodyId: "56",
|
||||
neopetsImageHash: "jhhhbrww",
|
||||
},
|
||||
{
|
||||
speciesName: "Eyrie",
|
||||
speciesId: "12",
|
||||
colorId: colors.RED,
|
||||
bodyId: "90",
|
||||
neopetsImageHash: "6kngmhvs",
|
||||
},
|
||||
{
|
||||
speciesName: "Flotsam",
|
||||
speciesId: "13",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "136",
|
||||
neopetsImageHash: "47vt32x2",
|
||||
},
|
||||
{
|
||||
speciesName: "Gelert",
|
||||
speciesId: "14",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "138",
|
||||
neopetsImageHash: "5nrd2lvd",
|
||||
},
|
||||
{
|
||||
speciesName: "Gnorbu",
|
||||
speciesId: "15",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "166",
|
||||
neopetsImageHash: "6c275jcg",
|
||||
},
|
||||
{
|
||||
speciesName: "Grarrl",
|
||||
speciesId: "16",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "119",
|
||||
neopetsImageHash: "j7q65fv4",
|
||||
},
|
||||
{
|
||||
speciesName: "Grundo",
|
||||
speciesId: "17",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "126",
|
||||
neopetsImageHash: "5xn4kjf8",
|
||||
},
|
||||
{
|
||||
speciesName: "Hissi",
|
||||
speciesId: "18",
|
||||
colorId: colors.RED,
|
||||
bodyId: "67",
|
||||
neopetsImageHash: "jsfvcqwt",
|
||||
},
|
||||
{
|
||||
speciesName: "Ixi",
|
||||
speciesId: "19",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "163",
|
||||
neopetsImageHash: "w32r74vo",
|
||||
},
|
||||
{
|
||||
speciesName: "Jetsam",
|
||||
speciesId: "20",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "147",
|
||||
neopetsImageHash: "kz43rnld",
|
||||
},
|
||||
{
|
||||
speciesName: "Jubjub",
|
||||
speciesId: "21",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "80",
|
||||
neopetsImageHash: "m267j935",
|
||||
},
|
||||
{
|
||||
speciesName: "Kacheek",
|
||||
speciesId: "22",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "117",
|
||||
neopetsImageHash: "4gsrb59g",
|
||||
},
|
||||
{
|
||||
speciesName: "Kau",
|
||||
speciesId: "23",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "201",
|
||||
neopetsImageHash: "ktlxmrtr",
|
||||
},
|
||||
{
|
||||
speciesName: "Kiko",
|
||||
speciesId: "24",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "51",
|
||||
neopetsImageHash: "42j5q3zx",
|
||||
},
|
||||
{
|
||||
speciesName: "Koi",
|
||||
speciesId: "25",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "208",
|
||||
neopetsImageHash: "ncfn87wk",
|
||||
},
|
||||
{
|
||||
speciesName: "Korbat",
|
||||
speciesId: "26",
|
||||
colorId: colors.RED,
|
||||
bodyId: "196",
|
||||
neopetsImageHash: "omx9c876",
|
||||
},
|
||||
{
|
||||
speciesName: "Kougra",
|
||||
speciesId: "27",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "143",
|
||||
neopetsImageHash: "rfsbh59t",
|
||||
},
|
||||
{
|
||||
speciesName: "Krawk",
|
||||
speciesId: "28",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "150",
|
||||
neopetsImageHash: "hxgsm5d4",
|
||||
},
|
||||
{
|
||||
speciesName: "Kyrii",
|
||||
speciesId: "29",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "175",
|
||||
neopetsImageHash: "blxmjgbk",
|
||||
},
|
||||
{
|
||||
speciesName: "Lenny",
|
||||
speciesId: "30",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "173",
|
||||
neopetsImageHash: "8r94jhfq",
|
||||
},
|
||||
{
|
||||
speciesName: "Lupe",
|
||||
speciesId: "31",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "199",
|
||||
neopetsImageHash: "z42535zh",
|
||||
},
|
||||
{
|
||||
speciesName: "Lutari",
|
||||
speciesId: "32",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "52",
|
||||
neopetsImageHash: "qgg6z8s7",
|
||||
},
|
||||
{
|
||||
speciesName: "Meerca",
|
||||
speciesId: "33",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "109",
|
||||
neopetsImageHash: "kk2nn2jr",
|
||||
},
|
||||
{
|
||||
speciesName: "Moehog",
|
||||
speciesId: "34",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "134",
|
||||
neopetsImageHash: "jgkoro5z",
|
||||
},
|
||||
{
|
||||
speciesName: "Mynci",
|
||||
speciesId: "35",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "95",
|
||||
neopetsImageHash: "xwlo9657",
|
||||
},
|
||||
{
|
||||
speciesName: "Nimmo",
|
||||
speciesId: "36",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "96",
|
||||
neopetsImageHash: "bx7fho8x",
|
||||
},
|
||||
{
|
||||
speciesName: "Ogrin",
|
||||
speciesId: "37",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "154",
|
||||
neopetsImageHash: "rjzmx24v",
|
||||
},
|
||||
{
|
||||
speciesName: "Peophin",
|
||||
speciesId: "38",
|
||||
colorId: colors.RED,
|
||||
bodyId: "55",
|
||||
neopetsImageHash: "kokc52kh",
|
||||
},
|
||||
{
|
||||
speciesName: "Poogle",
|
||||
speciesId: "39",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "76",
|
||||
neopetsImageHash: "fw6lvf3c",
|
||||
},
|
||||
{
|
||||
speciesName: "Pteri",
|
||||
speciesId: "40",
|
||||
colorId: colors.RED,
|
||||
bodyId: "156",
|
||||
neopetsImageHash: "tjhwbro3",
|
||||
},
|
||||
{
|
||||
speciesName: "Quiggle",
|
||||
speciesId: "41",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "78",
|
||||
neopetsImageHash: "jdto7mj4",
|
||||
},
|
||||
{
|
||||
speciesName: "Ruki",
|
||||
speciesId: "42",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "191",
|
||||
neopetsImageHash: "qsgbm5f6",
|
||||
},
|
||||
{
|
||||
speciesName: "Scorchio",
|
||||
speciesId: "43",
|
||||
colorId: colors.RED,
|
||||
bodyId: "187",
|
||||
neopetsImageHash: "hkjoncsx",
|
||||
},
|
||||
{
|
||||
speciesName: "Shoyru",
|
||||
speciesId: "44",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "46",
|
||||
neopetsImageHash: "mmvn4tkg",
|
||||
},
|
||||
{
|
||||
speciesName: "Skeith",
|
||||
speciesId: "45",
|
||||
colorId: colors.RED,
|
||||
bodyId: "178",
|
||||
neopetsImageHash: "fc4cxk3t",
|
||||
},
|
||||
{
|
||||
speciesName: "Techo",
|
||||
speciesId: "46",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "100",
|
||||
neopetsImageHash: "84gvowmj",
|
||||
},
|
||||
{
|
||||
speciesName: "Tonu",
|
||||
speciesId: "47",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "130",
|
||||
neopetsImageHash: "jd433863",
|
||||
},
|
||||
{
|
||||
speciesName: "Tuskaninny",
|
||||
speciesId: "48",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "188",
|
||||
neopetsImageHash: "q39wn6vq",
|
||||
},
|
||||
{
|
||||
speciesName: "Uni",
|
||||
speciesId: "49",
|
||||
colorId: colors.GREEN,
|
||||
bodyId: "257",
|
||||
neopetsImageHash: "njzvoflw",
|
||||
},
|
||||
{
|
||||
speciesName: "Usul",
|
||||
speciesId: "50",
|
||||
colorId: colors.RED,
|
||||
bodyId: "206",
|
||||
neopetsImageHash: "rox4mgh5",
|
||||
},
|
||||
{
|
||||
speciesName: "Vandagyre",
|
||||
speciesId: "55",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "306",
|
||||
neopetsImageHash: "xkntzsww",
|
||||
},
|
||||
{
|
||||
speciesName: "Wocky",
|
||||
speciesId: "51",
|
||||
colorId: colors.YELLOW,
|
||||
bodyId: "101",
|
||||
neopetsImageHash: "dnr2kj4b",
|
||||
},
|
||||
{
|
||||
speciesName: "Xweetok",
|
||||
speciesId: "52",
|
||||
colorId: colors.RED,
|
||||
bodyId: "68",
|
||||
neopetsImageHash: "tdkqr2b6",
|
||||
},
|
||||
{
|
||||
speciesName: "Yurble",
|
||||
speciesId: "53",
|
||||
colorId: colors.RED,
|
||||
bodyId: "182",
|
||||
neopetsImageHash: "h95cs547",
|
||||
},
|
||||
{
|
||||
speciesName: "Zafara",
|
||||
speciesId: "54",
|
||||
colorId: colors.BLUE,
|
||||
bodyId: "180",
|
||||
neopetsImageHash: "x8c57g2l",
|
||||
},
|
||||
];
|
||||
|
||||
export default SpeciesFacesPicker;
|
691
app/javascript/wardrobe-2020/ItemPageOutfitPreview.js
Normal file
|
@ -0,0 +1,691 @@
|
|||
import React from "react";
|
||||
import { useQuery } from "@apollo/client";
|
||||
import gql from "graphql-tag";
|
||||
import {
|
||||
AspectRatio,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Grid,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
usePrefersReducedMotion,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, WarningIcon } from "@chakra-ui/icons";
|
||||
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||
|
||||
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
||||
import SpeciesColorPicker, {
|
||||
useAllValidPetPoses,
|
||||
getValidPoses,
|
||||
getClosestPose,
|
||||
} from "./components/SpeciesColorPicker";
|
||||
import SpeciesFacesPicker, {
|
||||
colorIsBasic,
|
||||
} from "./ItemPage/SpeciesFacesPicker";
|
||||
import {
|
||||
itemAppearanceFragment,
|
||||
petAppearanceFragment,
|
||||
} from "./components/useOutfitAppearance";
|
||||
import { useOutfitPreview } from "./components/OutfitPreview";
|
||||
import { logAndCapture, useLocalStorage } from "./util";
|
||||
import { useItemAppearances } from "./loaders/items";
|
||||
|
||||
function ItemPageOutfitPreview({ itemId }) {
|
||||
const idealPose = React.useMemo(
|
||||
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
|
||||
[],
|
||||
);
|
||||
const [petState, setPetState] = React.useState({
|
||||
// We'll fill these in once the canonical appearance data arrives.
|
||||
speciesId: null,
|
||||
colorId: null,
|
||||
pose: null,
|
||||
isValid: false,
|
||||
|
||||
// We use appearance ID, in addition to the above, to give the Apollo cache
|
||||
// a really clear hint that the canonical pet appearance we preloaded is
|
||||
// the exact right one to show! But switching species/color will null this
|
||||
// out again, and that's okay. (We'll do an unnecessary reload if you
|
||||
// switch back to it though... we could maybe do something clever there!)
|
||||
appearanceId: null,
|
||||
});
|
||||
const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
|
||||
"DTIItemPreviewPreferredSpeciesId",
|
||||
null,
|
||||
);
|
||||
const [preferredColorId, setPreferredColorId] = useLocalStorage(
|
||||
"DTIItemPreviewPreferredColorId",
|
||||
null,
|
||||
);
|
||||
|
||||
const setPetStateFromUserAction = React.useCallback(
|
||||
(newPetState) =>
|
||||
setPetState((prevPetState) => {
|
||||
// When the user _intentionally_ chooses a species or color, save it in
|
||||
// local storage for next time. (This won't update when e.g. their
|
||||
// preferred species or color isn't available for this item, so we update
|
||||
// to the canonical species or color automatically.)
|
||||
//
|
||||
// Re the "ifs", I have no reason to expect null to come in here, but,
|
||||
// since this is touching client-persisted data, I want it to be even more
|
||||
// reliable than usual!
|
||||
if (
|
||||
newPetState.speciesId &&
|
||||
newPetState.speciesId !== prevPetState.speciesId
|
||||
) {
|
||||
setPreferredSpeciesId(newPetState.speciesId);
|
||||
}
|
||||
if (
|
||||
newPetState.colorId &&
|
||||
newPetState.colorId !== prevPetState.colorId
|
||||
) {
|
||||
if (colorIsBasic(newPetState.colorId)) {
|
||||
// When the user chooses a basic color, don't index on it specifically,
|
||||
// and instead reset to use default colors.
|
||||
setPreferredColorId(null);
|
||||
} else {
|
||||
setPreferredColorId(newPetState.colorId);
|
||||
}
|
||||
}
|
||||
|
||||
return newPetState;
|
||||
}),
|
||||
[setPreferredColorId, setPreferredSpeciesId],
|
||||
);
|
||||
|
||||
// We don't need to reload this query when preferred species/color change, so
|
||||
// cache their initial values here to use as query arguments.
|
||||
const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId);
|
||||
const [initialPreferredColorId] = React.useState(preferredColorId);
|
||||
|
||||
const {
|
||||
data: itemAppearancesData,
|
||||
loading: loadingAppearances,
|
||||
error: errorAppearances,
|
||||
} = useItemAppearances(itemId);
|
||||
const itemName = itemAppearancesData?.name ?? "";
|
||||
const itemAppearances = itemAppearancesData?.appearances ?? [];
|
||||
const restrictedZones = itemAppearancesData?.restrictedZones ?? [];
|
||||
|
||||
// Start by loading the "canonical" pet and item appearance for the outfit
|
||||
// preview. We'll use this to initialize both the preview and the picker.
|
||||
//
|
||||
// If the user has a preferred species saved from using the ItemPage in the
|
||||
// past, we'll send that instead. This will return the appearance on that
|
||||
// species if possible, or the default canonical species if not.
|
||||
//
|
||||
// TODO: If this is a non-standard pet color, like Mutant, we'll do an extra
|
||||
// query after this loads, because our Apollo cache can't detect the
|
||||
// shared item appearance. (For standard colors though, our logic to
|
||||
// cover standard-color switches works for this preloading too.)
|
||||
const {
|
||||
loading: loadingGQL,
|
||||
error: errorGQL,
|
||||
data,
|
||||
} = useQuery(
|
||||
gql`
|
||||
query ItemPageOutfitPreview(
|
||||
$itemId: ID!
|
||||
$preferredSpeciesId: ID
|
||||
$preferredColorId: ID
|
||||
) {
|
||||
item(id: $itemId) {
|
||||
id
|
||||
canonicalAppearance(
|
||||
preferredSpeciesId: $preferredSpeciesId
|
||||
preferredColorId: $preferredColorId
|
||||
) {
|
||||
id
|
||||
...ItemAppearanceForOutfitPreview
|
||||
body {
|
||||
id
|
||||
canonicalAppearance(preferredColorId: $preferredColorId) {
|
||||
id
|
||||
species {
|
||||
id
|
||||
name
|
||||
}
|
||||
color {
|
||||
id
|
||||
}
|
||||
pose
|
||||
|
||||
...PetAppearanceForOutfitPreview
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
${itemAppearanceFragment}
|
||||
${petAppearanceFragment}
|
||||
`,
|
||||
{
|
||||
variables: {
|
||||
itemId,
|
||||
preferredSpeciesId: initialPreferredSpeciesId,
|
||||
preferredColorId: initialPreferredColorId,
|
||||
},
|
||||
onCompleted: (data) => {
|
||||
const canonicalBody = data?.item?.canonicalAppearance?.body;
|
||||
const canonicalPetAppearance = canonicalBody?.canonicalAppearance;
|
||||
|
||||
setPetState({
|
||||
speciesId: canonicalPetAppearance?.species?.id,
|
||||
colorId: canonicalPetAppearance?.color?.id,
|
||||
pose: canonicalPetAppearance?.pose,
|
||||
isValid: true,
|
||||
appearanceId: canonicalPetAppearance?.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const compatibleBodies = itemAppearances?.map(({ body }) => body) || [];
|
||||
|
||||
// If there's only one compatible body, and the canonical species's name
|
||||
// appears in the item name, then this is probably a species-specific item,
|
||||
// and we should adjust the UI to avoid implying that other species could
|
||||
// model it.
|
||||
const speciesName =
|
||||
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name ??
|
||||
"";
|
||||
const isProbablySpeciesSpecific =
|
||||
compatibleBodies.length === 1 &&
|
||||
compatibleBodies[0] !== "all" &&
|
||||
itemName.toLowerCase().includes(speciesName.toLowerCase());
|
||||
const couldProbablyModelMoreData = !isProbablySpeciesSpecific;
|
||||
|
||||
// TODO: Does this double-trigger the HTTP request with SpeciesColorPicker?
|
||||
const {
|
||||
loading: loadingValids,
|
||||
error: errorValids,
|
||||
valids,
|
||||
} = useAllValidPetPoses();
|
||||
|
||||
const [hasAnimations, setHasAnimations] = React.useState(false);
|
||||
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||
|
||||
// This is like <OutfitPreview />, but we can use the appearance data, too!
|
||||
const { appearance, preview } = useOutfitPreview({
|
||||
speciesId: petState.speciesId,
|
||||
colorId: petState.colorId,
|
||||
pose: petState.pose,
|
||||
appearanceId: petState.appearanceId,
|
||||
wornItemIds: [itemId],
|
||||
isLoading: loadingGQL || loadingValids,
|
||||
spinnerVariant: "corner",
|
||||
engine: "canvas",
|
||||
onChangeHasAnimations: setHasAnimations,
|
||||
});
|
||||
|
||||
// If there's an appearance loaded for this item, but it's empty, then the
|
||||
// item is incompatible. (There should only be one item appearance: this one!)
|
||||
const itemAppearance = appearance?.itemAppearances?.[0];
|
||||
const itemLayers = itemAppearance?.layers || [];
|
||||
const isCompatible = itemLayers.length > 0;
|
||||
const usesHTML5 = itemLayers.every(layerUsesHTML5);
|
||||
|
||||
const onChange = React.useCallback(
|
||||
({ speciesId, colorId }) => {
|
||||
const validPoses = getValidPoses(valids, speciesId, colorId);
|
||||
const pose = getClosestPose(validPoses, idealPose);
|
||||
setPetStateFromUserAction({
|
||||
speciesId,
|
||||
colorId,
|
||||
pose,
|
||||
isValid: true,
|
||||
appearanceId: null,
|
||||
});
|
||||
},
|
||||
[valids, idealPose, setPetStateFromUserAction],
|
||||
);
|
||||
|
||||
const borderColor = useColorModeValue("green.700", "green.400");
|
||||
const errorColor = useColorModeValue("red.600", "red.400");
|
||||
|
||||
const error = errorGQL || errorAppearances || errorValids;
|
||||
if (error) {
|
||||
return <Box color="red.400">{error.message}</Box>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid
|
||||
templateAreas={{
|
||||
base: `
|
||||
"preview"
|
||||
"speciesColorPicker"
|
||||
"speciesFacesPicker"
|
||||
"zones"
|
||||
`,
|
||||
md: `
|
||||
"preview speciesFacesPicker"
|
||||
"speciesColorPicker zones"
|
||||
`,
|
||||
}}
|
||||
// HACK: Really I wanted 400px to match the natural height of the
|
||||
// preview in md, but in Chromium that creates a scrollbar and
|
||||
// 401px doesn't, not sure exactly why?
|
||||
templateRows={{
|
||||
base: "auto auto 200px auto",
|
||||
md: "401px auto",
|
||||
}}
|
||||
templateColumns={{
|
||||
base: "minmax(min-content, 400px)",
|
||||
md: "minmax(min-content, 400px) fit-content(480px)",
|
||||
}}
|
||||
rowGap="4"
|
||||
columnGap="6"
|
||||
justifyContent="center"
|
||||
width="100%"
|
||||
>
|
||||
<AspectRatio
|
||||
gridArea="preview"
|
||||
maxWidth="400px"
|
||||
maxHeight="400px"
|
||||
ratio="1"
|
||||
border="1px"
|
||||
borderColor={borderColor}
|
||||
transition="border-color 0.2s"
|
||||
borderRadius="lg"
|
||||
boxShadow="lg"
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box>
|
||||
{petState.isValid && preview}
|
||||
<CustomizeMoreButton
|
||||
speciesId={petState.speciesId}
|
||||
colorId={petState.colorId}
|
||||
pose={petState.pose}
|
||||
itemId={itemId}
|
||||
isDisabled={!petState.isValid}
|
||||
/>
|
||||
{hasAnimations && (
|
||||
<PlayPauseButton
|
||||
isPaused={isPaused}
|
||||
onClick={() => setIsPaused(!isPaused)}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</AspectRatio>
|
||||
<Flex gridArea="speciesColorPicker" alignSelf="start" align="center">
|
||||
<Box
|
||||
// This box grows at the same rate as the box on the right, so the
|
||||
// middle box will be centered, if there's space!
|
||||
flex="1 0 0"
|
||||
/>
|
||||
<SpeciesColorPicker
|
||||
speciesId={petState.speciesId}
|
||||
colorId={petState.colorId}
|
||||
pose={petState.pose}
|
||||
idealPose={idealPose}
|
||||
onChange={(species, color, isValid, closestPose) => {
|
||||
setPetStateFromUserAction({
|
||||
speciesId: species.id,
|
||||
colorId: color.id,
|
||||
pose: closestPose,
|
||||
isValid,
|
||||
appearanceId: null,
|
||||
});
|
||||
}}
|
||||
speciesIsDisabled={isProbablySpeciesSpecific}
|
||||
size="sm"
|
||||
showPlaceholders
|
||||
/>
|
||||
<Box flex="1 0 0" lineHeight="1" paddingLeft="1">
|
||||
{
|
||||
// Wait for us to start _requesting_ the appearance, and _then_
|
||||
// for it to load, and _then_ check compatibility.
|
||||
!loadingGQL &&
|
||||
!loadingAppearances &&
|
||||
!appearance.loading &&
|
||||
petState.isValid &&
|
||||
!isCompatible && (
|
||||
<Tooltip
|
||||
label={
|
||||
couldProbablyModelMoreData
|
||||
? "Item needs models"
|
||||
: "Not compatible"
|
||||
}
|
||||
placement="top"
|
||||
>
|
||||
<WarningIcon
|
||||
color={errorColor}
|
||||
transition="color 0.2"
|
||||
marginLeft="2"
|
||||
borderRadius="full"
|
||||
tabIndex="0"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</Box>
|
||||
</Flex>
|
||||
<Box
|
||||
gridArea="speciesFacesPicker"
|
||||
paddingTop="2"
|
||||
overflow="auto"
|
||||
padding="8px"
|
||||
>
|
||||
<SpeciesFacesPicker
|
||||
selectedSpeciesId={petState.speciesId}
|
||||
selectedColorId={petState.colorId}
|
||||
compatibleBodies={compatibleBodies}
|
||||
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||
onChange={onChange}
|
||||
isLoading={loadingGQL || loadingAppearances || loadingValids}
|
||||
/>
|
||||
</Box>
|
||||
<Flex gridArea="zones" justifySelf="center" align="center">
|
||||
{itemAppearances.length > 0 && (
|
||||
<ItemZonesInfo
|
||||
itemAppearances={itemAppearances}
|
||||
restrictedZones={restrictedZones}
|
||||
/>
|
||||
)}
|
||||
<Box width="6" />
|
||||
<Flex
|
||||
// Avoid layout shift while loading
|
||||
minWidth="54px"
|
||||
>
|
||||
<HTML5Badge
|
||||
usesHTML5={usesHTML5}
|
||||
// If we're not compatible, act the same as if we're loading:
|
||||
// don't change the badge, but don't show one yet if we don't
|
||||
// have one yet.
|
||||
isLoading={appearance.loading || !isCompatible}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) {
|
||||
const url =
|
||||
`/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` +
|
||||
`objects[]=${itemId}`;
|
||||
|
||||
// The default background is good in light mode, but in dark mode it's a
|
||||
// very subtle transparent white... make it a semi-transparent black, for
|
||||
// better contrast against light-colored background items!
|
||||
const backgroundColor = useColorModeValue(undefined, "blackAlpha.700");
|
||||
const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900");
|
||||
|
||||
return (
|
||||
<LinkOrButton
|
||||
href={isDisabled ? null : url}
|
||||
role="group"
|
||||
position="absolute"
|
||||
top="2"
|
||||
right="2"
|
||||
size="sm"
|
||||
background={backgroundColor}
|
||||
_hover={{ backgroundColor: backgroundColorHover }}
|
||||
_focus={{ backgroundColor: backgroundColorHover, boxShadow: "outline" }}
|
||||
boxShadow="sm"
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<ExpandOnGroupHover paddingRight="2">Customize more</ExpandOnGroupHover>
|
||||
<EditIcon />
|
||||
</LinkOrButton>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, ...props }) {
|
||||
if (href != null) {
|
||||
return <Button as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <Button {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ExpandOnGroupHover starts at width=0, and expands to full width when a
|
||||
* parent with role="group" gains hover or focus state.
|
||||
*/
|
||||
function ExpandOnGroupHover({ children, ...props }) {
|
||||
const [measuredWidth, setMeasuredWidth] = React.useState(null);
|
||||
const measurerRef = React.useRef(null);
|
||||
const prefersReducedMotion = usePrefersReducedMotion();
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
if (!measurerRef) {
|
||||
// I don't think this is possible, but I'd like to know if it happens!
|
||||
logAndCapture(
|
||||
new Error(
|
||||
`Measurer node not ready during effect. Transition won't be smooth.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (measuredWidth != null) {
|
||||
// Skip re-measuring when we already have a measured width. This is
|
||||
// mainly defensive, to prevent the possibility of loops, even though
|
||||
// this algorithm should be stable!
|
||||
return;
|
||||
}
|
||||
|
||||
const newMeasuredWidth = measurerRef.current.offsetWidth;
|
||||
setMeasuredWidth(newMeasuredWidth);
|
||||
}, [measuredWidth]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
// In block layout, the overflowing children would _also_ be constrained
|
||||
// to width 0. But in flex layout, overflowing children _keep_ their
|
||||
// natural size, so we can measure it even when not visible.
|
||||
width="0"
|
||||
overflow="hidden"
|
||||
// Right-align the children, to keep the text feeling right-aligned when
|
||||
// we expand. (To support left-side expansion, make this a prop!)
|
||||
justify="flex-end"
|
||||
// If the width somehow isn't measured yet, expand to width `auto`, which
|
||||
// won't transition smoothly but at least will work!
|
||||
_groupHover={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
||||
_groupFocus={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
||||
transition={!prefersReducedMotion && "width 0.2s"}
|
||||
>
|
||||
<Box ref={measurerRef} {...props}>
|
||||
{children}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function PlayPauseButton({ isPaused, onClick }) {
|
||||
return (
|
||||
<IconButton
|
||||
icon={isPaused ? <MdPlayArrow /> : <MdPause />}
|
||||
aria-label={isPaused ? "Play" : "Pause"}
|
||||
onClick={onClick}
|
||||
borderRadius="full"
|
||||
boxShadow="md"
|
||||
color="gray.50"
|
||||
backgroundColor="blackAlpha.700"
|
||||
position="absolute"
|
||||
bottom="2"
|
||||
left="2"
|
||||
_hover={{ backgroundColor: "blackAlpha.900" }}
|
||||
_focus={{ backgroundColor: "blackAlpha.900" }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemZonesInfo({ itemAppearances, restrictedZones }) {
|
||||
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
||||
// merging zones with the same label, because that's how user-facing zone UI
|
||||
// generally works!
|
||||
const zoneLabelsAndTheirBodiesMap = {};
|
||||
for (const { body, swfAssets } of itemAppearances) {
|
||||
for (const { zone } of swfAssets) {
|
||||
if (!zoneLabelsAndTheirBodiesMap[zone.label]) {
|
||||
zoneLabelsAndTheirBodiesMap[zone.label] = {
|
||||
zoneLabel: zone.label,
|
||||
bodies: [],
|
||||
};
|
||||
}
|
||||
zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body);
|
||||
}
|
||||
}
|
||||
const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap);
|
||||
|
||||
const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
|
||||
buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
|
||||
buildSortKeyForZoneLabelsAndTheirBodies(b),
|
||||
),
|
||||
);
|
||||
|
||||
const restrictedZoneLabels = [
|
||||
...new Set(restrictedZones.map((z) => z.label)),
|
||||
].sort();
|
||||
|
||||
// We only show body info if there's more than one group of bodies to talk
|
||||
// about. If they all have the same zones, it's clear from context that any
|
||||
// preview available in the list has the zones listed here.
|
||||
const bodyGroups = new Set(
|
||||
zoneLabelsAndTheirBodies.map(({ bodies }) =>
|
||||
bodies.map((b) => b.id).join(","),
|
||||
),
|
||||
);
|
||||
const showBodyInfo = bodyGroups.size > 1;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
fontSize="sm"
|
||||
textAlign="center"
|
||||
// If the text gets too long, wrap Restricts onto another line, and center
|
||||
// them relative to each other.
|
||||
wrap="wrap"
|
||||
justify="center"
|
||||
data-test-id="item-zones-info"
|
||||
>
|
||||
<Box flex="0 0 auto" maxWidth="100%">
|
||||
<Box as="header" fontWeight="bold" display="inline">
|
||||
Occupies:
|
||||
</Box>{" "}
|
||||
<Box as="ul" listStyleType="none" display="inline">
|
||||
{sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => (
|
||||
<Box
|
||||
key={zoneLabel}
|
||||
as="li"
|
||||
display="inline"
|
||||
_notLast={{ _after: { content: '", "' } }}
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
// Don't wrap any of the list item content. But, by putting
|
||||
// this in an extra container element, we _do_ allow wrapping
|
||||
// _between_ list items.
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
<ItemZonesInfoListItem
|
||||
zoneLabel={zoneLabel}
|
||||
bodies={bodies}
|
||||
showBodyInfo={showBodyInfo}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="0 0 auto" />
|
||||
<Box flex="0 0 auto" maxWidth="100%">
|
||||
<Box as="header" fontWeight="bold" display="inline">
|
||||
Restricts:
|
||||
</Box>{" "}
|
||||
{restrictedZoneLabels.length > 0 ? (
|
||||
<Box as="ul" listStyleType="none" display="inline">
|
||||
{restrictedZoneLabels.map((zoneLabel) => (
|
||||
<Box
|
||||
key={zoneLabel}
|
||||
as="li"
|
||||
display="inline"
|
||||
_notLast={{ _after: { content: '", "' } }}
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
// Don't wrap any of the list item content. But, by putting
|
||||
// this in an extra container element, we _do_ allow wrapping
|
||||
// _between_ list items.
|
||||
whiteSpace="nowrap"
|
||||
>
|
||||
{zoneLabel}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
<Box as="span" fontStyle="italic" opacity="0.8">
|
||||
N/A
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) {
|
||||
let content = zoneLabel;
|
||||
|
||||
if (showBodyInfo) {
|
||||
if (bodies.some((b) => b.representsAllBodies)) {
|
||||
content = <>{content} (all species)</>;
|
||||
} else {
|
||||
// TODO: This is a bit reductive, if it's different for like special
|
||||
// colors, e.g. Blue Acara vs Mutant Acara, this will just show
|
||||
// "Acara" in either case! (We are at least gonna be defensive here
|
||||
// and remove duplicates, though, in case both the Blue Acara and
|
||||
// Mutant Acara body end up in the same list.)
|
||||
const speciesNames = new Set(bodies.map((b) => b.species.humanName));
|
||||
const speciesListString = [...speciesNames].sort().join(", ");
|
||||
|
||||
content = (
|
||||
<>
|
||||
{content}{" "}
|
||||
<Tooltip
|
||||
label={speciesListString}
|
||||
textAlign="center"
|
||||
placement="bottom"
|
||||
>
|
||||
<Box
|
||||
as="span"
|
||||
tabIndex="0"
|
||||
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||
fontStyle="italic"
|
||||
textDecoration="underline"
|
||||
style={{ textDecorationStyle: "dotted" }}
|
||||
opacity="0.8"
|
||||
>
|
||||
{/* Show the speciesNames count, even though it's less info,
|
||||
* because it's more important that the tooltip content matches
|
||||
* the count we show! */}
|
||||
({speciesNames.size} species)
|
||||
</Box>
|
||||
</Tooltip>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
|
||||
// Sort by "represents all bodies", then by body count descending, then
|
||||
// alphabetically.
|
||||
const representsAllBodies = bodies.some((body) => body.representsAllBodies);
|
||||
|
||||
// To sort by body count _descending_, we subtract it from a large number.
|
||||
// Then, to make it work in string comparison, we pad it with leading zeroes.
|
||||
// Hacky but solid!
|
||||
const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0");
|
||||
|
||||
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
||||
}
|
||||
|
||||
export default ItemPageOutfitPreview;
|
|
@ -1,31 +1,31 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
Box,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
useColorModeValue,
|
||||
useTheme,
|
||||
} from "@chakra-ui/react";
|
||||
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
|
||||
import { loadable } from "../util";
|
||||
|
||||
import {
|
||||
ItemCardContent,
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
MaybeAnimatedBadge,
|
||||
YouOwnThisBadge,
|
||||
YouWantThisBadge,
|
||||
getZoneBadges,
|
||||
ItemCardContent,
|
||||
ItemBadgeList,
|
||||
ItemKindBadge,
|
||||
MaybeAnimatedBadge,
|
||||
YouOwnThisBadge,
|
||||
YouWantThisBadge,
|
||||
getZoneBadges,
|
||||
} from "../components/ItemCard";
|
||||
import SupportOnly from "./support/SupportOnly";
|
||||
import useSupport from "./support/useSupport";
|
||||
|
||||
const LoadableItemSupportDrawer = loadable(
|
||||
() => import("./support/ItemSupportDrawer"),
|
||||
const LoadableItemSupportDrawer = loadable(() =>
|
||||
import("./support/ItemSupportDrawer"),
|
||||
);
|
||||
|
||||
/**
|
||||
|
@ -48,79 +48,79 @@ const LoadableItemSupportDrawer = loadable(
|
|||
* devices.
|
||||
*/
|
||||
function Item({
|
||||
item,
|
||||
itemNameId,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
onRemove,
|
||||
isDisabled = false,
|
||||
item,
|
||||
itemNameId,
|
||||
isWorn,
|
||||
isInOutfit,
|
||||
onRemove,
|
||||
isDisabled = false,
|
||||
}) {
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ItemContainer isDisabled={isDisabled}>
|
||||
<Box flex="1 1 0" minWidth="0">
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={<ItemBadges item={item} />}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDiabled={isDisabled}
|
||||
focusSelector={containerHasFocus}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="0 0 auto" marginTop="5px">
|
||||
{isInOutfit && (
|
||||
<ItemActionButton
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
onClick={(e) => {
|
||||
onRemove(item.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<EditIcon />}
|
||||
label="Support"
|
||||
onClick={(e) => {
|
||||
setSupportDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
target="_blank"
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
isOpen={supportDrawerIsOpen}
|
||||
onClose={() => setSupportDrawerIsOpen(false)}
|
||||
/>
|
||||
</SupportOnly>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ItemContainer isDisabled={isDisabled}>
|
||||
<Box flex="1 1 0" minWidth="0">
|
||||
<ItemCardContent
|
||||
item={item}
|
||||
badges={<ItemBadges item={item} />}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={isWorn}
|
||||
isDiabled={isDisabled}
|
||||
focusSelector={containerHasFocus}
|
||||
/>
|
||||
</Box>
|
||||
<Box flex="0 0 auto" marginTop="5px">
|
||||
{isInOutfit && (
|
||||
<ItemActionButton
|
||||
icon={<DeleteIcon />}
|
||||
label="Remove"
|
||||
onClick={(e) => {
|
||||
onRemove(item.id);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<EditIcon />}
|
||||
label="Support"
|
||||
onClick={(e) => {
|
||||
setSupportDrawerIsOpen(true);
|
||||
e.preventDefault();
|
||||
}}
|
||||
/>
|
||||
</SupportOnly>
|
||||
<ItemActionButton
|
||||
icon={<InfoIcon />}
|
||||
label="More info"
|
||||
to={`/items/${item.id}`}
|
||||
target="_blank"
|
||||
/>
|
||||
</Box>
|
||||
</ItemContainer>
|
||||
<SupportOnly>
|
||||
<LoadableItemSupportDrawer
|
||||
item={item}
|
||||
isOpen={supportDrawerIsOpen}
|
||||
onClose={() => setSupportDrawerIsOpen(false)}
|
||||
/>
|
||||
</SupportOnly>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemSkeleton is a placeholder for when an Item is loading.
|
||||
*/
|
||||
function ItemSkeleton() {
|
||||
return (
|
||||
<ItemContainer isDisabled>
|
||||
<Skeleton width="50px" height="50px" />
|
||||
<Box width="3" />
|
||||
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
|
||||
</ItemContainer>
|
||||
);
|
||||
return (
|
||||
<ItemContainer isDisabled>
|
||||
<Skeleton width="50px" height="50px" />
|
||||
<Box width="3" />
|
||||
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
|
||||
</ItemContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -131,152 +131,152 @@ function ItemSkeleton() {
|
|||
* .item-container parent!
|
||||
*/
|
||||
function ItemContainer({ children, isDisabled = false }) {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["100"],
|
||||
theme.colors.gray["700"],
|
||||
);
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["100"],
|
||||
theme.colors.gray["700"],
|
||||
);
|
||||
|
||||
const activeBorderColor = useColorModeValue(
|
||||
theme.colors.green["400"],
|
||||
theme.colors.green["500"],
|
||||
);
|
||||
const activeBorderColor = useColorModeValue(
|
||||
theme.colors.green["400"],
|
||||
theme.colors.green["500"],
|
||||
);
|
||||
|
||||
const focusCheckedBorderColor = useColorModeValue(
|
||||
theme.colors.green["800"],
|
||||
theme.colors.green["300"],
|
||||
);
|
||||
const focusCheckedBorderColor = useColorModeValue(
|
||||
theme.colors.green["800"],
|
||||
theme.colors.green["300"],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
p="1"
|
||||
my="1"
|
||||
borderRadius="lg"
|
||||
d="flex"
|
||||
cursor={isDisabled ? undefined : "pointer"}
|
||||
border="1px"
|
||||
borderColor="transparent"
|
||||
className={cx([
|
||||
"item-container",
|
||||
!isDisabled &&
|
||||
css`
|
||||
&:hover,
|
||||
input:focus + & {
|
||||
background-color: ${focusBackgroundColor};
|
||||
}
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css, cx }) => (
|
||||
<Box
|
||||
p="1"
|
||||
my="1"
|
||||
borderRadius="lg"
|
||||
d="flex"
|
||||
cursor={isDisabled ? undefined : "pointer"}
|
||||
border="1px"
|
||||
borderColor="transparent"
|
||||
className={cx([
|
||||
"item-container",
|
||||
!isDisabled &&
|
||||
css`
|
||||
&:hover,
|
||||
input:focus + & {
|
||||
background-color: ${focusBackgroundColor};
|
||||
}
|
||||
|
||||
input:active + & {
|
||||
border-color: ${activeBorderColor};
|
||||
}
|
||||
input:active + & {
|
||||
border-color: ${activeBorderColor};
|
||||
}
|
||||
|
||||
input:checked:focus + & {
|
||||
border-color: ${focusCheckedBorderColor};
|
||||
}
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
input:checked:focus + & {
|
||||
border-color: ${focusCheckedBorderColor};
|
||||
}
|
||||
`,
|
||||
])}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function ItemBadges({ item }) {
|
||||
const { isSupportUser } = useSupport();
|
||||
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
|
||||
const restrictedZones = item.appearanceOn.restrictedZones.filter(
|
||||
(z) => z.isCommonlyUsedByItems,
|
||||
);
|
||||
const isMaybeAnimated = item.appearanceOn.layers.some(
|
||||
(l) => l.canvasMovieLibraryUrl,
|
||||
);
|
||||
const { isSupportUser } = useSupport();
|
||||
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
|
||||
const restrictedZones = item.appearanceOn.restrictedZones.filter(
|
||||
(z) => z.isCommonlyUsedByItems,
|
||||
);
|
||||
const isMaybeAnimated = item.appearanceOn.layers.some(
|
||||
(l) => l.canvasMovieLibraryUrl,
|
||||
);
|
||||
|
||||
return (
|
||||
<ItemBadgeList>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
|
||||
{
|
||||
// This badge is unreliable, but it's helpful for looking for animated
|
||||
// items to test, so we show it only to support. We use this form
|
||||
// instead of <SupportOnly />, to avoid adding extra badge list spacing
|
||||
// on the additional empty child.
|
||||
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
|
||||
}
|
||||
{getZoneBadges(occupiedZones, { variant: "occupies" })}
|
||||
{getZoneBadges(restrictedZones, { variant: "restricts" })}
|
||||
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
|
||||
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
|
||||
</ItemBadgeList>
|
||||
);
|
||||
return (
|
||||
<ItemBadgeList>
|
||||
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
|
||||
{
|
||||
// This badge is unreliable, but it's helpful for looking for animated
|
||||
// items to test, so we show it only to support. We use this form
|
||||
// instead of <SupportOnly />, to avoid adding extra badge list spacing
|
||||
// on the additional empty child.
|
||||
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
|
||||
}
|
||||
{getZoneBadges(occupiedZones, { variant: "occupies" })}
|
||||
{getZoneBadges(restrictedZones, { variant: "restricts" })}
|
||||
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
|
||||
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
|
||||
</ItemBadgeList>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemActionButton is one of a list of actions a user can take for this item.
|
||||
*/
|
||||
function ItemActionButton({ icon, label, to, onClick, ...props }) {
|
||||
const theme = useTheme();
|
||||
const theme = useTheme();
|
||||
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["300"],
|
||||
theme.colors.gray["800"],
|
||||
);
|
||||
const focusColor = useColorModeValue(
|
||||
theme.colors.gray["700"],
|
||||
theme.colors.gray["200"],
|
||||
);
|
||||
const focusBackgroundColor = useColorModeValue(
|
||||
theme.colors.gray["300"],
|
||||
theme.colors.gray["800"],
|
||||
);
|
||||
const focusColor = useColorModeValue(
|
||||
theme.colors.gray["700"],
|
||||
theme.colors.gray["200"],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
{...props}
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Tooltip label={label} placement="top">
|
||||
<LinkOrButton
|
||||
{...props}
|
||||
component={IconButton}
|
||||
href={to}
|
||||
icon={icon}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
color="gray.400"
|
||||
onClick={onClick}
|
||||
className={css`
|
||||
opacity: 0;
|
||||
transition: all 0.2s;
|
||||
|
||||
${containerHasFocus} {
|
||||
opacity: 1;
|
||||
}
|
||||
${containerHasFocus} {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: ${focusBackgroundColor};
|
||||
color: ${focusColor};
|
||||
}
|
||||
&:focus,
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
background-color: ${focusBackgroundColor};
|
||||
color: ${focusColor};
|
||||
}
|
||||
|
||||
/* On touch devices, always show the buttons! This avoids having to
|
||||
/* On touch devices, always show the buttons! This avoids having to
|
||||
* tap to reveal them (which toggles the item), or worse,
|
||||
* accidentally tapping a hidden button without realizing! */
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
@media (hover: none) {
|
||||
opacity: 1;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkOrButton({ href, component, ...props }) {
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return <ButtonComponent as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
const ButtonComponent = component;
|
||||
if (href != null) {
|
||||
return <ButtonComponent as="a" href={href} {...props} />;
|
||||
} else {
|
||||
return <ButtonComponent {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
|
|||
* components in this to ensure a consistent list layout.
|
||||
*/
|
||||
export function ItemListContainer({ children, ...props }) {
|
||||
return (
|
||||
<Flex direction="column" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
return (
|
||||
<Flex direction="column" {...props}>
|
||||
{children}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
|
|||
* Items are loading.
|
||||
*/
|
||||
export function ItemListSkeleton({ count, ...props }) {
|
||||
return (
|
||||
<ItemListContainer {...props}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</ItemListContainer>
|
||||
);
|
||||
return (
|
||||
<ItemListContainer {...props}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<ItemSkeleton key={i} />
|
||||
))}
|
||||
</ItemListContainer>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -311,6 +311,6 @@ export function ItemListSkeleton({ count, ...props }) {
|
|||
* focused.
|
||||
*/
|
||||
const containerHasFocus =
|
||||
".item-container:hover &, input:focus + .item-container &";
|
||||
".item-container:hover &, input:focus + .item-container &";
|
||||
|
||||
export default React.memo(Item);
|
||||
|
|
|
@ -21,72 +21,72 @@ import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
|
|||
* state and refs.
|
||||
*/
|
||||
function ItemsAndSearchPanels({
|
||||
loading,
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
outfitState,
|
||||
outfitSaving,
|
||||
dispatchToOutfit,
|
||||
loading,
|
||||
searchQuery,
|
||||
onChangeSearchQuery,
|
||||
outfitState,
|
||||
outfitSaving,
|
||||
dispatchToOutfit,
|
||||
}) {
|
||||
const scrollContainerRef = React.useRef();
|
||||
const searchQueryRef = React.useRef();
|
||||
const firstSearchResultRef = React.useRef();
|
||||
const scrollContainerRef = React.useRef();
|
||||
const searchQueryRef = React.useRef();
|
||||
const firstSearchResultRef = React.useRef();
|
||||
|
||||
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
|
||||
const [canUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
|
||||
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
|
||||
const [canUseSearchFooter] = useLocalStorage(
|
||||
"DTIFeatureFlagCanUseSearchFooter",
|
||||
false,
|
||||
);
|
||||
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Flex direction="column" height="100%">
|
||||
{isShowingSearchFooter && <Box height="2" />}
|
||||
{!isShowingSearchFooter && (
|
||||
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
onChange={onChangeSearchQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
|
||||
<Box
|
||||
key="search-panel"
|
||||
flex="1 0 0"
|
||||
position="relative"
|
||||
overflowY="scroll"
|
||||
ref={scrollContainerRef}
|
||||
data-test-id="search-panel-scroll-container"
|
||||
>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="relative" overflow="auto" key="items-panel">
|
||||
<Box px="4" py="2">
|
||||
<ItemsPanel
|
||||
loading={loading}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||
<TestErrorSender />
|
||||
<Flex direction="column" height="100%">
|
||||
{isShowingSearchFooter && <Box height="2" />}
|
||||
{!isShowingSearchFooter && (
|
||||
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||
<SearchToolbar
|
||||
query={searchQuery}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
onChange={onChangeSearchQuery}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
|
||||
<Box
|
||||
key="search-panel"
|
||||
flex="1 0 0"
|
||||
position="relative"
|
||||
overflowY="scroll"
|
||||
ref={scrollContainerRef}
|
||||
data-test-id="search-panel-scroll-container"
|
||||
>
|
||||
<SearchPanel
|
||||
query={searchQuery}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
scrollContainerRef={scrollContainerRef}
|
||||
searchQueryRef={searchQueryRef}
|
||||
firstSearchResultRef={firstSearchResultRef}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
<Box position="relative" overflow="auto" key="items-panel">
|
||||
<Box px="4" py="2">
|
||||
<ItemsPanel
|
||||
loading={loading}
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
</Sentry.ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
export default ItemsAndSearchPanels;
|
||||
|
|
|
@ -1,38 +1,38 @@
|
|||
import React from "react";
|
||||
import { ClassNames } from "@emotion/react";
|
||||
import {
|
||||
Box,
|
||||
Editable,
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
Box,
|
||||
Editable,
|
||||
EditablePreview,
|
||||
EditableInput,
|
||||
Flex,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Tooltip,
|
||||
VisuallyHidden,
|
||||
Menu,
|
||||
MenuButton,
|
||||
MenuList,
|
||||
MenuItem,
|
||||
Portal,
|
||||
Button,
|
||||
Spinner,
|
||||
useColorModeValue,
|
||||
Modal,
|
||||
ModalContent,
|
||||
ModalOverlay,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
useDisclosure,
|
||||
ModalCloseButton,
|
||||
} from "@chakra-ui/react";
|
||||
import {
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
QuestionIcon,
|
||||
WarningTwoIcon,
|
||||
CheckIcon,
|
||||
DeleteIcon,
|
||||
EditIcon,
|
||||
QuestionIcon,
|
||||
WarningTwoIcon,
|
||||
} from "@chakra-ui/icons";
|
||||
import { IoBagCheck } from "react-icons/io5";
|
||||
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
||||
|
@ -59,70 +59,70 @@ import { useDeleteOutfitMutation } from "../loaders/outfits";
|
|||
* full width of the container, it doesn't look like it!
|
||||
*/
|
||||
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
||||
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
|
||||
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box>
|
||||
<Box px="1">
|
||||
<OutfitHeading
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
<Flex direction="column">
|
||||
{loading ? (
|
||||
<ItemZoneGroupsSkeleton
|
||||
itemCount={outfitState.allItemIds.length}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TransitionGroup component={null}>
|
||||
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
|
||||
<CSSTransition
|
||||
key={zoneId}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
<ItemZoneGroup
|
||||
zoneLabel={zoneLabel}
|
||||
items={items}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
{incompatibleItems.length > 0 && (
|
||||
<ItemZoneGroup
|
||||
zoneLabel="Incompatible"
|
||||
afterHeader={
|
||||
<Tooltip
|
||||
label={
|
||||
altStyleId != null
|
||||
? "Many items don't fit Alt Style pets"
|
||||
: "These items don't fit this pet"
|
||||
}
|
||||
placement="top"
|
||||
openDelay={100}
|
||||
>
|
||||
<QuestionIcon fontSize="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
items={incompatibleItems}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box>
|
||||
<Box px="1">
|
||||
<OutfitHeading
|
||||
outfitState={outfitState}
|
||||
outfitSaving={outfitSaving}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</Box>
|
||||
<Flex direction="column">
|
||||
{loading ? (
|
||||
<ItemZoneGroupsSkeleton
|
||||
itemCount={outfitState.allItemIds.length}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<TransitionGroup component={null}>
|
||||
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
|
||||
<CSSTransition
|
||||
key={zoneId}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
<ItemZoneGroup
|
||||
zoneLabel={zoneLabel}
|
||||
items={items}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
/>
|
||||
</CSSTransition>
|
||||
))}
|
||||
</TransitionGroup>
|
||||
{incompatibleItems.length > 0 && (
|
||||
<ItemZoneGroup
|
||||
zoneLabel="Incompatible"
|
||||
afterHeader={
|
||||
<Tooltip
|
||||
label={
|
||||
altStyleId != null
|
||||
? "Many items don't fit Alt Style pets"
|
||||
: "These items don't fit this pet"
|
||||
}
|
||||
placement="top"
|
||||
openDelay={100}
|
||||
>
|
||||
<QuestionIcon fontSize="sm" />
|
||||
</Tooltip>
|
||||
}
|
||||
items={incompatibleItems}
|
||||
outfitState={outfitState}
|
||||
dispatchToOutfit={dispatchToOutfit}
|
||||
isDisabled
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -134,102 +134,102 @@ function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
|
|||
* makes the list screen-reader- and keyboard-accessible!
|
||||
*/
|
||||
function ItemZoneGroup({
|
||||
zoneLabel,
|
||||
items,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isDisabled = false,
|
||||
afterHeader = null,
|
||||
zoneLabel,
|
||||
items,
|
||||
outfitState,
|
||||
dispatchToOutfit,
|
||||
isDisabled = false,
|
||||
afterHeader = null,
|
||||
}) {
|
||||
// onChange is fired when the radio button becomes checked, not unchecked!
|
||||
const onChange = (e) => {
|
||||
const itemId = e.target.value;
|
||||
dispatchToOutfit({ type: "wearItem", itemId });
|
||||
};
|
||||
// onChange is fired when the radio button becomes checked, not unchecked!
|
||||
const onChange = (e) => {
|
||||
const itemId = e.target.value;
|
||||
dispatchToOutfit({ type: "wearItem", itemId });
|
||||
};
|
||||
|
||||
// Clicking the radio button when already selected deselects it - this is how
|
||||
// you can select none!
|
||||
const onClick = (e) => {
|
||||
const itemId = e.target.value;
|
||||
if (outfitState.wornItemIds.includes(itemId)) {
|
||||
// We need the event handler to finish before this, so that simulated
|
||||
// events don't just come back around and undo it - but we can't just
|
||||
// solve that with `preventDefault`, because it breaks the radio's
|
||||
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
||||
// after all event handlers resolve!
|
||||
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
||||
}
|
||||
};
|
||||
// Clicking the radio button when already selected deselects it - this is how
|
||||
// you can select none!
|
||||
const onClick = (e) => {
|
||||
const itemId = e.target.value;
|
||||
if (outfitState.wornItemIds.includes(itemId)) {
|
||||
// We need the event handler to finish before this, so that simulated
|
||||
// events don't just come back around and undo it - but we can't just
|
||||
// solve that with `preventDefault`, because it breaks the radio's
|
||||
// intended visual updates when we unwear. So, we `setTimeout` to do it
|
||||
// after all event handlers resolve!
|
||||
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
|
||||
}
|
||||
};
|
||||
|
||||
const onRemove = React.useCallback(
|
||||
(itemId) => {
|
||||
dispatchToOutfit({ type: "removeItem", itemId });
|
||||
},
|
||||
[dispatchToOutfit],
|
||||
);
|
||||
const onRemove = React.useCallback(
|
||||
(itemId) => {
|
||||
dispatchToOutfit({ type: "removeItem", itemId });
|
||||
},
|
||||
[dispatchToOutfit],
|
||||
);
|
||||
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box mb="10">
|
||||
<Heading2 display="flex" alignItems="center" mx="1">
|
||||
{zoneLabel}
|
||||
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
||||
</Heading2>
|
||||
<ItemListContainer>
|
||||
<TransitionGroup component={null}>
|
||||
{items.map((item) => {
|
||||
const itemNameId =
|
||||
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
||||
const itemNode = (
|
||||
<Item
|
||||
item={item}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={
|
||||
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
||||
}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
onRemove={onRemove}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ClassNames>
|
||||
{({ css }) => (
|
||||
<Box mb="10">
|
||||
<Heading2 display="flex" alignItems="center" mx="1">
|
||||
{zoneLabel}
|
||||
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
||||
</Heading2>
|
||||
<ItemListContainer>
|
||||
<TransitionGroup component={null}>
|
||||
{items.map((item) => {
|
||||
const itemNameId =
|
||||
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
|
||||
const itemNode = (
|
||||
<Item
|
||||
item={item}
|
||||
itemNameId={itemNameId}
|
||||
isWorn={
|
||||
!isDisabled && outfitState.wornItemIds.includes(item.id)
|
||||
}
|
||||
isInOutfit={outfitState.allItemIds.includes(item.id)}
|
||||
onRemove={onRemove}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CSSTransition
|
||||
key={item.id}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
itemNode
|
||||
) : (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-labelledby={itemNameId}
|
||||
name={zoneLabel}
|
||||
value={item.id}
|
||||
checked={outfitState.wornItemIds.includes(item.id)}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{itemNode}
|
||||
</label>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</ItemListContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
return (
|
||||
<CSSTransition
|
||||
key={item.id}
|
||||
{...fadeOutAndRollUpTransition(css)}
|
||||
>
|
||||
{isDisabled ? (
|
||||
itemNode
|
||||
) : (
|
||||
<label>
|
||||
<VisuallyHidden
|
||||
as="input"
|
||||
type="radio"
|
||||
aria-labelledby={itemNameId}
|
||||
name={zoneLabel}
|
||||
value={item.id}
|
||||
checked={outfitState.wornItemIds.includes(item.id)}
|
||||
onChange={onChange}
|
||||
onClick={onClick}
|
||||
onKeyUp={(e) => {
|
||||
if (e.key === " ") {
|
||||
onClick(e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{itemNode}
|
||||
</label>
|
||||
)}
|
||||
</CSSTransition>
|
||||
);
|
||||
})}
|
||||
</TransitionGroup>
|
||||
</ItemListContainer>
|
||||
</Box>
|
||||
)}
|
||||
</ClassNames>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -240,35 +240,35 @@ function ItemZoneGroup({
|
|||
* we don't show skeleton items that just clear away!
|
||||
*/
|
||||
function ItemZoneGroupsSkeleton({ itemCount }) {
|
||||
const groups = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
||||
// outfit shares I think, but looks bad for pet loading... once shares
|
||||
// become a more common use case, it might be useful to figure out how
|
||||
// to differentiate these cases and show 1-per-group for pets, but
|
||||
// maybe more for built outfits?
|
||||
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
||||
}
|
||||
return groups;
|
||||
const groups = [];
|
||||
for (let i = 0; i < itemCount; i++) {
|
||||
// NOTE: I initially wrote this to return groups of 3, which looks good for
|
||||
// outfit shares I think, but looks bad for pet loading... once shares
|
||||
// become a more common use case, it might be useful to figure out how
|
||||
// to differentiate these cases and show 1-per-group for pets, but
|
||||
// maybe more for built outfits?
|
||||
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
|
||||
*/
|
||||
function ItemZoneGroupSkeleton({ itemCount }) {
|
||||
return (
|
||||
<Box mb="10">
|
||||
<Delay>
|
||||
<Skeleton
|
||||
mx="1"
|
||||
// 2.25rem font size, 1.25rem line height
|
||||
height={`${2.25 * 1.25}rem`}
|
||||
width="12rem"
|
||||
/>
|
||||
<ItemListSkeleton count={itemCount} />
|
||||
</Delay>
|
||||
</Box>
|
||||
);
|
||||
return (
|
||||
<Box mb="10">
|
||||
<Delay>
|
||||
<Skeleton
|
||||
mx="1"
|
||||
// 2.25rem font size, 1.25rem line height
|
||||
height={`${2.25 * 1.25}rem`}
|
||||
width="12rem"
|
||||
/>
|
||||
<ItemListSkeleton count={itemCount} />
|
||||
</Delay>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -277,36 +277,36 @@ function ItemZoneGroupSkeleton({ itemCount }) {
|
|||
* this is disabled.
|
||||
*/
|
||||
function ShoppingListButton({ outfitState }) {
|
||||
const itemIds = [...outfitState.wornItemIds].sort();
|
||||
const isDisabled = itemIds.length === 0;
|
||||
const itemIds = [...outfitState.wornItemIds].sort();
|
||||
const isDisabled = itemIds.length === 0;
|
||||
|
||||
let targetUrl = `/items/sources/${itemIds.join(",")}`;
|
||||
if (outfitState.name != null && outfitState.name.trim().length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("for", outfitState.name);
|
||||
targetUrl += "?" + params.toString();
|
||||
}
|
||||
let targetUrl = `/items/sources/${itemIds.join(",")}`;
|
||||
if (outfitState.name != null && outfitState.name.trim().length > 0) {
|
||||
const params = new URLSearchParams();
|
||||
params.append("for", outfitState.name);
|
||||
targetUrl += "?" + params.toString();
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
label="Shopping list"
|
||||
placement="top"
|
||||
background="purple.500"
|
||||
color="white"
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Shopping list"
|
||||
as={isDisabled ? "button" : "a"}
|
||||
href={isDisabled ? undefined : targetUrl}
|
||||
target={isDisabled ? undefined : "_blank"}
|
||||
icon={<IoBagCheck />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
isRound
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
label="Shopping list"
|
||||
placement="top"
|
||||
background="purple.500"
|
||||
color="white"
|
||||
>
|
||||
<IconButton
|
||||
aria-label="Shopping list"
|
||||
as={isDisabled ? "button" : "a"}
|
||||
href={isDisabled ? undefined : targetUrl}
|
||||
target={isDisabled ? undefined : "_blank"}
|
||||
icon={<IoBagCheck />}
|
||||
colorScheme="purple"
|
||||
size="sm"
|
||||
isRound
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -314,100 +314,100 @@ function ShoppingListButton({ outfitState }) {
|
|||
* if the user can save this outfit. If not, this is empty!
|
||||
*/
|
||||
function OutfitSavingIndicator({ outfitSaving }) {
|
||||
const {
|
||||
canSaveOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
} = outfitSaving;
|
||||
const {
|
||||
canSaveOutfit,
|
||||
isNewOutfit,
|
||||
isSaving,
|
||||
latestVersionIsSaved,
|
||||
saveError,
|
||||
saveOutfit,
|
||||
} = outfitSaving;
|
||||
|
||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||
const errorTextColor = useColorModeValue("red.600", "red.400");
|
||||
|
||||
if (!canSaveOutfit) {
|
||||
return null;
|
||||
}
|
||||
if (!canSaveOutfit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isNewOutfit) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
if (isNewOutfit) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
isLoading={isSaving}
|
||||
loadingText="Saving…"
|
||||
leftIcon={
|
||||
<Box
|
||||
// Adjust the visual balance toward the cloud
|
||||
marginBottom="-2px"
|
||||
>
|
||||
<IoCloudUploadOutline />
|
||||
</Box>
|
||||
}
|
||||
onClick={saveOutfit}
|
||||
data-test-id="wardrobe-save-outfit-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saving-indicator"
|
||||
>
|
||||
<Spinner
|
||||
size="xs"
|
||||
marginRight="1.5"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saving…
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (isSaving) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saving-indicator"
|
||||
>
|
||||
<Spinner
|
||||
size="xs"
|
||||
marginRight="1.5"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saving…
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (latestVersionIsSaved) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saved-indicator"
|
||||
>
|
||||
<CheckIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saved
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (latestVersionIsSaved) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-is-saved-indicator"
|
||||
>
|
||||
<CheckIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Saved
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
if (saveError) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-save-error-indicator"
|
||||
color={errorTextColor}
|
||||
>
|
||||
<WarningTwoIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Error saving
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
if (saveError) {
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
fontSize="xs"
|
||||
data-test-id="wardrobe-outfit-save-error-indicator"
|
||||
color={errorTextColor}
|
||||
>
|
||||
<WarningTwoIcon
|
||||
marginRight="1"
|
||||
// HACK: Not sure why my various centering things always feel wrong...
|
||||
marginBottom="-2px"
|
||||
/>
|
||||
Error saving
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// The most common way we'll hit this null is when the outfit is changing,
|
||||
// but the debouncing isn't done yet, so it's not saving yet.
|
||||
return null;
|
||||
// The most common way we'll hit this null is when the outfit is changing,
|
||||
// but the debouncing isn't done yet, so it's not saving yet.
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -415,133 +415,133 @@ function OutfitSavingIndicator({ outfitSaving }) {
|
|||
* It also contains the outfit menu, for saving etc.
|
||||
*/
|
||||
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
|
||||
const { canDeleteOutfit } = outfitSaving;
|
||||
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
|
||||
const { canDeleteOutfit } = outfitSaving;
|
||||
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
|
||||
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
// a Rename option.
|
||||
<Editable
|
||||
// Make sure not to ever pass `undefined` into here, or else the
|
||||
// component enters uncontrolled mode, and changing the value
|
||||
// later won't fix it!
|
||||
value={outfitState.name || ""}
|
||||
placeholder="Untitled outfit"
|
||||
onChange={(value) =>
|
||||
dispatchToOutfit({ type: "rename", outfitName: value })
|
||||
}
|
||||
>
|
||||
{({ onEdit }) => (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Box>
|
||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||
<Heading1>
|
||||
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||
<EditableInput lineHeight="48px" />
|
||||
</Heading1>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||
<Box width="3" flex="0 0 auto" />
|
||||
<ShoppingListButton outfitState={outfitState} />
|
||||
<Box width="2" flex="0 0 auto" />
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="ghost"
|
||||
icon={<MdMoreVert />}
|
||||
aria-label="Outfit menu"
|
||||
isRound
|
||||
size="sm"
|
||||
fontSize="24px"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{outfitState.id && (
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
as="a"
|
||||
href={outfitCopyUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit a copy
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<BiRename />}
|
||||
onClick={() => {
|
||||
// Start the rename after a tick, so finishing up the click
|
||||
// won't just immediately remove focus from the Editable.
|
||||
setTimeout(onEdit, 0);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
{canDeleteOutfit && (
|
||||
<DeleteOutfitMenuItem outfitState={outfitState} />
|
||||
)}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
)}
|
||||
</Editable>
|
||||
);
|
||||
return (
|
||||
// The Editable wraps everything, including the menu, because the menu has
|
||||
// a Rename option.
|
||||
<Editable
|
||||
// Make sure not to ever pass `undefined` into here, or else the
|
||||
// component enters uncontrolled mode, and changing the value
|
||||
// later won't fix it!
|
||||
value={outfitState.name || ""}
|
||||
placeholder="Untitled outfit"
|
||||
onChange={(value) =>
|
||||
dispatchToOutfit({ type: "rename", outfitName: value })
|
||||
}
|
||||
>
|
||||
{({ onEdit }) => (
|
||||
<Flex align="center" marginBottom="6">
|
||||
<Box>
|
||||
<Box role="group" d="inline-block" position="relative" width="100%">
|
||||
<Heading1>
|
||||
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
|
||||
<EditableInput lineHeight="48px" />
|
||||
</Heading1>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box width="4" flex="1 0 auto" />
|
||||
<OutfitSavingIndicator outfitSaving={outfitSaving} />
|
||||
<Box width="3" flex="0 0 auto" />
|
||||
<ShoppingListButton outfitState={outfitState} />
|
||||
<Box width="2" flex="0 0 auto" />
|
||||
<Menu placement="bottom-end">
|
||||
<MenuButton
|
||||
as={IconButton}
|
||||
variant="ghost"
|
||||
icon={<MdMoreVert />}
|
||||
aria-label="Outfit menu"
|
||||
isRound
|
||||
size="sm"
|
||||
fontSize="24px"
|
||||
opacity="0.8"
|
||||
/>
|
||||
<Portal>
|
||||
<MenuList>
|
||||
{outfitState.id && (
|
||||
<MenuItem
|
||||
icon={<EditIcon />}
|
||||
as="a"
|
||||
href={outfitCopyUrl}
|
||||
target="_blank"
|
||||
>
|
||||
Edit a copy
|
||||
</MenuItem>
|
||||
)}
|
||||
<MenuItem
|
||||
icon={<BiRename />}
|
||||
onClick={() => {
|
||||
// Start the rename after a tick, so finishing up the click
|
||||
// won't just immediately remove focus from the Editable.
|
||||
setTimeout(onEdit, 0);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</MenuItem>
|
||||
{canDeleteOutfit && (
|
||||
<DeleteOutfitMenuItem outfitState={outfitState} />
|
||||
)}
|
||||
</MenuList>
|
||||
</Portal>
|
||||
</Menu>
|
||||
</Flex>
|
||||
)}
|
||||
</Editable>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteOutfitMenuItem({ outfitState }) {
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const { id, name } = outfitState;
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
|
||||
const { status, error, mutateAsync } = useDeleteOutfitMutation();
|
||||
const { status, error, mutateAsync } = useDeleteOutfitMutation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
We'll delete this data and remove it from your list of outfits.
|
||||
Links and image embeds pointing to this outfit will break. Is that
|
||||
okay?
|
||||
{status === "error" && (
|
||||
<ErrorMessage marginTop="1em">
|
||||
Error deleting outfit: "{error.message}". Try again?
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>No, keep this outfit</Button>
|
||||
<Box flex="1 0 auto" width="2" />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() =>
|
||||
mutateAsync(id)
|
||||
.then(() => {
|
||||
window.location = "/your-outfits";
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
})
|
||||
}
|
||||
// We continue to show the loading spinner in the success case,
|
||||
// while we redirect away!
|
||||
isLoading={status === "pending" || status === "success"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalOverlay />
|
||||
<ModalContent>
|
||||
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody>
|
||||
We'll delete this data and remove it from your list of outfits.
|
||||
Links and image embeds pointing to this outfit will break. Is that
|
||||
okay?
|
||||
{status === "error" && (
|
||||
<ErrorMessage marginTop="1em">
|
||||
Error deleting outfit: "{error.message}". Try again?
|
||||
</ErrorMessage>
|
||||
)}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onClick={onClose}>No, keep this outfit</Button>
|
||||
<Box flex="1 0 auto" width="2" />
|
||||
<Button
|
||||
colorScheme="red"
|
||||
onClick={() =>
|
||||
mutateAsync(id)
|
||||
.then(() => {
|
||||
window.location = "/your-outfits";
|
||||
})
|
||||
.catch((e) => {
|
||||
/* handled in error UI */
|
||||
})
|
||||
}
|
||||
// We continue to show the loading spinner in the success case,
|
||||
// while we redirect away!
|
||||
isLoading={status === "pending" || status === "success"}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -555,24 +555,24 @@ function DeleteOutfitMenuItem({ outfitState }) {
|
|||
* See react-transition-group docs for more info!
|
||||
*/
|
||||
const fadeOutAndRollUpTransition = (css) => ({
|
||||
classNames: css`
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
classNames: css`
|
||||
&-exit {
|
||||
opacity: 1;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&-exit-active {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
`,
|
||||
timeout: 500,
|
||||
onExit: (e) => {
|
||||
e.style.height = e.offsetHeight + "px";
|
||||
},
|
||||
&-exit-active {
|
||||
opacity: 0;
|
||||
height: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
margin-bottom: 0 !important;
|
||||
transition: all 0.5s;
|
||||
}
|
||||
`,
|
||||
timeout: 500,
|
||||
onExit: (e) => {
|
||||
e.style.height = e.offsetHeight + "px";
|
||||
},
|
||||
});
|
||||
|
||||
export default ItemsPanel;
|
||||
|
|