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 FROM mcr.microsoft.com/devcontainers/ruby:1-3.1-bullseye
ARG RUBY_VERSION=3.4.5
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION # 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 format details, see https://aka.ms/devcontainer.json. For config options, see the
// For config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby // README at: https://github.com/devcontainers/templates/tree/main/src/ruby-rails-postgres
{ {
"name": "openneo_impress_items", "name": "Dress to Impress",
"dockerComposeFile": "compose.yaml", "dockerComposeFile": "docker-compose.yml",
"service": "rails-app", "service": "app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", "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 to add to the dev container. More info: https://containers.dev/features.
"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}"
},
// Use 'forwardPorts' to make a list of ports inside the container available locally. // 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], "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. // Configure tool-specific properties.
// "customizations": {}, // "customizations": {},
// Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser. // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root", // "remoteUser": "root"
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "bash .devcontainer/setup-ssh-config.sh && bin/setup --skip-server"
} }

View file

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

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/**/* tmp/**/*
.env .env
.env.* .env.*
/spec/examples.txt
/.yardoc
/app/assets/builds/* /app/assets/builds/*
!/app/assets/builds/.keep !/app/assets/builds/.keep

View file

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

1
.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' 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. # The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.0' gem 'falcon', '~> 0.48.0'
@ -18,7 +18,8 @@ gem 'sprockets', '~> 4.2'
gem 'haml', '~> 6.1', '>= 6.1.1' gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0' gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17' gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'jsbundling-rails', '~> 1.3' gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.1'
gem 'turbo-rails', '~> 2.0' gem 'turbo-rails', '~> 2.0'
# For authentication. # For authentication.
@ -32,7 +33,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0' gem 'will_paginate', '~> 4.0'
# For translation, both for the site UI and for Neopets data. # For translation, both for the site UI and for Neopets data.
gem 'rails-i18n', '~> 8.0', '>= 8.0.1' gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
gem 'http_accept_language', '~> 2.1', '>= 2.1.1' gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages. # For reading and parsing HTML from Neopets.com, like importing Closet pages.
@ -44,8 +45,7 @@ gem 'sanitize', '~> 6.0', '>= 6.0.2'
# For working with Neopets APIs. # For working with Neopets APIs.
# unstable version of RocketAMF interprets info registry as a hash instead of an array # 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', :git => 'https://github.com/rubyamf/rocketamf.git'
gem 'RocketAMF', path: 'vendor/gems/RocketAMF-1.0.0'
# For preventing too many modeling attempts. # For preventing too many modeling attempts.
gem 'rack-attack', '~> 6.7' gem 'rack-attack', '~> 6.7'
@ -53,19 +53,23 @@ gem 'rack-attack', '~> 6.7'
# For testing emails in development. # For testing emails in development.
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
# For parallel API calls.
gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests. # For miscellaneous HTTP requests.
gem "httparty", "~> 0.22.0"
gem "addressable", "~> 2.8" gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests. # For advanced batching of many HTTP requests.
gem "async", "~> 2.17", require: false gem "async", "~> 2.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 gem "thread-local", "~> 1.1", require: false
# For debugging. # For debugging.
group :development do gem 'web-console', '~> 4.2', group: :development
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2' # TODO: Review our use of content_tag_for etc and uninstall this!
end gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false gem 'bootsnap', '~> 1.16', require: false
@ -83,15 +87,5 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1" gem "shell", "~> 0.8.1"
# For workspace autocomplete. # For workspace autocomplete.
group :development do gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph", "~> 0.50.0" gem "solargraph-rails", "~> 1.1", group: :development
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

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

161
README.md
View file

@ -2,163 +2,6 @@
# Dress to Impress # 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 There'll be more to say about it here soon :3
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)**

View file

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

Binary file not shown.

Binary file not shown.

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 () {
function addCSRFToken(xhr) {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
}
var hangersInitCallbacks = []; var hangersInitCallbacks = [];
function onHangersInit(callback) { function onHangersInit(callback) {
@ -292,7 +285,6 @@
type: "post", type: "post",
data: data, data: data,
dataType: "json", dataType: "json",
beforeSend: addCSRFToken,
complete: function (data) { complete: function (data) {
if (quantityEl.val() == 0) { if (quantityEl.val() == 0) {
objectRemoved(objectWrapper); objectRemoved(objectWrapper);
@ -397,7 +389,6 @@
type: "post", type: "post",
data: data, data: data,
dataType: "json", dataType: "json",
beforeSend: addCSRFToken,
complete: function () { complete: function () {
button.val("Remove"); button.val("Remove");
}, },
@ -474,7 +465,6 @@
url: form.attr("action"), url: form.attr("action"),
type: form.attr("method"), type: form.attr("method"),
data: data, data: data,
beforeSend: addCSRFToken,
success: function (html) { success: function (html) {
var doc = $(html); var doc = $(html);
hangersEl.html(doc.find("#closet-hangers").html()); hangersEl.html(doc.find("#closet-hangers").html());
@ -511,7 +501,6 @@
url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }), url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
type: "delete", type: "delete",
dataType: "json", dataType: "json",
beforeSend: addCSRFToken,
success: function () { success: function () {
objectRemoved(hangerEls); objectRemoved(hangerEls);
}, },
@ -578,7 +567,6 @@
closet_hanger: closetHanger, closet_hanger: closetHanger,
return_to: window.location.pathname + window.location.search, return_to: window.location.pathname + window.location.search,
}, },
beforeSend: addCSRFToken,
complete: function () { complete: function () {
itemsSearchField.removeClass("loading"); itemsSearchField.removeClass("loading");
}, },
@ -723,7 +711,6 @@
type: "post", type: "post",
data: data, data: data,
dataType: "json", dataType: "json",
beforeSend: addCSRFToken,
complete: function () { complete: function () {
contactForm.enableForms(); contactForm.enableForms();
}, },
@ -744,7 +731,6 @@
type: "POST", type: "POST",
data: { neopets_connection: { neopets_username: newUsername } }, data: { neopets_connection: { neopets_username: newUsername } },
dataType: "json", dataType: "json",
beforeSend: addCSRFToken,
success: function (connection) { success: function (connection) {
var newOption = $("<option/>", { var newOption = $("<option/>", {
text: newUsername, text: newUsername,
@ -754,11 +740,6 @@
contactField.val(connection.id); contactField.val(connection.id);
submitContactForm(); 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 { } 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 class MeasuredContent extends HTMLElement {
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
class MeasuredContainer extends HTMLElement {
static observedAttributes = ["style"];
connectedCallback() { connectedCallback() {
setTimeout(() => this.#measure(), 0); 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() { #measure() {
// Find our `<measured-content>` child, and set our natural width as // Find our `<measured-container>` parent, and set our natural width
// `var(--natural-width)` in the context of our CSS styles. // as `var(--natural-width)` in the context of its CSS styles.
const content = this.querySelector("measured-content"); const container = this.closest("measured-container");
if (content == null) { if (container == null) {
throw new Error(`<measured-container> must contain a <measured-content>`); 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-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker); customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions); 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.#setIsPlaying(playPauseToggle.checked);
this.#setIsPlayingCookie(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) { #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(); pets.shift();
loading = true; loading = true;
$.ajax({ $.ajax({
beforeSend: (xhr) => {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
},
complete: function (data) { complete: function (data) {
loading = false; loading = false;
loadNextIfReady(); loadNextIfReady();

View file

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

View file

@ -32,6 +32,9 @@ body
a[href] a[href]
color: $link-color color: $link-color
p
font-family: $text-font
input, button, select input, button, select
font: font:
family: inherit family: inherit
@ -74,7 +77,7 @@ $container_width: 800px
input, button, select, label input, button, select, label
cursor: pointer cursor: pointer
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
border-radius: 3px border-radius: 3px
background: #fff background: #fff
border: 1px solid $input-border-color border: 1px solid $input-border-color
@ -83,15 +86,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
&:focus, &:active &:focus, &:active
color: inherit color: inherit
select:has(option[value='']:checked)
color: #666
option[value='']
color: #666
option:not([value=''])
color: $text-color
textarea textarea
font: inherit font: inherit

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,9 @@
@import partials/jquery.jgrowl @import partials/jquery.jgrowl
@import alt_styles/index
@import closet_hangers/index @import closet_hangers/index
@import closet_hangers/petpage
@import closet_lists/form @import closet_lists/form
@import neopets_page_import_tasks/new @import neopets_page_import_tasks/new
@import contributions/index @import contributions/index

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */ /* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
@font-face { @font-face {
font-family: Delicious; font-family: Delicious;
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>"); src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>)");
} }
@font-face { @font-face {
@ -15,3 +15,25 @@
font-style: italic; font-style: italic;
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>"); 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 overflow: hidden
text-overflow: ellipsis 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 .trade-list-names
list-style: none list-style: none

View file

@ -2,8 +2,6 @@
@import "../partials/clean/mixins" @import "../partials/clean/mixins"
@import "../partials/item_header" @import "../partials/item_header"
@import "../application/outfit-viewer"
#container #container
width: 900px // A bit more generous to the preview area! width: 900px // A bit more generous to the preview area!
@ -80,10 +78,93 @@
width: var(--natural-width) width: var(--natural-width)
outfit-viewer outfit-viewer
display: block
position: relative
width: 300px width: 300px
height: 300px height: 300px
border: 1px solid $module-border-color border: 1px solid $module-border-color
border-radius: 1em 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 .error-indicator
font-size: 85% font-size: 85%
@ -97,9 +178,19 @@ outfit-viewer
// is loading. // is loading.
// //
// We only apply the delay here, not on the base styles, because fading // We only apply the delay here, not on the base styles, because fading
// *out* on load should be instant. // *out* on load should be instant. We also wait for the outfit-viewer to
#item-preview[busy] outfit-viewer // execute a `setTimeout(0)`, to make sure we always *start* in the
+outfit-viewer-loading // 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)) #item-preview:has(outfit-layer:state(error))
outfit-viewer outfit-viewer

View file

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

View file

@ -78,57 +78,85 @@ body.outfits-new
font-size: 175% font-size: 175%
select select
font-size: 120% font-size: 120%
#description, #top-contributors
float: left
#description
margin-right: 2%
width: 64%
#top-contributors
border: 1px solid $input-border-color
margin-top: 1em
padding: 1%
width: 30%
ol
margin-left: 2em
padding-left: 1em
> a
font-size: 80%
display: block
text-align: right
#how-can-i-help, #i-found-something
+module
float: left
padding: 1%
width: 46%
h2
font-style: italic
input, button
font-size: 115%
input[type=text]
border-color: $module-border-color
width: 12em
#how-can-i-help
margin-right: 1%
#i-found-something
margin-left: 1%
a
float: right
font-size: 87.5%
margin-top: 1em
$section-count: 3
$section-border-width: 1px
$section-padding: 0.5em
$section-width: 100% / $section-count
// (A - (B-1)*C) / B
#sections #sections
display: grid +clearfix
grid-template-columns: 1fr 1fr 1fr display: table
list-style: none list-style: none
margin-top: 1em margin-top: 1em
li
display: grid
grid-template-areas: "header image" "info image" "form form"
grid-template-rows: auto auto auto
row-gap: .5em
padding: 0.5em
&:not(:first-child)
border-left: 1px solid $module-border-color
h3 h3
grid-area: header margin-bottom: .25em
margin-bottom: 0 li
border-left:
color: $module-border-color
style: solid
width: $section-border-width
display: table-cell
padding: $section-padding
position: relative
width: $section-width
&:first-child
border-left: 0
div div
grid-area: info
color: $soft-text-color color: $soft-text-color
font-size: 75% font-size: 75%
margin-left: 1em margin-left: 1em
z-index: 2 z-index: 2
strong h4, input
font-size: 116% font-size: 116%
a:has(img) h4, input[type=text]
grid-area: image color: inherit
h4 a
background: #ffffc0
img img
opacity: 0.75 +opacity(0.75)
float: right float: right
margin-left: .5em margin-left: .5em
&:hover &:hover
opacity: 1 +opacity(1)
p p
line-height: 1.5
min-height: 4.5em min-height: 4.5em
margin-bottom: 0
form
grid-area: form
display: flex
align-items: center
gap: .5em
font-size: .85em
margin-left: 1em
margin-right: .5em
input[type=text], input[type=search]
// TODO: It doesn't make sense to me that this is the right style? I
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
// which I would have *thought* is the same as this, but isn't! Idk!
width: 100%
#whats-new #whats-new
margin-bottom: 1em margin-bottom: 1em
@ -297,3 +325,4 @@ body.outfits-new
#latest-contribution-created-at #latest-contribution-created-at
color: $soft-text-color color: $soft-text-color
margin-left: .5em margin-left: .5em

View file

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

View file

@ -67,21 +67,14 @@
background: #FEEBC8 background: #FEEBC8
color: #7B341E color: #7B341E
.support-form
grid-area: support
font-size: 85%
text-align: left
.user-lists-info .user-lists-info
grid-area: lists grid-area: lists
font-size: 85% font-size: 85%
text-align: left text-align: left
display: flex .user-lists-form-opener
gap: 1em &::after
content: " "
a::after
content: " "
.user-lists-form .user-lists-form
background: $background-color background: $background-color

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -218,7 +218,7 @@ class ClosetHangersController < ApplicationController
def enforce_shadowban def enforce_shadowban
# If this user is shadowbanned, and this *doesn't* seem to be a request # If this user is shadowbanned, and this *doesn't* seem to be a request
# from that user, render the 404 page. # from that user, render the 404 page.
if !@user.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 render file: "public/404.html", layout: false, status: :not_found
end end
end end

View file

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

View file

@ -1,6 +1,5 @@
class ItemsController < ApplicationController class ItemsController < ApplicationController
before_action :set_query before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error rescue_from Item::Search::Error, :with => :search_error
def index def index
@ -80,8 +79,7 @@ class ItemsController < ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
@trades = @item.closet_hangers.trading.user_is_active. @trades = @item.closet_hangers.trading.user_is_active.to_trades
to_trades(current_user, request.remote_ip)
@contributors_with_counts = @item.contributors_with_counts @contributors_with_counts = @item.contributors_with_counts
@ -99,8 +97,8 @@ class ItemsController < ApplicationController
@preview_error = validate_preview @preview_error = validate_preview
@all_appearances = @item.appearances @all_appearances = @item.appearances
@appearances_by_occupied_zone_label = @appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l } sort_by { |z, a| z.label }
@selected_item_appearance = @preview_outfit.item_appearances.first @selected_item_appearance = @preview_outfit.item_appearances.first
@preview_pet_type_options = PetType.where(color: @preview_outfit.color). @preview_pet_type_options = PetType.where(color: @preview_outfit.color).
@ -114,21 +112,6 @@ class ItemsController < ApplicationController
end end
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 def sources
# Load all the items, then group them by source. # Load all the items, then group them by source.
item_ids = params[:ids].split(",") 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 # 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 # 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]) Item.preload_nc_trade_values(@items[:dyeworks])
# Start loading the NC trade values for the non-Mall NC items. # Start loading the NC trade values for the non-Mall NC items.
@ -181,15 +164,6 @@ class ItemsController < ApplicationController
protected protected
def item_params
params.require(:item).permit(
:name, :thumbnail_url, :description, :modeling_status_hint,
:is_manually_nc, :explicitly_body_specific,
).tap do |p|
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
end
end
def assign_closeted!(items) def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in? current_user.assign_closeted_to_items!(items) if user_signed_in?
end end
@ -241,8 +215,7 @@ class ItemsController < ApplicationController
@item.compatible_pet_types. @item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>"). preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>"). preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first || preferring_simple.first
PetType.matching_name("Blue", "Acara").first!
end end
def validate_preview def validate_preview

View file

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

View file

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

View file

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

View file

@ -1,111 +1,10 @@
class PetTypesController < ApplicationController class PetTypesController < ApplicationController
def index
respond_to do |format|
format.html {
@species_names = Species.order(:name).map(&:human_name)
@color_names = Color.order(:name).map(&:human_name)
if params[:species].present?
@selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name
end
if params[:color].present?
@selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name
end
@selected_order =
if @selected_species.present? || @selected_color.present?
:alphabetical
else
:newest
end
@pet_types = PetType.
includes(:color, :species, :pet_states).
paginate(page: params[:page], per_page: 30)
@pet_types.where!(species_id: @selected_species) if @selected_species
@pet_types.where!(color_id: @selected_color) if @selected_color
if @selected_order == :newest
@pet_types.order!(created_at: :desc)
elsif @selected_order == :alphabetical
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
end
if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first
end
if support_staff?
@counts = {
total: PetState.count,
glitched: PetState.glitched.count,
needs_labeling: PetState.needs_labeling.count,
usable: PetState.usable.count,
}
@unlabeled_appearance = PetState.next_unlabeled_appearance
end
}
format.json {
if stale?(etag: PetState.last_updated_key)
render json: {
species: Species.order(:name).all,
colors: Color.order(:name).all,
supported_poses: PetState.all_supported_poses,
}
end
}
end
end
def show def show
@pet_type = find_pet_type @pet_type = PetType.
where(species_id: params[:species_id]).
where(color_id: params[:color_id]).
first
respond_to do |format| render json: @pet_type
format.html do
@pet_states = group_pet_states @pet_type.pet_states
end
format.json { render json: @pet_type }
end
end
protected
# The API-ish route uses IDs, but the human-facing route uses names.
def find_pet_type
if params[:species_id] && params[:color_id]
PetType.find_by!(
species_id: params[:species_id],
color_id: params[:color_id],
)
elsif params[:name]
PetType.find_by_param!(params[:name])
else
raise "expected params: species_id and color_id, or name"
end
end
# The `canonical` pet states are the main ones we want to show: the most
# canonical state for each pose. The `other` pet states are, the others!
#
# If no main poses are available, then we just make all the poses
# "canonical", and show the whole mish-mash!
def group_pet_states(pet_states)
pose_groups = pet_states.emotion_order.group_by(&:pose)
main_groups =
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
other_groups =
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
if main_groups.empty?
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
end
canonical = main_groups.map(&:first).sort_by(&:pose)
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
{canonical:, other:}
end end
end end

View file

@ -1,11 +1,14 @@
class PetsController < ApplicationController class PetsController < ApplicationController
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found rescue_from Pet::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled rescue_from Pet::DownloadError, with: :pet_download_error
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load def load
raise Neopets::CustomPets::PetNotFound unless params[:name] # Uncomment this to temporarily disable modeling for most users.
# return modeling_disabled unless user_signed_in? && current_user.admin?
raise Pet::PetNotFound unless params[:name]
@pet = Pet.load(params[:name]) @pet = Pet.load(params[:name])
points = contribute(current_user, @pet) points = contribute(current_user, @pet)
@ -45,6 +48,12 @@ class PetsController < ApplicationController
:status => :not_found :status => :not_found
end end
def asset_download_error(e)
Rails.logger.warn e.message
pet_load_error :long_message => t('pets.load.asset_download_error'),
:status => :gateway_timeout
end
def pet_download_error(e) def pet_download_error(e)
Rails.logger.warn e.message Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n") Rails.logger.warn e.backtrace.join("\n")

View file

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

View file

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

View file

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

View file

@ -1,4 +1,6 @@
module ApplicationHelper module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url) def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL if path_or_url.include?('://') # already an absolute URL
path_or_url path_or_url
@ -127,6 +129,10 @@ module ApplicationHelper
!@hide_home_link !@hide_home_link
end end
def support_staff?
user_signed_in? && current_user.support_staff?
end
def impress_2020_meta_tags def impress_2020_meta_tags
origin = Rails.configuration.impress_2020_origin origin = Rails.configuration.impress_2020_origin
support_secret = Rails.application.credentials.dig( support_secret = Rails.application.credentials.dig(
@ -142,9 +148,20 @@ module ApplicationHelper
end end
end end
JAVASCRIPT_LIBRARIES = {
:jquery => 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js',
:jquery_tmpl => 'https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js',
}
def include_javascript_libraries(*library_names)
raw(library_names.inject('') do |html, name|
html + javascript_include_tag(JAVASCRIPT_LIBRARIES[name], defer: true)
end)
end
def locale_options def locale_options
current_locale_is_public = false current_locale_is_public = false
options = I18n.available_locales.map do |available_locale| options = I18n.public_locales.map do |available_locale|
current_locale_is_public = true if I18n.locale == available_locale current_locale_is_public = true if I18n.locale == available_locale
# Include fallbacks data on the tag. Right now it's used in blog # Include fallbacks data on the tag. Right now it's used in blog
# localization, but may conceivably be used for something else later. # localization, but may conceivably be used for something else later.
@ -160,6 +177,13 @@ module ApplicationHelper
options options
end end
def localized_cache(key={}, &block)
localized_key = localize_fragment_key(key, locale)
# TODO: The digest feature is handy, but it's not compatible with how we
# check for fragments existence in the controller, so skip it for now.
cache(localized_key, skip_digest: true, &block)
end
def auth_user_sign_in_path_with_return_to def auth_user_sign_in_path_with_return_to
new_auth_user_session_path :return_to => request.fullpath new_auth_user_session_path :return_to => request.fullpath
end end
@ -213,10 +237,6 @@ module ApplicationHelper
@hide_title_header = true @hide_title_header = true
end end
def hide_after(last_day, &block)
yield if Date.today <= last_day
end
def use_responsive_design def use_responsive_design
@use_responsive_design = true @use_responsive_design = true
add_body_class "use-responsive-design" add_body_class "use-responsive-design"

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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