Compare commits

..

1 commit

Author SHA1 Message Date
283e45cf3a fix hash in Thanks for showing us banner 2024-09-09 23:37:55 -04:00
645 changed files with 3329 additions and 15750 deletions

View file

@ -1,3 +1,15 @@
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
ARG RUBY_VERSION=3.4.5
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
FROM mcr.microsoft.com/devcontainers/ruby:1-3.1-bullseye
# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
# The value is a comma-separated list of allowed domains
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"
# [Optional] Uncomment this section to install additional OS packages.
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
# && apt-get -y install --no-install-recommends <your-package-list-here>
# [Optional] Uncomment this line to install additional gems.
# RUN gem install <your-gem-names-here>
# [Optional] Uncomment this line to install global node packages.
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1

View file

@ -0,0 +1,5 @@
CREATE DATABASE openneo_impress;
GRANT ALL PRIVILEGES ON openneo_impress.* TO impress_dev;
CREATE DATABASE openneo_id;
GRANT ALL PRIVILEGES ON openneo_id.* TO impress_dev;

View file

@ -1,36 +1,46 @@
// For format details, see https://containers.dev/implementors/json_reference/.
// For config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/ruby-rails-postgres
{
"name": "openneo_impress_items",
"dockerComposeFile": "compose.yaml",
"service": "rails-app",
"name": "Dress to Impress",
"dockerComposeFile": "docker-compose.yml",
"service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
"features": {
"ghcr.io/devcontainers/features/node:1": {
"nodeGypDependencies": true,
"version": "lts"
}
},
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {},
"ghcr.io/rails/devcontainer/features/mysql-client": {},
"ghcr.io/devcontainers-extra/features/ansible:2": {}
},
"containerEnv": {
"DB_HOST": "mysql"
},
"remoteEnv": {
"IMPRESS_DEPLOY_USER": "${localEnv:USER}"
},
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// This can be used to network with other containers or the host.
"forwardPorts": [3000],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": ".devcontainer/post-create.sh",
"containerEnv": {
// Because the database is hosted on the local network at the hostname `db`,
// we partially override `config/database.yml` to connect to `db`!
"DATABASE_URL_PRIMARY_DEV": "mysql2://db",
"DATABASE_URL_OPENNEO_ID_DEV": "mysql2://db",
"DATABASE_URL_PRIMARY_TEST": "mysql2://db",
"DATABASE_URL_OPENNEO_ID_TEST": "mysql2://db",
// HACK: Out of the box, this dev container doesn't allow installation to
// the default GEM_HOME, because of a weird thing going on with RVM.
// Instead, we set a custom GEM_HOME and GEM_PATH in our home directory!
// https://github.com/devcontainers/templates/issues/188
"GEM_HOME": "~/.rubygems",
"GEM_PATH": "~/.rubygems"
}
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser.
// "remoteUser": "root",
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash .devcontainer/setup-ssh-config.sh && bin/setup --skip-server"
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

View file

@ -1,7 +1,7 @@
name: "openneo_impress_items"
version: '3'
services:
rails-app:
app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
@ -12,26 +12,18 @@ services:
# Overrides default command so things don't shut down after the process ends.
command: sleep infinity
# Uncomment the next line to use a non-root user for all processes.
# user: vscode
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
network_mode: service:db
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
# (Adding the "ports" property to this file will not forward from a Codespace.)
depends_on:
- mysql
environment:
DB_USER: root
mysql:
image: mariadb:10.6
db:
image: mysql:latest
restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'true'
volumes:
- mysql-data:/var/lib/mysql
networks:
- default
volumes:
mysql-data:
- ./create-db.sql:/docker-entrypoint-initdb.d/create-db.sql
environment:
MYSQL_ROOT_PASSWORD: impress_dev
MYSQL_USER: impress_dev
MYSQL_PASSWORD: impress_dev

19
.devcontainer/post-create.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e # Quit if any part of this script fails.
# Mark all git repositories as safe to execute, including cached gems.
# NOTE: This would be dangerous to run on a normal multi-user machine,
# but for a dev container that only we use, it should be fine!
git config --global safe.directory '*'
# Install the app's Ruby gem dependencies.
bundle install
# Set up the databases: create the schema, and load in some default data.
bin/rails db:schema:load db:seed
# Install the app's JS dependencies.
yarn install
# Run a first-time build of the app's JS, in development mode.
yarn build:dev

View file

@ -1,28 +0,0 @@
#!/bin/bash
# Creates SSH config for devcontainer to use host's SSH identity
# This allows `ssh impress.openneo.net` to work without hardcoding usernames
mkdir -p ~/.ssh
chmod 700 ~/.ssh
# Only create SSH config if IMPRESS_DEPLOY_USER is explicitly set
if [ -z "$IMPRESS_DEPLOY_USER" ]; then
echo "⚠️ IMPRESS_DEPLOY_USER not set - skipping SSH config creation."
echo " This should be automatically set from your host \$USER environment variable."
echo " See docs/deployment-setup.md for details."
exit 0
fi
cat > ~/.ssh/config <<EOF
# Deployment server config
# Username: ${IMPRESS_DEPLOY_USER}
Host impress.openneo.net
User ${IMPRESS_DEPLOY_USER}
ForwardAgent yes
# Add other host configurations as needed
EOF
chmod 600 ~/.ssh/config
echo "✓ SSH config created. Deployment username: ${IMPRESS_DEPLOY_USER}"

2
.gitignore vendored
View file

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

View file

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

1
.prettierignore Normal file
View file

@ -0,0 +1 @@
/app/assets/javascripts/lib

1
.rspec
View file

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

View file

@ -1 +1 @@
3.4.5
3.3.4

40
Gemfile
View file

@ -1,7 +1,7 @@
source 'https://rubygems.org'
ruby '3.4.5'
ruby '3.3.4'
gem 'rails', '~> 8.0', '>= 8.0.1'
gem 'rails', '~> 7.1', '>= 7.1.3.4'
# The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.0'
@ -18,7 +18,8 @@ gem 'sprockets', '~> 4.2'
gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'jsbundling-rails', '~> 1.3'
gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.1'
gem 'turbo-rails', '~> 2.0'
# For authentication.
@ -32,7 +33,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0'
# For translation, both for the site UI and for Neopets data.
gem 'rails-i18n', '~> 8.0', '>= 8.0.1'
gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages.
@ -44,8 +45,7 @@ gem 'sanitize', '~> 6.0', '>= 6.0.2'
# For working with Neopets APIs.
# unstable version of RocketAMF interprets info registry as a hash instead of an array
# Vendored version with Ruby 3.4 ARM compatibility fixes (see vendor/gems/README-RocketAMF.md)
gem 'RocketAMF', path: 'vendor/gems/RocketAMF-1.0.0'
gem 'RocketAMF', :git => 'https://github.com/rubyamf/rocketamf.git'
# For preventing too many modeling attempts.
gem 'rack-attack', '~> 6.7'
@ -53,19 +53,23 @@ gem 'rack-attack', '~> 6.7'
# For testing emails in development.
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
# For parallel API calls.
gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests.
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.89.0", require: false
gem "async-http", "~> 0.75.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
@ -83,15 +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"
end
group :test do
gem "webmock", "~> 3.24"
end
gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph-rails", "~> 1.1", group: :development

View file

@ -1,142 +1,136 @@
PATH
remote: vendor/gems/RocketAMF-1.0.0
GIT
remote: https://github.com/rubyamf/rocketamf.git
revision: 796f591d002b5cf47df436dbcbd6f2ab00e869ed
specs:
RocketAMF (1.0.0.dti1)
RocketAMF (1.0.0)
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.15)
railties
actioncable (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
actioncable (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
actionmailbox (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
actionmailer (8.1.1)
actionpack (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activesupport (= 8.1.1)
actionmailer (7.2.1)
actionpack (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activesupport (= 7.2.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.1)
actionview (= 8.1.1)
activesupport (= 8.1.1)
actionpack (7.2.1)
actionview (= 7.2.1)
activesupport (= 7.2.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
racc
rack (>= 2.2.4, < 3.2)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
actiontext (7.2.1)
actionpack (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.1)
activesupport (= 8.1.1)
actionview (7.2.1)
activesupport (= 7.2.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.1)
activesupport (= 8.1.1)
activejob (7.2.1)
activesupport (= 7.2.1)
globalid (>= 0.3.6)
activemodel (8.1.1)
activesupport (= 8.1.1)
activerecord (8.1.1)
activemodel (= 8.1.1)
activesupport (= 8.1.1)
activemodel (7.2.1)
activesupport (= 7.2.1)
activerecord (7.2.1)
activemodel (= 7.2.1)
activesupport (= 7.2.1)
timeout (>= 0.4.0)
activestorage (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activesupport (= 8.1.1)
activestorage (7.2.1)
actionpack (= 7.2.1)
activejob (= 7.2.1)
activerecord (= 7.2.1)
activesupport (= 7.2.1)
marcel (~> 1.0)
activesupport (8.1.1)
activesupport (7.2.1)
base64
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
json
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.8)
public_suffix (>= 2.0.2, < 8.0)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.3)
async (2.35.0)
console (~> 1.29)
ast (2.4.2)
async (2.17.0)
console (~> 1.26)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-container (0.27.7)
async (~> 2.22)
async-http (0.89.0)
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.9)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
protocol-http (~> 0.49)
protocol-http1 (~> 0.30)
protocol-http2 (~> 0.22)
traces (~> 0.10)
async-http-cache (0.4.6)
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-http-cache (0.4.4)
async-http (~> 0.56)
async-pool (0.11.1)
async (>= 2.0)
async-service (0.16.0)
async-pool (0.8.1)
async (>= 1.25)
metrics
traces
async-service (0.12.0)
async
async-container (~> 0.16)
string-format (~> 0.2)
attr_required (1.0.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
babel-source (>= 4.0, < 6)
execjs (~> 2.0)
backport (1.2.0)
base64 (0.3.0)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindata (2.5.1)
benchmark (0.3.0)
bigdecimal (3.1.8)
bindata (2.5.0)
bindex (0.8.1)
bootsnap (1.20.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
builder (3.3.0)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
console (1.34.2)
concurrent-ruby (1.3.4)
connection_pool (2.4.1)
console (1.27.0)
fiber-annotation
fiber-local (~> 1.1)
json
crack (1.0.1)
bigdecimal
rexml
crass (1.0.6)
date (3.5.1)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
csv (3.3.0)
date (3.3.4)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
@ -145,19 +139,18 @@ GEM
warden (~> 1.2.3)
devise-encryptable (0.2.0)
devise (>= 2.1.0)
diff-lcs (1.6.2)
diff-lcs (1.5.1)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
drb (2.2.3)
drb (2.2.1)
e2mmap (0.1.0)
email_validator (2.2.4)
activemodel
erb (6.0.1)
erubi (1.13.1)
execjs (2.10.0)
falcon (0.48.6)
erubi (1.13.0)
execjs (2.9.1)
falcon (0.48.0)
async
async-container (~> 0.18)
async-http (~> 0.75)
@ -170,111 +163,98 @@ GEM
protocol-http (~> 0.31)
protocol-rack (~> 0.7)
samovar (~> 2.3)
faraday (2.14.0)
faraday-net_http (>= 2.0, < 3.5)
json
faraday (2.11.0)
faraday-net_http (>= 2.0, < 3.4)
logger
faraday-follow_redirects (0.4.0)
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
faraday-net_http (3.3.0)
net-http
ffi (1.17.0)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
globalid (1.3.0)
fiber-storage (1.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
haml (6.4.0)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
hashdiff (1.2.1)
hashie (5.1.0)
logger
hashie (5.0.0)
http_accept_language (2.1.1)
i18n (1.14.8)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.5)
concurrent-ruby (~> 1.0)
io-console (0.8.2)
io-endpoint (0.16.0)
io-event (1.14.2)
io-stream (0.11.1)
irb (1.16.0)
pp (>= 0.6.0)
io-console (0.7.2)
io-endpoint (0.13.1)
io-event (1.6.5)
io-stream (0.4.0)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jaro_winkler (1.6.1)
jaro_winkler (1.6.0)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.18.0)
json-jwt (1.17.0)
json (2.7.2)
json-jwt (1.16.6)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
language_server-protocol (3.17.0.3)
launchy (3.0.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
localhost (1.6.0)
logger (1.7.0)
loofah (2.25.0)
localhost (1.3.1)
logger (1.6.0)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
mail (2.8.1)
mini_mime (>= 0.1.1)
net-imap
net-pop
net-smtp
mapping (1.1.3)
marcel (1.1.0)
memory_profiler (1.1.0)
metrics (0.15.0)
mapping (1.1.1)
marcel (1.0.4)
memory_profiler (1.0.2)
metrics (0.10.2)
mini_mime (1.1.5)
mini_portile2 (2.8.9)
minitest (6.0.1)
prism (~> 1.5)
msgpack (1.8.0)
mysql2 (0.5.7)
bigdecimal
net-http (0.9.1)
uri (>= 0.11.1)
net-imap (0.6.2)
mini_portile2 (2.8.7)
minitest (5.25.1)
msgpack (1.7.2)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
mysql2 (0.5.6)
net-http (0.4.1)
uri
net-imap (0.4.14)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-smtp (0.5.0)
net-protocol
nio4r (2.7.5)
nokogiri (1.18.10)
nio4r (2.7.3)
nokogiri (1.16.7)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
omniauth (2.1.4)
omniauth (2.1.2)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (1.0.2)
@ -283,7 +263,7 @@ GEM
omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.1)
openid_connect (2.3.0)
activemodel
attr_required (>= 1.0.0)
email_validator
@ -296,142 +276,123 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
openssl (3.3.2)
openssl (3.2.0)
orm_adapter (0.5.0)
parallel (1.27.0)
parser (3.3.10.0)
parallel (1.26.3)
parser (3.3.4.2)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.7.0)
process-metrics (0.8.0)
process-metrics (0.3.0)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.5.1)
protocol-http (0.56.1)
protocol-http1 (0.35.2)
protocol-hpack (1.5.0)
protocol-http (0.33.0)
protocol-http1 (0.22.0)
protocol-http (~> 0.22)
protocol-http2 (0.23.0)
protocol-http2 (0.18.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.19.0)
io-stream (>= 0.10)
protocol-http (~> 0.43)
protocol-http (~> 0.18)
protocol-rack (0.7.0)
protocol-http (~> 0.27)
rack (>= 1.0)
psych (5.3.1)
date
psych (5.1.2)
stringio
public_suffix (7.0.0)
public_suffix (6.0.1)
racc (1.8.1)
rack (3.2.4)
rack-attack (6.8.0)
rack (3.1.7)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-oauth2 (2.3.0)
rack-oauth2 (2.2.1)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
rack-protection (4.0.0)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack-session (2.0.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack-test (2.1.0)
rack (>= 1.3)
rackup (2.3.1)
rackup (2.1.0)
rack (>= 3)
rails (8.1.1)
actioncable (= 8.1.1)
actionmailbox (= 8.1.1)
actionmailer (= 8.1.1)
actionpack (= 8.1.1)
actiontext (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activemodel (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
webrick (~> 1.8)
rails (7.2.1)
actioncable (= 7.2.1)
actionmailbox (= 7.2.1)
actionmailer (= 7.2.1)
actionpack (= 7.2.1)
actiontext (= 7.2.1)
actionview (= 7.2.1)
activejob (= 7.2.1)
activemodel (= 7.2.1)
activerecord (= 7.2.1)
activestorage (= 7.2.1)
activesupport (= 7.2.1)
bundler (>= 1.15.0)
railties (= 8.1.1)
rails-dom-testing (2.3.0)
railties (= 7.2.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.1.0)
nokogiri (~> 1.14)
rails-i18n (7.0.9)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
railties (>= 6.0.0, < 8)
railties (7.2.1)
actionpack (= 7.2.1)
activesupport (= 7.2.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rake (13.2.1)
rbs (2.8.4)
rdiscount (2.2.7.3)
rdoc (7.0.3)
erb
rdoc (6.7.0)
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
react-rails (2.7.1)
babel-transpiler (>= 0.7.0)
connection_pool
execjs
railties (>= 3.2)
tilt
record_tag_helper (1.0.1)
actionview (>= 5)
regexp_parser (2.9.2)
reline (0.5.9)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.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.6)
rubocop (1.82.1)
rexml (3.3.6)
strscan
rubocop (1.65.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
regexp_parser (>= 2.4, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.32.1)
parser (>= 3.3.1.0)
ruby-progressbar (1.13.0)
samovar (2.4.1)
samovar (2.3.0)
console (~> 1.0)
mapping (~> 1.0)
sanitize (6.1.3)
@ -447,11 +408,11 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
securerandom (0.4.1)
sentry-rails (5.28.1)
securerandom (0.3.1)
sentry-rails (5.19.0)
railties (>= 5.0)
sentry-ruby (~> 5.28.1)
sentry-ruby (5.28.1)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shell (0.8.1)
@ -473,45 +434,42 @@ GEM
thor (~> 1.0)
tilt (~> 2.0)
yard (~> 0.9, >= 0.9.24)
solargraph-rails (1.2.4)
solargraph-rails (1.1.0)
activesupport
solargraph (>= 0.48.0, <= 0.57)
sprockets (4.2.2)
solargraph
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
logger
rack (>= 2.2.4, < 4)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stackprof (0.2.27)
string-format (0.2.0)
stringio (3.2.0)
stackprof (0.2.26)
stringio (3.1.1)
strscan (3.1.0)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
faraday (~> 2.0)
faraday-follow_redirects
sync (0.5.0)
temple (0.10.4)
terser (1.2.6)
temple (0.10.3)
terser (1.2.3)
execjs (>= 0.3.0, < 3)
thor (1.4.0)
thor (1.3.1)
thread-local (1.1.0)
tilt (2.6.1)
timeout (0.6.0)
traces (0.18.2)
tsort (0.2.0)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tilt (2.4.0)
timeout (0.4.1)
traces (0.13.1)
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 (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
unicode-display_width (2.5.0)
uri (0.13.1)
useragent (0.16.10)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
@ -526,38 +484,31 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webmock (3.26.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.8.0)
base64
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.38)
zeitwerk (2.7.4)
yard (0.9.36)
zeitwerk (2.6.17)
PLATFORMS
aarch64-linux
arm64-darwin
ruby
x86_64-linux
DEPENDENCIES
RocketAMF!
addressable (~> 2.8)
async (~> 2.17)
async-http (~> 0.89.0)
async-http (~> 0.75.0)
bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
jsbundling-rails (~> 1.3)
httparty (~> 0.22.0)
jsbundling-rails (~> 1.1)
letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0)
mysql2 (~> 0.5.5)
@ -565,12 +516,14 @@ DEPENDENCIES
omniauth (~> 2.1)
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.7.1)
parallel (~> 1.23)
rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1)
rails (~> 8.0, >= 8.0.1)
rails-i18n (~> 8.0, >= 8.0.1)
rails (~> 7.1, >= 7.1.3.4)
rails-i18n (~> 7.0, >= 7.0.7)
rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 7.0)
react-rails (~> 2.7, >= 2.7.1)
record_tag_helper (~> 1.0, >= 1.0.1)
sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0)
sentry-rails (~> 5.12)
@ -584,11 +537,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.4.5p51
ruby 3.3.4p94
BUNDLED WITH
2.7.1
2.5.18

View file

@ -1,2 +1,2 @@
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server -b 0.0.0.0
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server
js: yarn dev

161
README.md
View file

@ -2,163 +2,6 @@
# Dress to Impress
Dress to Impress (DTI) is a tool for designing Neopets outfits. Load your pet, browse items, and see how they look together—all with a mobile-friendly interface!
Oh! We've been revitalizing the Rails app! Fun!
## Architecture Overview
DTI is a Rails application with a React-based outfit editor, backed by MySQL databases and a crowdsourced data collection system.
### Core Components
- **Rails backend** (Ruby 3.4, Rails 8.0): Serves web pages, API endpoints, and manages data
- **MySQL databases**: Primary database (`openneo_impress`) + legacy auth database (`openneo_id`)
- **React outfit editor**: Embedded in `app/javascript/wardrobe-2020/`, provides the main customization UI
- **Modeling system**: Crowdsources pet/item appearance data by fetching from Neopets APIs when users load their pets
### The Impress 2020 Complication
In 2020, we started a NextJS rewrite ("Impress 2020") to modernize the frontend. We've since consolidated back into Rails, but **Impress 2020 still provides essential services**:
- **GraphQL API**: Some outfit appearance data still loads via GraphQL (being migrated to Rails REST APIs)
- **Image generation**: Runs a headless browser to render outfit thumbnails and convert HTML5 assets to PNGs
See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for migration status.
## Key Concepts
### Customization Data Model
The core data model powers outfit rendering and item compatibility. See [docs/customization-architecture.md](./docs/customization-architecture.md) for details.
**Quick summary**:
- `body_id` is the key compatibility constraint (not species or color directly)
- Items have different `swf_assets` (visual layers) for different bodies
- Restrictions are subtractive: start with all layers, hide some based on zone restrictions
- Data is crowdsourced through "modeling" (users loading pets to contribute appearance data)
### Modeling (Crowdsourced Data)
DTI doesn't pre-populate item/pet data. Instead:
1. User loads a pet (via pet name lookup)
2. DTI fetches appearance data from Neopets APIs (legacy Flash/AMF protocol)
3. New `SwfAsset` records and relationships are created
4. Over time, the database learns which items fit which pet bodies
This "self-sustaining" approach means the site stays up-to-date as Neopets releases new content, without manual data entry.
## Directory Map
### Key Application Files
```
app/
├── controllers/
│ ├── outfits_controller.rb # Outfit editor + CRUD
│ ├── items_controller.rb # Item search, pages, and JSON APIs
│ ├── pets_controller.rb # Pet loading (triggers modeling)
│ └── closet_hangers_controller.rb # User item lists ("closets")
├── models/
│ ├── item.rb # Items + compatibility prediction logic
│ ├── pet_type.rb # Species+Color combinations (has body_id)
│ ├── pet_state.rb # Visual variants (pose/gender/mood)
│ ├── swf_asset.rb # Visual layers (biology/object)
│ ├── outfit.rb # Saved outfits + rendering logic (visible_layers)
│ ├── alt_style.rb # Alternative pet appearances (Nostalgic, etc.)
│ └── pet/
│ └── modeling_snapshot.rb # Processes Neopets API data into models
├── services/
│ ├── neopets/
│ │ ├── custom_pets.rb # Neopets AMF/Flash API client (pet data)
│ │ ├── nc_mall.rb # NC Mall item scraping
│ │ └── neopass.rb # NeoPass OAuth integration
│ ├── neopets_media_archive.rb # Local mirror of images.neopets.com
│ └── lebron_nc_values.rb # NC item trading values (external API)
├── javascript/
│ ├── wardrobe-2020/ # React outfit editor (extracted from Impress 2020)
│ │ ├── loaders/ # REST API calls (migrated from GraphQL)
│ │ ├── WardrobePage/ # Main editor UI
│ │ └── components/ # Shared React components
│ └── application.js # Rails asset pipeline entrypoint
└── views/
├── outfits/
│ └── edit.html.haml # Outfit editor page (loads React app)
├── items/
│ └── show.html.haml # Item detail page
└── closet_hangers/
└── index.html.haml # User closet/item lists
```
### Configuration & Docs
```
config/
├── routes.rb # All Rails routes
├── database.yml # Multi-database setup (main + openneo_id)
└── environments/
└── *.rb # Env-specific config (incl. impress_2020_origin)
```
**Documentation:**
- [docs/customization-architecture.md](./docs/customization-architecture.md) - Deep dive into data model & rendering
- [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) - What still depends on Impress 2020 service
**Tests:**
- `test/` - Test::Unit tests (privacy features)
- `spec/` - RSpec tests (models, services, integrations)
- Coverage is focused on key areas: modeling, prediction logic, external APIs
- Not comprehensive, but thorough for critical behaviors
## Tech Stack
- **Backend**: Ruby on Rails (Ruby 3.4, Rails 8.0)
- **Frontend**: Mix of Rails views (Turbo/HAML) and React (for outfit editor)
- **Database**: MySQL (two databases: `openneo_impress`, `openneo_id`)
- **Styling**: CSS, Sass (moving toward modern Rails conventions)
- **External Integrations**:
- **Neopets.com**: Legacy Flash/AMF protocol for pet appearance data (modeling)
- **Neopets NC Mall**: Web scraping for NC item availability/pricing
- **NeoPass**: OAuth integration for Neopets account linking
- **Neopets Media Archive**: Local filesystem mirror of `images.neopets.com` (never discards old files)
- **Lebron's NC Values**: Third-party API for NC item trading values ([lebron-values.netlify.app](https://lebron-values.netlify.app))
- **Impress 2020**: GraphQL for some outfit data, image generation service (being phased out)
## Development Notes
### OpenNeo ID Database
The `openneo_id` database is a legacy from when authentication was a separate service ("OpenNeo ID") meant to unify auth across multiple OpenNeo projects. DTI was the only project that succeeded, so the apps were merged—but the database split remains for now.
**Implications**:
- Rails is configured for multi-database mode
- User auth models live in `auth_user.rb` and connect to `openneo_id`
- **⚠️ CRITICAL**: Impress 2020 also directly accesses both `openneo_impress` and `openneo_id` databases via SQL
- **Database migrations affecting these schemas must consider Impress 2020's direct access**
- See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for full details on this dependency
### Rails/React Hybrid
Most pages are traditional Rails views using Turbo for interactivity. The **outfit editor** (`/outfits/new`) is a full React app that:
- Loads into a `#wardrobe-2020-root` div
- Uses React Query for data fetching
- Calls both Rails REST APIs (in `loaders/`) and Impress 2020 GraphQL (being migrated)
The goal is to simplify this over time—either consolidate into Rails+Turbo, or commit fully to React. For now, we're in a hybrid state.
## Deployment
- **Main app**: VPS running Rails (Puma, MySQL)
- **Impress 2020**: Separate VPS in same datacenter (NextJS, GraphQL, headless browser for images)
- **Shared databases**: Both services directly access the same MySQL databases over the network
- `openneo_impress` - Main application data
- `openneo_id` - Authentication data
- ⚠️ **Any database schema changes must be compatible with both services**
---
**Project maintained by [@matchu](https://github.com/matchu)** • **[OpenNeo.net](https://openneo.net)**
There'll be more to say about it here soon :3

View file

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 172 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View file

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

View file

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

View file

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

View file

@ -81,35 +81,23 @@ class SpeciesFacePickerOptions extends HTMLElement {
}
}
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
class MeasuredContainer extends HTMLElement {
static observedAttributes = ["style"];
class MeasuredContent extends HTMLElement {
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>`);
// Find our `<measured-container>` parent, and set our natural width
// as `var(--natural-width)` in the context of its CSS styles.
const container = this.closest("measured-container");
if (container == null) {
throw new Error(`<measured-content> must be in a <measured-container>`);
}
this.style.setProperty("--natural-width", content.offsetWidth + "px");
container.style.setProperty("--natural-width", this.offsetWidth + "px");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);
customElements.define("measured-content", MeasuredContent);

View file

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

View file

@ -21,6 +21,10 @@ class OutfitViewer extends HTMLElement {
this.#setIsPlaying(playPauseToggle.checked);
this.#setIsPlayingCookie(playPauseToggle.checked);
});
// Tell the CSS our first frame has rendered, which we use for loading
// state transitions.
this.#internals.states.add("after-first-frame");
}
#setIsPlaying(isPlaying) {

View file

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

View file

@ -37,12 +37,6 @@
pets.shift();
loading = true;
$.ajax({
beforeSend: (xhr) => {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
},
complete: function (data) {
loading = false;
loadNextIfReady();

View file

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

View file

@ -32,6 +32,9 @@ body
a[href]
color: $link-color
p
font-family: $text-font
input, button, select
font:
family: inherit
@ -74,7 +77,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 +86,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

View file

@ -3,20 +3,10 @@ body.use-responsive-design
max-width: 100%
padding-inline: 1rem
box-sizing: border-box
padding-top: 0
#main-nav
display: flex
flex-wrap: wrap
#home-link, #userbar
position: static
#home-link
padding-inline: .5rem
margin-inline: -.5rem
margin-right: auto
margin-left: 1rem
padding-inline: 0
#userbar
margin-left: auto
text-align: right
margin-right: 1rem

View file

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

View file

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

View file

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

View file

@ -8,7 +8,9 @@
@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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
@font-face {
font-family: Delicious;
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>)");
}
@font-face {
@ -15,3 +15,25 @@
font-style: italic;
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
}
@font-face {
font-family: "Noto Sans";
src: local("Noto Sans"), url("<%= font_path "NotoSans-Variable.ttf" %>");
}
@font-face {
font-family: "Noto Sans";
font-style: italic;
src: local("Noto Sans"), url("<%= font_path "NotoSans-Italic-Variable.ttf" %>");
}
@font-face {
font-family: "Noto Serif";
src: local("Noto Serif"), url("<%= font_path "NotoSerif-Variable.ttf" %>");
}
@font-face {
font-family: "Noto Serif";
font-style: italic;
src: local("Noto Serif"), url("<%= font_path "NotoSerif-Italic-Variable.ttf" %>");
}

View file

@ -18,16 +18,6 @@
overflow: hidden
text-overflow: ellipsis
td[data-is-same-as-prev]
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
.trade-list-names
list-style: none

View file

@ -2,8 +2,6 @@
@import "../partials/clean/mixins"
@import "../partials/item_header"
@import "../application/outfit-viewer"
#container
width: 900px // A bit more generous to the preview area!
@ -80,10 +78,93 @@
width: var(--natural-width)
outfit-viewer
display: block
position: relative
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
overflow: hidden
// There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection.
user-select: none
-webkit-user-select: none
outfit-layer
display: block
position: absolute
inset: 0
// We disable pointer-events most importantly for the iframes, which
// will ignore our `cursor: wait` and show a plain cursor for the
// inside of its own document. But also, the context menus for these
// elements are kinda actively misleading, too!
pointer-events: none
img, iframe
width: 100%
height: 100%
.loading-indicator
position: absolute
z-index: 1000
bottom: 0px
right: 4px
padding: 8px
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
opacity: 0
transition: opacity .5s
.play-pause-button
position: absolute
z-index: 1001
left: 8px
bottom: 8px
display: none
align-items: center
justify-content: center
color: white
background: rgba(0, 0, 0, 0.64)
width: 2.5em
height: 2.5em
border-radius: 100%
border: 2px solid transparent
transition: all .25s
.playing-label, .paused-label
display: none
width: 1em
height: 1em
.play-pause-toggle
// Visually hidden
clip: rect(0 0 0 0)
clip-path: inset(50%)
height: 1px
overflow: hidden
position: absolute
white-space: nowrap
width: 1px
&:checked ~ .playing-label
display: block
&:not(:checked) ~ .paused-label
display: block
&:hover, &:has(.play-pause-toggle:focus)
border: 2px solid $module-border-color
background: $module-bg-color
color: $text-color
&:has(.play-pause-toggle:active)
transform: translateY(2px)
&:has(outfit-layer:state(has-animations))
.play-pause-button
display: flex
.error-indicator
font-size: 85%
@ -97,9 +178,19 @@ outfit-viewer
// 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
// *out* on load should be instant. We also wait for the outfit-viewer to
// execute a `setTimeout(0)`, to make sure we always *start* in the
// non-loading state. This is because it's sometimes possible for the page to
// start with the web component already in `state(loading)`, and we need to
// make sure we *start* in *non-loading* state for the transition delay to
// happen. (This can happen when you Turbo-navigate between multiple items.)
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
cursor: wait
&:state(after-first-frame)
.loading-indicator
opacity: 1
transition-delay: 2s
#item-preview:has(outfit-layer:state(error))
outfit-viewer

View file

@ -107,10 +107,10 @@
a
color: inherit
.nc-trade-guide-info-link
.owls-info-link
cursor: help
.nc-trade-guide-info-label
.owls-info-label
text-decoration-line: underline
text-decoration-style: dotted

View file

@ -78,57 +78,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 +325,4 @@ body.outfits-new
#latest-contribution-created-at
color: $soft-text-color
margin-left: .5em

View file

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

View file

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

View file

@ -18,8 +18,9 @@ $error-color: #8a1f11
$error-bg-color: #fbe3e4
$error-border-color: #fbc2c4
$header-font: Delicious, system-ui, sans-serif
$main-font: system-ui, sans-serif
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
$main-font: "Noto Sans", Helvetica, Arial, Verdana, sans-serif
$text-font: "Noto Serif", Georgia, "Times New Roman", Times, serif
$object-img-size: 80px
$object-width: 100px

View file

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

View file

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

View file

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

View file

@ -1,45 +1,23 @@
class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index]
def index
@all_series_names = AltStyle.all_series_names
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
order(:species_id, :color_id)
@series_name = params[:series]
@color = find_color
@species = find_species
if params[:species_id]
@species = Species.find(params[:species_id])
@alt_styles = @alt_styles.merge(@species.alt_styles)
end
@alt_styles = AltStyle.includes(:color, :species, :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 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)
# We're using the HTML5 image for our preview, so make sure we have all the
# manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
if support_staff?
@counts = {
total: AltStyle.count,
unlabeled: AltStyle.unlabeled.count,
}
@counts[:labeled] = @counts[:total] - @counts[:unlabeled]
@unlabeled_style = AltStyle.unlabeled.newest.first
end
render
}
format.html { render }
format.json {
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped
render json: @alt_styles.as_json(
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url],
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: {
swf_assets: {
only: [:id, :body_id],
@ -47,62 +25,9 @@ class AltStylesController < ApplicationController
methods: [:urls, :known_glitches],
}
},
methods: [:series_main_name, :adjective_name],
methods: [:series_name, :adjective_name, :thumbnail_url],
)
}
end
end
def edit
@alt_style = AltStyle.find params[:id]
end
def update
@alt_style = AltStyle.find params[:id]
if @alt_style.update(alt_style_params)
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
redirect_to destination_after_save
else
render action: :edit, status: :bad_request
end
end
protected
def alt_style_params
params.require(:alt_style).
permit(:real_series_name, :real_full_name, :thumbnail_url)
end
def find_color
if params[:color]
Color.find_by(name: params[:color])
end
end
def find_species
if params[:species_id]
Species.find_by(id: params[:species_id])
elsif params[:species]
Species.find_by(name: params[:species])
end
end
def destination_after_save
if params[:next] == "unlabeled-style"
next_unlabeled_style_path
else
alt_styles_path
end
end
def next_unlabeled_style_path
unlabeled_style = AltStyle.unlabeled.newest.first
if unlabeled_style
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
else
alt_styles_path
end
end
end

View file

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

View file

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

View file

@ -4,8 +4,7 @@ class ItemTradesController < ApplicationController
@type = type_from_params
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
user_is_active.order('users.last_trade_activity_at DESC').
to_trades(current_user, request.remote_ip)
user_is_active.order('users.last_trade_activity_at DESC').to_trades
@trades = @item_trades[@type]
if user_signed_in?

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController
before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error
def index
@ -80,8 +79,7 @@ class ItemsController < ApplicationController
respond_to do |format|
format.html do
@trades = @item.closet_hangers.trading.user_is_active.
to_trades(current_user, request.remote_ip)
@trades = @item.closet_hangers.trading.user_is_active.to_trades
@contributors_with_counts = @item.contributors_with_counts
@ -99,8 +97,8 @@ class ItemsController < ApplicationController
@preview_error = validate_preview
@all_appearances = @item.appearances
@appearances_by_occupied_zone_label =
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l }
@appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
sort_by { |z, a| z.label }
@selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
@ -114,21 +112,6 @@ class ItemsController < ApplicationController
end
end
def edit
@item = Item.find params[:id]
render layout: "application"
end
def update
@item = Item.find params[:id]
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!"
redirect_to @item
else
render action: "edit", layout: "application", status: :bad_request
end
end
def sources
# Load all the items, then group them by source.
item_ids = params[:ids].split(",")
@ -145,7 +128,7 @@ class ItemsController < ApplicationController
# For Dyeworks items whose base is currently in the NC Mall, preload their
# trade values. We'll use this to determine which ones are fully buyable rn
# (because our NC values guide tracks this data and we don't).
# (because Owls tracks this data and we don't).
Item.preload_nc_trade_values(@items[:dyeworks])
# Start loading the NC trade values for the non-Mall NC items.
@ -181,15 +164,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
@ -241,8 +215,7 @@ class ItemsController < ApplicationController
@item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first ||
PetType.matching_name("Blue", "Acara").first!
preferring_simple.first
end
def validate_preview

View file

@ -5,10 +5,7 @@ class NeopetsConnectionsController < ApplicationController
if connection.save
render json: connection
else
render json: {
errors: connection.errors,
full_error_messages: connection.errors.map(&:full_message)
}, status: :bad_request
render json: {error: 'failure'}, status: :internal_server_error
end
end

View file

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

View file

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

View file

@ -1,111 +1,10 @@
class PetTypesController < ApplicationController
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

View file

@ -1,11 +1,14 @@
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)
@ -45,6 +48,12 @@ class PetsController < ApplicationController
:status => :not_found
end
def asset_download_error(e)
Rails.logger.warn e.message
pet_load_error :long_message => t('pets.load.asset_download_error'),
:status => :gateway_timeout
end
def pet_download_error(e)
Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n")

View file

@ -12,20 +12,13 @@ class SwfAssetsController < ApplicationController
helpers.image_url("favicon.png"),
@swf_asset.image_url,
*@swf_asset.canvas_movie_sprite_urls,
# For images, `images.neopets.com` is a generally safe host to load
# from (shouldn't be a vulnerable site or exfiltration vector), and
# doing this can help make this header a *lot* shorter, which helps
# our nginx reverse proxy (and probably some clients) handle it. (For
# example, see asset `667993` for "Engulfed in Flames Effect".)
origins: ["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,23 +38,7 @@ class SwfAssetsController < ApplicationController
private
def src_list(*urls, origins: [])
clean_urls = urls.
# Ignore any `nil`s that might arise
filter(&:present?).
# Parse the URL.
map { |url| Addressable::URI.parse(url) }.
# Remove query strings from URLs (they're invalid in CSPs)
each { |url| url.query = nil }.
# For the given `origins`, remove all their specific URLs, because
# we'll just include the entire origin anyway.
reject { |url| origins.include?(url.origin) }.
# Normalize the URLs. (This fixes issues like when the canonical
# Neopets version of the URL contains plain unescaped spaces.)
each(&:normalize!).
# Convert the URLs back into strings.
map(&:to_s)
clean_urls + origins
def src_list(*urls)
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
end
end

View file

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

View file

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

View file

@ -1,4 +1,6 @@
module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL
path_or_url
@ -127,6 +129,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 +148,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 +176,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 +237,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"

View file

@ -1,28 +1,22 @@
module ItemTradesHelper
def vague_trade_timestamp(trade)
return nil if trade.nil?
if trade.last_activity_at >= 1.week.ago
def vague_trade_timestamp(last_trade_activity_at)
if last_trade_activity_at >= 1.week.ago
translate "item_trades.index.table.last_active.this_week"
else
trade.last_activity_at.to_date.to_fs(:month_and_year)
last_trade_activity_at.strftime("%b %Y")
end
end
def same_vague_trade_timestamp?(trade1, trade2)
vague_trade_timestamp(trade1) == vague_trade_timestamp(trade2)
end
def sorted_vaguely_by_trade_activity(trades)
# First, sort the list in ascending order.
trades_ascending = trades.sort_by do |trade|
if trade.last_activity_at >= 1.week.ago
if trade.user.last_trade_activity_at >= 1.week.ago
# Sort recent trades in a random order, but still collectively as the
# most recent. (This discourages spamming updates to game the system!)
[1, rand]
else
# Sort older trades by last trade activity.
[0, trade.last_activity_at]
[0, trade.user.last_trade_activity_at]
end
end

View file

@ -14,30 +14,19 @@ module ItemsHelper
}
Sizes = {
face: 1, # 50x50
face_3x: 6, # 150x150
thumb: 2, # 150x150
full: 4, # 300x300
large: 5, # 500x500
xlarge: 7, # 640x640
zoom: 3, # 80x80
autocrop: 9, # <varies>
}
SizeUpgrades = {
face: :face_3x,
thumb: :full,
full: :xlarge,
face: 1,
thumb: 2,
zoom: 3,
full: 4,
face_2x: 6,
}
end
def pet_type_image_url(pet_type, emotion: :happy, size: :face)
PetTypeImage::Template.expand(
hash: pet_type.basic_image_hash || pet_type.image_hash,
emotion: PetTypeImage::Emotions.fetch(emotion),
size: PetTypeImage::Sizes.fetch(size),
emotion: PetTypeImage::Emotions[emotion],
size: PetTypeImage::Sizes[size],
).to_s
end
@ -141,13 +130,6 @@ module ItemsHelper
def auction_genie_url_for(item)
AUCTION_GENIE_URL_TEMPLATE.expand(auctiongenie: item.name).to_s
end
LEBRON_URL_TEMPLATE = Addressable::Template.new(
"https://stylisher.club/search/{name}"
)
def lebron_url_for(item)
LEBRON_URL_TEMPLATE.expand(name: item.name).to_s
end
def format_contribution_count(count)
" (&times;#{count})".html_safe if count > 1
@ -158,7 +140,7 @@ module ItemsHelper
end
def nc_trade_value_updated_at_text(nc_trade_value)
return "NC trade value" if nc_trade_value.updated_at.nil?
return nil if nc_trade_value.updated_at.nil?
# Render both "[X] [days] ago", and also the exact date, only including the
# year if it's not this same year.
@ -167,7 +149,7 @@ module ItemsHelper
nc_trade_value.updated_at.strftime("%b %-d") :
nc_trade_value.updated_at.strftime("%b %-d, %Y")
"NC trade value—Last updated: #{date_str} (#{time_ago_str} ago)"
"Last updated: #{date_str} (#{time_ago_str} ago)"
end
NC_TRADE_VALUE_ESTIMATE_PATTERN = %r{
@ -191,7 +173,7 @@ module ItemsHelper
# nicely for our use case.
def nc_trade_value_estimate_text(nc_trade_value)
match = nc_trade_value.value_text.match(NC_TRADE_VALUE_ESTIMATE_PATTERN)
return nc_trade_value.value_text if match.nil?
return nc_trade_value if match.nil?
match => {single:, low:, high:}
if single.present?
@ -199,7 +181,7 @@ module ItemsHelper
elsif low.present? && high.present?
"#{low}#{high} capsules"
else
nc_trade_value.value_text
nc_trade_value
end
end
@ -264,10 +246,8 @@ module ItemsHelper
def pet_type_image(pet_type, emotion, size, **options)
src = pet_type_image_url(pet_type, emotion:, size:)
size_2x = PetTypeImage::SizeUpgrades[size]
srcset = if size_2x
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
srcset = if size == :face
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
end
image_tag(src, srcset:, **options)

View file

@ -1,4 +1,9 @@
module OutfitsHelper
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-09-13")
def show_announcement?
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
end
def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil
end
@ -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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,6 @@
import React from "react";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import {
ChakraProvider,
Box,
@ -41,6 +43,8 @@ const globalStyles = theme.styles.global;
theme.styles.global = {};
export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []);
return (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
@ -54,6 +58,47 @@ export default function AppProvider({ children }) {
);
}
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"),
}),
// 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,
});
}
/**
* ScopedCSSReset applies a copy of Chakra UI's CSS reset, but only to its
* children (or, well, any element with the chakra-css-reset class). It also

View file

@ -1,10 +1,11 @@
import React from "react";
import { Box, Flex, useBreakpointValue } from "@chakra-ui/react";
import * as Sentry from "@sentry/react";
import ItemsPanel from "./ItemsPanel";
import SearchToolbar, { searchQueryIsEmpty } from "./SearchToolbar";
import SearchPanel from "./SearchPanel";
import { ErrorBoundary, TestErrorSender, useLocalStorage } from "../util";
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
/**
* ItemsAndSearchPanels manages the shared layout and state for:
@ -39,7 +40,7 @@ function ItemsAndSearchPanels({
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
return (
<ErrorBoundary>
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
@ -84,7 +85,7 @@ function ItemsAndSearchPanels({
</Box>
)}
</Flex>
</ErrorBoundary>
</Sentry.ErrorBoundary>
);
}

View file

@ -233,7 +233,7 @@ function OutfitControls({
/>
</DarkMode>
</Box>
<Flex flex="0 1 auto" align="center" pl="2">
<Flex flex="0 0 auto" align="center" pl="2">
<PosePicker
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}

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