Compare commits
No commits in common. "main" and "modeling-tests" have entirely different histories.
main
...
modeling-t
549 changed files with 1949 additions and 13514 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
5
.devcontainer/create-db.sql
Normal file
5
.devcontainer/create-db.sql
Normal 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;
|
||||||
|
|
@ -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"
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
19
.devcontainer/post-create.sh
Executable 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
|
||||||
|
|
@ -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}"
|
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -5,7 +5,6 @@ tmp/**/*
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
/spec/examples.txt
|
/spec/examples.txt
|
||||||
/.yardoc
|
|
||||||
|
|
||||||
/app/assets/builds/*
|
/app/assets/builds/*
|
||||||
!/app/assets/builds/.keep
|
!/app/assets/builds/.keep
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
3.4.5
|
3.3.5
|
||||||
|
|
|
||||||
33
Gemfile
33
Gemfile
|
|
@ -1,7 +1,7 @@
|
||||||
source 'https://rubygems.org'
|
source 'https://rubygems.org'
|
||||||
ruby '3.4.5'
|
ruby '3.3.5'
|
||||||
|
|
||||||
gem 'rails', '~> 8.0', '>= 8.0.1'
|
gem 'rails', '~> 7.2', '>= 7.2.1'
|
||||||
|
|
||||||
# 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,6 +18,7 @@ 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 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||||
gem 'jsbundling-rails', '~> 1.3'
|
gem 'jsbundling-rails', '~> 1.3'
|
||||||
gem 'turbo-rails', '~> 2.0'
|
gem 'turbo-rails', '~> 2.0'
|
||||||
|
|
||||||
|
|
@ -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,22 +53,20 @@ 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 image processing (outfit PNG rendering).
|
|
||||||
gem "ruby-vips", "~> 2.2"
|
|
||||||
|
|
||||||
# For debugging.
|
# For debugging.
|
||||||
group :development do
|
gem 'web-console', '~> 4.2', group: :development
|
||||||
gem 'debug', '~> 1.9.2'
|
|
||||||
gem 'web-console', '~> 4.2'
|
|
||||||
end
|
|
||||||
|
|
||||||
# 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
|
||||||
|
|
@ -86,15 +84,10 @@ 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.
|
# For automated tests.
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem "rspec-rails", "~> 7.0"
|
gem "rspec-rails", "~> 7.0"
|
||||||
end
|
end
|
||||||
group :test do
|
|
||||||
gem "webmock", "~> 3.24"
|
|
||||||
end
|
|
||||||
|
|
|
||||||
505
Gemfile.lock
505
Gemfile.lock
|
|
@ -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.2)
|
||||||
falcon (0.48.6)
|
|
||||||
async
|
async
|
||||||
async-container (~> 0.18)
|
async-container (~> 0.18)
|
||||||
async-http (~> 0.75)
|
async-http (~> 0.75)
|
||||||
|
|
@ -170,107 +163,99 @@ 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.12.0)
|
||||||
faraday-net_http (>= 2.0, < 3.5)
|
faraday-net_http (>= 2.0, < 3.4)
|
||||||
json
|
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.6)
|
||||||
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.1)
|
||||||
irb (1.16.0)
|
irb (1.14.1)
|
||||||
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.1)
|
||||||
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.1.0)
|
||||||
metrics (0.15.0)
|
metrics (0.10.2)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
minitest (6.0.1)
|
mini_portile2 (2.8.7)
|
||||||
prism (~> 1.5)
|
minitest (5.25.1)
|
||||||
msgpack (1.8.0)
|
msgpack (1.7.2)
|
||||||
mysql2 (0.5.7)
|
multi_xml (0.7.1)
|
||||||
bigdecimal
|
bigdecimal (~> 3.1)
|
||||||
net-http (0.9.1)
|
mysql2 (0.5.6)
|
||||||
uri (>= 0.11.1)
|
net-http (0.4.1)
|
||||||
net-imap (0.6.2)
|
uri
|
||||||
|
net-imap (0.4.16)
|
||||||
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-aarch64-linux-gnu)
|
nokogiri (1.16.7)
|
||||||
|
mini_portile2 (~> 2.8.2)
|
||||||
racc (~> 1.4)
|
racc (~> 1.4)
|
||||||
nokogiri (1.18.10-arm64-darwin)
|
omniauth (2.1.2)
|
||||||
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)
|
||||||
|
|
@ -279,7 +264,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
|
||||||
|
|
@ -292,118 +277,114 @@ 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.5.0)
|
||||||
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.1)
|
||||||
protocol-http (0.56.1)
|
protocol-http (0.37.0)
|
||||||
protocol-http1 (0.35.2)
|
protocol-http1 (0.27.0)
|
||||||
protocol-http (~> 0.22)
|
protocol-http (~> 0.22)
|
||||||
protocol-http2 (0.23.0)
|
protocol-http2 (0.19.1)
|
||||||
protocol-hpack (~> 1.4)
|
protocol-hpack (~> 1.4)
|
||||||
protocol-http (~> 0.47)
|
protocol-http (~> 0.18)
|
||||||
protocol-rack (0.19.0)
|
protocol-rack (0.10.0)
|
||||||
io-stream (>= 0.10)
|
protocol-http (~> 0.37)
|
||||||
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
|
||||||
|
regexp_parser (2.9.2)
|
||||||
|
reline (0.5.10)
|
||||||
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.7)
|
||||||
rspec-core (3.13.6)
|
rspec-core (3.13.2)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.5)
|
rspec-expectations (3.13.3)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-mocks (3.13.7)
|
rspec-mocks (3.13.2)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (7.1.1)
|
rspec-rails (7.0.1)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 7.0)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
railties (>= 7.0)
|
railties (>= 7.0)
|
||||||
|
|
@ -411,26 +392,21 @@ GEM
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
rspec-support (~> 3.13)
|
rspec-support (~> 3.13)
|
||||||
rspec-support (3.13.6)
|
rspec-support (3.13.1)
|
||||||
rubocop (1.82.1)
|
rubocop (1.66.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)
|
rubocop-ast (>= 1.32.2, < 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.3)
|
||||||
parser (>= 3.3.7.2)
|
parser (>= 3.3.1.0)
|
||||||
prism (~> 1.4)
|
|
||||||
ruby-progressbar (1.13.0)
|
ruby-progressbar (1.13.0)
|
||||||
ruby-vips (2.3.0)
|
samovar (2.3.0)
|
||||||
ffi (~> 1.12)
|
|
||||||
logger
|
|
||||||
samovar (2.4.1)
|
|
||||||
console (~> 1.0)
|
console (~> 1.0)
|
||||||
mapping (~> 1.0)
|
mapping (~> 1.0)
|
||||||
sanitize (6.1.3)
|
sanitize (6.1.3)
|
||||||
|
|
@ -446,11 +422,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)
|
||||||
|
|
@ -472,45 +448,40 @@ 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)
|
|
||||||
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.2)
|
||||||
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.10)
|
||||||
turbo-rails (2.0.20)
|
actionpack (>= 6.0.0)
|
||||||
actionpack (>= 7.1.0)
|
railties (>= 6.0.0)
|
||||||
railties (>= 7.1.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.6.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
|
||||||
|
|
@ -525,36 +496,30 @@ GEM
|
||||||
activesupport
|
activesupport
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
faraday-follow_redirects
|
faraday-follow_redirects
|
||||||
webmock (3.26.1)
|
webrick (1.8.2)
|
||||||
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.37)
|
||||||
zeitwerk (2.7.4)
|
zeitwerk (2.6.18)
|
||||||
|
|
||||||
PLATFORMS
|
PLATFORMS
|
||||||
aarch64-linux
|
ruby
|
||||||
arm64-darwin
|
|
||||||
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)
|
||||||
|
httparty (~> 0.22.0)
|
||||||
jsbundling-rails (~> 1.3)
|
jsbundling-rails (~> 1.3)
|
||||||
letter_opener (~> 1.8, >= 1.8.1)
|
letter_opener (~> 1.8, >= 1.8.1)
|
||||||
memory_profiler (~> 1.0)
|
memory_profiler (~> 1.0)
|
||||||
|
|
@ -563,13 +528,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.2, >= 7.2.1)
|
||||||
rails-i18n (~> 8.0, >= 8.0.1)
|
rails-i18n (~> 7.0, >= 7.0.7)
|
||||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||||
|
react-rails (~> 2.7, >= 2.7.1)
|
||||||
rspec-rails (~> 7.0)
|
rspec-rails (~> 7.0)
|
||||||
ruby-vips (~> 2.2)
|
|
||||||
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)
|
||||||
|
|
@ -583,11 +549,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.5p100
|
||||||
|
|
||||||
BUNDLED WITH
|
BUNDLED WITH
|
||||||
2.7.1
|
2.5.18
|
||||||
|
|
|
||||||
|
|
@ -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
161
README.md
|
|
@ -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)**
|
|
||||||
|
|
|
||||||
|
|
@ -754,11 +754,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 {
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -2,3 +2,54 @@
|
||||||
width: 300px
|
width: 300px
|
||||||
height: 300px
|
height: 300px
|
||||||
margin: 0 auto
|
margin: 0 auto
|
||||||
|
|
||||||
|
.alt-style-form
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 1em
|
||||||
|
align-items: flex-start
|
||||||
|
|
||||||
|
fieldset
|
||||||
|
width: 100%
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: auto 1fr
|
||||||
|
align-items: center
|
||||||
|
gap: 1em
|
||||||
|
|
||||||
|
> *:nth-child(2n)
|
||||||
|
width: 40rch
|
||||||
|
max-width: 100%
|
||||||
|
box-sizing: border-box
|
||||||
|
|
||||||
|
input[type=url]
|
||||||
|
font-size: .85em
|
||||||
|
|
||||||
|
label
|
||||||
|
font-weight: bold
|
||||||
|
|
||||||
|
.thumbnail-field
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .25em
|
||||||
|
|
||||||
|
img
|
||||||
|
width: 40px
|
||||||
|
height: 40px
|
||||||
|
|
||||||
|
input
|
||||||
|
flex: 1 0 20ch
|
||||||
|
|
||||||
|
.field_with_errors
|
||||||
|
display: contents
|
||||||
|
|
||||||
|
.actions
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 1em
|
||||||
|
|
||||||
|
label
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: .25em
|
||||||
|
font-size: .85em
|
||||||
|
font-style: italic
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,3 @@
|
||||||
@import "../partials/clean/constants"
|
|
||||||
|
|
||||||
// Prefer to break the name at visually appealing points.
|
|
||||||
.rainbow-pool-list
|
.rainbow-pool-list
|
||||||
.name
|
.name span
|
||||||
text-wrap: balance
|
display: inline-block
|
||||||
|
|
||||||
// De-emphasize Prismatic styles, in browsers that support it.
|
|
||||||
.rainbow-pool-filters
|
|
||||||
select[name="series"]
|
|
||||||
option[value*=": "]
|
|
||||||
color: $soft-text-color
|
|
||||||
font-style: italic
|
|
||||||
|
|
|
||||||
|
|
@ -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 corner—this is in fact the default!—but that
|
|
||||||
// gets confusing fast with scale in play. I think this is easier to reason
|
|
||||||
// about with the top-left corner in terms of math, and center it after the
|
|
||||||
// fact.
|
|
||||||
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
|
|
||||||
transform-origin: left top
|
|
||||||
|
|
@ -108,18 +108,3 @@ outfit-viewer
|
||||||
|
|
||||||
&:has(outfit-layer:state(loading))
|
&:has(outfit-layer:state(loading))
|
||||||
+outfit-viewer-loading
|
+outfit-viewer-loading
|
||||||
|
|
||||||
// If a layer has the `[highlighted]` attribute, it's brought to the front,
|
|
||||||
// and other layers are grayed out and blurred. We use this in the support
|
|
||||||
// outfit viewer, when you hover over a layer.
|
|
||||||
&:has(outfit-layer[highlighted])
|
|
||||||
outfit-layer[highlighted]
|
|
||||||
z-index: 999
|
|
||||||
|
|
||||||
// Filter everything behind the bottom-most highlighted layer, using a
|
|
||||||
// backdrop filter. This gives us the best visual consistency by applying
|
|
||||||
// effects to the entire backdrop, instead of each layer and then
|
|
||||||
// re-compositing them.
|
|
||||||
backdrop-filter: grayscale(1) brightness(2) blur(1px)
|
|
||||||
& ~ outfit-layer[highlighted]
|
|
||||||
backdrop-filter: none
|
|
||||||
|
|
|
||||||
|
|
@ -1,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
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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%
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
support-outfit-viewer
|
@import "../partials/clean/constants"
|
||||||
margin-block: 1em
|
|
||||||
|
|
||||||
.fields li[data-type=radio-grid]
|
outfit-viewer
|
||||||
--num-columns: 3
|
margin: 0 auto
|
||||||
|
|
||||||
.reference-link
|
.pose-options
|
||||||
display: flex
|
list-style-type: none
|
||||||
align-items: center
|
display: grid
|
||||||
gap: .5em
|
grid-template-columns: 1fr 1fr 1fr
|
||||||
padding-inline: .5em
|
gap: .25em
|
||||||
|
|
||||||
img
|
label
|
||||||
height: 2em
|
display: flex
|
||||||
width: auto
|
align-items: center
|
||||||
|
gap: .5em
|
||||||
|
padding: .5em 1em
|
||||||
|
border: 1px solid $soft-border-color
|
||||||
|
border-radius: 1em
|
||||||
|
|
||||||
|
input
|
||||||
|
margin: 0
|
||||||
|
|
||||||
|
&:has(:checked)
|
||||||
|
background: $module-bg-color
|
||||||
|
border-color: $module-border-color
|
||||||
|
|
|
||||||
|
|
@ -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!
|
|
||||||
|
|
@ -2,44 +2,36 @@ class AltStylesController < ApplicationController
|
||||||
before_action :support_staff_only, except: [:index]
|
before_action :support_staff_only, except: [:index]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@all_series_names = AltStyle.all_series_names
|
@all_alt_styles = AltStyle.includes(:species, :color)
|
||||||
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
|
|
||||||
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
|
@all_colors = @all_alt_styles.map(&:color).uniq.sort_by(&:name)
|
||||||
|
@all_species = @all_alt_styles.map(&:species).uniq.sort_by(&:name)
|
||||||
|
|
||||||
|
@all_series_names = @all_alt_styles.map(&:series_name).uniq.sort
|
||||||
|
@all_color_names = @all_colors.map(&:human_name)
|
||||||
|
@all_species_names = @all_species.map(&:human_name)
|
||||||
|
|
||||||
@series_name = params[:series]
|
@series_name = params[:series]
|
||||||
@color = find_color
|
@color = find_color
|
||||||
@species = find_species
|
@species = find_species
|
||||||
|
|
||||||
@alt_styles = AltStyle.includes(:color, :species, :swf_assets)
|
@alt_styles = @all_alt_styles.includes(:swf_assets).
|
||||||
|
by_creation_date.order(:color_id, :species_id, :series_name).
|
||||||
|
paginate(page: params[:page], per_page: 30)
|
||||||
@alt_styles.where!(series_name: @series_name) if @series_name.present?
|
@alt_styles.where!(series_name: @series_name) if @series_name.present?
|
||||||
@alt_styles.merge!(@color.alt_styles) if @color
|
@alt_styles.merge!(@color.alt_styles) if @color
|
||||||
@alt_styles.merge!(@species.alt_styles) if @species
|
@alt_styles.merge!(@species.alt_styles) if @species
|
||||||
|
|
||||||
|
# We're using the HTML5 image for our preview, so make sure we have all the
|
||||||
|
# manifests ready!
|
||||||
|
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
|
||||||
|
|
||||||
respond_to do |format|
|
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,7 +39,7 @@ 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
|
||||||
|
|
@ -71,8 +63,7 @@ class AltStylesController < ApplicationController
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def alt_style_params
|
def alt_style_params
|
||||||
params.require(:alt_style).
|
params.require(:alt_style).permit(:real_series_name, :thumbnail_url)
|
||||||
permit(:real_series_name, :real_full_name, :thumbnail_url)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_color
|
def find_color
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ require 'async/container'
|
||||||
class ApplicationController < ActionController::Base
|
class ApplicationController < ActionController::Base
|
||||||
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
|
||||||
|
|
||||||
|
|
@ -111,12 +111,10 @@ class ApplicationController < ActionController::Base
|
||||||
return_to || root_path
|
return_to || root_path
|
||||||
end
|
end
|
||||||
|
|
||||||
def support_staff?
|
|
||||||
current_user&.support_staff?
|
|
||||||
end
|
|
||||||
|
|
||||||
def support_staff_only
|
def support_staff_only
|
||||||
raise AccessDenied, "Support staff only" unless support_staff?
|
unless current_user&.support_staff?
|
||||||
|
raise AccessDenied, "Support staff only"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,8 @@ class ItemTradesController < ApplicationController
|
||||||
@item = Item.find params[:item_id]
|
@item = Item.find params[:item_id]
|
||||||
@type = type_from_params
|
@type = type_from_params
|
||||||
|
|
||||||
@item_trades = @item.visible_trades(
|
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
|
||||||
scope: ClosetHanger.includes(:user, :list).
|
user_is_active.order('users.last_trade_activity_at DESC').to_trades
|
||||||
order('users.last_trade_activity_at DESC'),
|
|
||||||
user: current_user,
|
|
||||||
remote_ip: request.remote_ip
|
|
||||||
)
|
|
||||||
@trades = @item_trades[@type]
|
@trades = @item_trades[@type]
|
||||||
|
|
||||||
if user_signed_in?
|
if user_signed_in?
|
||||||
|
|
|
||||||
|
|
@ -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,10 +79,7 @@ class ItemsController < ApplicationController
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html do
|
format.html do
|
||||||
@trades = @item.visible_trades(
|
@trades = @item.closet_hangers.trading.user_is_active.to_trades
|
||||||
user: current_user,
|
|
||||||
remote_ip: request.remote_ip
|
|
||||||
)
|
|
||||||
|
|
||||||
@contributors_with_counts = @item.contributors_with_counts
|
@contributors_with_counts = @item.contributors_with_counts
|
||||||
|
|
||||||
|
|
@ -101,23 +97,14 @@ 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).
|
||||||
includes(:species).merge(Species.alphabetical)
|
includes(:species).merge(Species.alphabetical)
|
||||||
end
|
end
|
||||||
|
|
||||||
format.json do
|
|
||||||
render json: @item.as_json(
|
|
||||||
include_trade_counts: true,
|
|
||||||
include_nc_trade_value: true,
|
|
||||||
current_user: current_user,
|
|
||||||
remote_ip: request.remote_ip
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
format.gif do
|
format.gif do
|
||||||
expires_in 1.month
|
expires_in 1.month
|
||||||
redirect_to @item.thumbnail_url, allow_other_host: true
|
redirect_to @item.thumbnail_url, allow_other_host: true
|
||||||
|
|
@ -125,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(",")
|
||||||
|
|
@ -156,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.
|
||||||
|
|
@ -192,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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
respond_to do |format|
|
render "outfits/edit", layout: false
|
||||||
format.html { render "outfits/edit", layout: false }
|
|
||||||
format.png do
|
|
||||||
@outfit = build_outfit_from_wardrobe_params
|
|
||||||
if @outfit.valid?
|
|
||||||
renderer = OutfitImageRenderer.new(@outfit)
|
|
||||||
png_data = renderer.render
|
|
||||||
|
|
||||||
if png_data
|
|
||||||
send_data png_data, type: "image/png", disposition: "inline",
|
|
||||||
filename: "outfit.png"
|
|
||||||
expires_in 1.day, public: true
|
|
||||||
else
|
|
||||||
head :not_found
|
|
||||||
end
|
|
||||||
else
|
|
||||||
head :bad_request
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def index
|
def index
|
||||||
|
|
@ -69,7 +50,10 @@ class OutfitsController < ApplicationController
|
||||||
@colors = Color.alphabetical
|
@colors = Color.alphabetical
|
||||||
@species = Species.alphabetical
|
@species = Species.alphabetical
|
||||||
|
|
||||||
newest_items = Item.newest.limit(18)
|
newest_items = Item.newest.
|
||||||
|
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index,
|
||||||
|
:is_manually_nc, :cached_compatible_body_ids)
|
||||||
|
.limit(18)
|
||||||
@newest_modeled_items, @newest_unmodeled_items =
|
@newest_modeled_items, @newest_unmodeled_items =
|
||||||
newest_items.partition(&:predicted_fully_modeled?)
|
newest_items.partition(&:predicted_fully_modeled?)
|
||||||
|
|
||||||
|
|
@ -136,40 +120,6 @@ class OutfitsController < ApplicationController
|
||||||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
def build_outfit_from_wardrobe_params
|
|
||||||
# Load items first
|
|
||||||
worn_item_ids = params[:objects] ? Array(params[:objects]).map(&:to_i) : []
|
|
||||||
closeted_item_ids = params[:closet] ? Array(params[:closet]).map(&:to_i) : []
|
|
||||||
|
|
||||||
worn_items = Item.where(id: worn_item_ids)
|
|
||||||
closeted_items = Item.where(id: closeted_item_ids)
|
|
||||||
|
|
||||||
# Build outfit with biology and items
|
|
||||||
outfit = Outfit.new(
|
|
||||||
worn_items: worn_items,
|
|
||||||
closeted_items: closeted_items,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Set biology from species, color, and pose params
|
|
||||||
if params[:species] && params[:color] && params[:pose]
|
|
||||||
outfit.biology = {
|
|
||||||
species_id: params[:species],
|
|
||||||
color_id: params[:color],
|
|
||||||
pose: params[:pose]
|
|
||||||
}
|
|
||||||
elsif params[:state]
|
|
||||||
# Alternative: use pet_state_id directly
|
|
||||||
outfit.biology = { pet_state_id: params[:state] }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Set alt style if provided
|
|
||||||
if params[:style]
|
|
||||||
outfit.alt_style_id = params[:style].to_i
|
|
||||||
end
|
|
||||||
|
|
||||||
outfit
|
|
||||||
end
|
|
||||||
|
|
||||||
def find_authorized_outfit
|
def find_authorized_outfit
|
||||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||||
@outfit = current_user.outfits.find(params[:id])
|
@outfit = current_user.outfits.find(params[:id])
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
class PetStatesController < ApplicationController
|
class PetStatesController < ApplicationController
|
||||||
before_action :support_staff_only
|
|
||||||
before_action :find_pet_state
|
before_action :find_pet_state
|
||||||
before_action :preload_assets
|
before_action :support_staff_only
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
end
|
end
|
||||||
|
|
@ -9,7 +8,7 @@ class PetStatesController < ApplicationController
|
||||||
def update
|
def update
|
||||||
if @pet_state.update(pet_state_params)
|
if @pet_state.update(pet_state_params)
|
||||||
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
||||||
redirect_to destination_after_save
|
redirect_to @pet_type
|
||||||
else
|
else
|
||||||
render action: :edit, status: :bad_request
|
render action: :edit, status: :bad_request
|
||||||
end
|
end
|
||||||
|
|
@ -18,39 +17,11 @@ class PetStatesController < ApplicationController
|
||||||
protected
|
protected
|
||||||
|
|
||||||
def find_pet_state
|
def find_pet_state
|
||||||
@pet_type = PetType.find_by_param!(params[:pet_type_name])
|
@pet_type = PetType.matching_name_param(params[:pet_type_name]).first!
|
||||||
@pet_state = @pet_type.pet_states.find(params[:id])
|
@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
|
end
|
||||||
|
|
||||||
def pet_state_params
|
def pet_state_params
|
||||||
params.require(:pet_state).permit(:pose, :glitched)
|
params.require(:pet_state).permit(:pose, :glitched)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -35,16 +35,6 @@ class PetTypesController < ApplicationController
|
||||||
if @selected_species && @selected_color && @pet_types.size == 1
|
if @selected_species && @selected_color && @pet_types.size == 1
|
||||||
redirect_to @pet_types.first
|
redirect_to @pet_types.first
|
||||||
end
|
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 {
|
format.json {
|
||||||
|
|
@ -80,7 +70,9 @@ class PetTypesController < ApplicationController
|
||||||
color_id: params[:color_id],
|
color_id: params[:color_id],
|
||||||
)
|
)
|
||||||
elsif params[:name]
|
elsif params[:name]
|
||||||
PetType.find_by_param!(params[:name])
|
color_name, _, species_name = params[:name].rpartition("-")
|
||||||
|
raise ActiveRecord::RecordNotFound if species_name.blank?
|
||||||
|
PetType.matching_name(color_name, species_name).first!
|
||||||
else
|
else
|
||||||
raise "expected params: species_id and color_id, or name"
|
raise "expected params: species_id and color_id, or name"
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
class PetsController < ApplicationController
|
class PetsController < ApplicationController
|
||||||
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
|
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
|
||||||
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
|
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
|
||||||
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
|
|
||||||
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
||||||
|
|
||||||
def load
|
def load
|
||||||
|
# Uncomment this to temporarily disable modeling for most users.
|
||||||
|
# return modeling_disabled unless user_signed_in? && current_user.admin?
|
||||||
|
|
||||||
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
||||||
@pet = Pet.load(params[:name])
|
@pet = Pet.load(params[:name])
|
||||||
points = contribute(current_user, @pet)
|
points = contribute(current_user, @pet)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ class SwfAssetsController < ApplicationController
|
||||||
# doing this can help make this header a *lot* shorter, which helps
|
# doing this can help make this header a *lot* shorter, which helps
|
||||||
# our nginx reverse proxy (and probably some clients) handle it. (For
|
# our nginx reverse proxy (and probably some clients) handle it. (For
|
||||||
# example, see asset `667993` for "Engulfed in Flames Effect".)
|
# example, see asset `667993` for "Engulfed in Flames Effect".)
|
||||||
origins: ["https://images.neopets.com"],
|
hosts: ["https://images.neopets.com"],
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -45,23 +45,14 @@ class SwfAssetsController < ApplicationController
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def src_list(*urls, origins: [])
|
def src_list(*urls, hosts: [])
|
||||||
clean_urls = urls.
|
urls.
|
||||||
# Ignore any `nil`s that might arise
|
# Ignore any `nil`s that might arise
|
||||||
filter(&:present?).
|
filter(&:present?).
|
||||||
# Parse the URL.
|
|
||||||
map { |url| Addressable::URI.parse(url) }.
|
|
||||||
# Remove query strings from URLs (they're invalid in CSPs)
|
# Remove query strings from URLs (they're invalid in CSPs)
|
||||||
each { |url| url.query = nil }.
|
map { |url| url.sub(/\?.*\z/, "") }.
|
||||||
# For the given `origins`, remove all their specific URLs, because
|
# For the given `hosts`, remove all their specific URLs, and just list
|
||||||
# we'll just include the entire origin anyway.
|
# the host itself.
|
||||||
reject { |url| origins.include?(url.origin) }.
|
reject { |url| hosts.any? { |h| url.start_with? h } } + hosts
|
||||||
# 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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,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(
|
||||||
|
|
@ -213,10 +217,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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -141,13 +141,6 @@ module ItemsHelper
|
||||||
def auction_genie_url_for(item)
|
def auction_genie_url_for(item)
|
||||||
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)
|
||||||
" (×#{count})".html_safe if count > 1
|
" (×#{count})".html_safe if count > 1
|
||||||
|
|
@ -158,7 +151,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 +160,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 +184,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 +192,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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,9 @@
|
||||||
module OutfitsHelper
|
module OutfitsHelper
|
||||||
|
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-10-21")
|
||||||
|
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
|
||||||
|
|
@ -65,27 +70,11 @@ module OutfitsHelper
|
||||||
text_field_tag 'name', nil, options
|
text_field_tag 'name', nil, options
|
||||||
end
|
end
|
||||||
|
|
||||||
def outfit_viewer(...)
|
def outfit_viewer(outfit=nil, pet_state: nil, **html_options)
|
||||||
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?
|
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
|
||||||
|
raise "outfit_viewer must have outfit or pet state" if outfit.nil?
|
||||||
|
|
||||||
if outfit.nil?
|
render partial: "outfit_viewer", locals: {outfit:, html_options:}
|
||||||
raise ArgumentError, "outfit viewer must have outfit or pet state"
|
|
||||||
end
|
|
||||||
|
|
||||||
{outfit:, preferred_image_format:, html_options:}
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,64 +0,0 @@
|
||||||
module SupportFormHelper
|
|
||||||
class SupportFormBuilder < ActionView::Helpers::FormBuilder
|
|
||||||
attr_reader :template
|
|
||||||
delegate :capture, :check_box_tag, :concat, :content_tag,
|
|
||||||
:hidden_field_tag, :params, :render,
|
|
||||||
to: :template, private: true
|
|
||||||
|
|
||||||
def errors
|
|
||||||
render partial: "application/support_form/errors", locals: {form: self}
|
|
||||||
end
|
|
||||||
|
|
||||||
def fields(&block)
|
|
||||||
content_tag(:ul, class: "fields", &block)
|
|
||||||
end
|
|
||||||
|
|
||||||
def field(**options, &block)
|
|
||||||
content_tag(:li, **options, &block)
|
|
||||||
end
|
|
||||||
|
|
||||||
def radio_fieldset(legend, **options, &block)
|
|
||||||
render partial: "application/support_form/radio_fieldset",
|
|
||||||
locals: {form: self, legend:, options:, content: capture(&block)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def radio_field(**options, &block)
|
|
||||||
content_tag(:li) do
|
|
||||||
content_tag(:label, **options, &block)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def radio_grid_fieldset(*args, &block)
|
|
||||||
radio_fieldset(*args, "data-type": "radio-grid", &block)
|
|
||||||
end
|
|
||||||
|
|
||||||
def thumbnail_input(method)
|
|
||||||
render partial: "application/support_form/thumbnail_input",
|
|
||||||
locals: {form: self, method:}
|
|
||||||
end
|
|
||||||
|
|
||||||
def actions(&block)
|
|
||||||
content_tag(:section, class: "actions", &block)
|
|
||||||
end
|
|
||||||
|
|
||||||
def go_to_next_field(after: nil, **options, &block)
|
|
||||||
content_tag(:label, class: "go-to-next", **options) do
|
|
||||||
concat hidden_field_tag(:after, after) if after
|
|
||||||
yield
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def go_to_next_check_box(value)
|
|
||||||
check_box_tag "next", value, checked: params[:next] == value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def support_form_with(**options, &block)
|
|
||||||
form_with(
|
|
||||||
builder: SupportFormBuilder,
|
|
||||||
**options,
|
|
||||||
class: ["support-form", options[:class]],
|
|
||||||
&block
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -1,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
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -283,10 +283,7 @@ const PosePickerButton = React.forwardRef(
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
|
|
||||||
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
|
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
|
||||||
const label =
|
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
|
||||||
altStyle != null
|
|
||||||
? altStyle.seriesMainName.split(/\s+/)[0]
|
|
||||||
: getLabel(pose);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClassNames>
|
<ClassNames>
|
||||||
|
|
@ -339,9 +336,9 @@ const PosePickerButton = React.forwardRef(
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<EmojiImage src={icon} alt="Style" />
|
<EmojiImage src={icon} alt="Style" />
|
||||||
<Box overflow="hidden" textOverflow="ellipsis" marginX=".5em">
|
<Box width=".5em" />
|
||||||
{label}
|
{label}
|
||||||
</Box>
|
<Box width=".5em" />
|
||||||
<ChevronDownIcon />
|
<ChevronDownIcon />
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
@ -726,13 +723,6 @@ function StyleOption({ altStyle, checked, onChange, inputRef }) {
|
||||||
checked={checked}
|
checked={checked}
|
||||||
onChange={(e) => onChange(altStyle.id)}
|
onChange={(e) => onChange(altStyle.id)}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
// HACK: Without this, the page extends super long. I think this is
|
|
||||||
// because the VisuallyHidden just uses `position: absolute`,
|
|
||||||
// which makes it float invisibly *beyond* the scrolling
|
|
||||||
// container it's in, extending the page? But if we put it at
|
|
||||||
// the top-left corner instead, it doesn't.
|
|
||||||
left="0"
|
|
||||||
top="0"
|
|
||||||
/>
|
/>
|
||||||
<Flex
|
<Flex
|
||||||
alignItems="center"
|
alignItems="center"
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { Box, Flex } from "@chakra-ui/react";
|
import { Box, Flex } from "@chakra-ui/react";
|
||||||
import SearchToolbar from "./SearchToolbar";
|
import SearchToolbar from "./SearchToolbar";
|
||||||
import { ErrorBoundary, TestErrorSender, useLocalStorage } from "../util";
|
import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
|
||||||
import PaginationToolbar from "../components/PaginationToolbar";
|
import PaginationToolbar from "../components/PaginationToolbar";
|
||||||
import { useSearchResults } from "./useSearchResults";
|
import { useSearchResults } from "./useSearchResults";
|
||||||
|
|
||||||
|
|
@ -33,7 +34,7 @@ function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||||
<TestErrorSender />
|
<TestErrorSender />
|
||||||
<Box>
|
<Box>
|
||||||
<Box paddingX="4" paddingY="4">
|
<Box paddingX="4" paddingY="4">
|
||||||
|
|
@ -72,7 +73,7 @@ function SearchFooter({ searchQuery, onChangeSearchQuery, outfitState }) {
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</ErrorBoundary>
|
</Sentry.ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,10 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import {
|
import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
|
||||||
Alert,
|
|
||||||
AlertIcon,
|
|
||||||
Box,
|
|
||||||
Text,
|
|
||||||
useColorModeValue,
|
|
||||||
VisuallyHidden,
|
|
||||||
} from "@chakra-ui/react";
|
|
||||||
|
|
||||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||||
import PaginationToolbar from "../components/PaginationToolbar";
|
import PaginationToolbar from "../components/PaginationToolbar";
|
||||||
import { useSearchResults } from "./useSearchResults";
|
import { useSearchResults } from "./useSearchResults";
|
||||||
import { MajorErrorMessage } from "../util";
|
import { MajorErrorMessage } from "../util";
|
||||||
import { useAltStylesForSpecies } from "../loaders/alt-styles";
|
|
||||||
|
|
||||||
export const SEARCH_PER_PAGE = 30;
|
export const SEARCH_PER_PAGE = 30;
|
||||||
|
|
||||||
|
|
@ -169,7 +161,6 @@ function SearchResults({
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<SearchNCStylesHint query={query} outfitState={outfitState} />
|
|
||||||
<ItemListContainer paddingX="4" paddingBottom="2">
|
<ItemListContainer paddingX="4" paddingBottom="2">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<SearchResultItem
|
<SearchResultItem
|
||||||
|
|
@ -271,55 +262,6 @@ function SearchResultItem({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SearchNCStylesHint({ query, outfitState }) {
|
|
||||||
const { data: altStyles } = useAltStylesForSpecies(outfitState.speciesId);
|
|
||||||
|
|
||||||
const message = getSearchNCStylesMessage(query, altStyles);
|
|
||||||
if (!message) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box paddingX="4" paddingY="2">
|
|
||||||
<Alert status="info" variant="left-accent" fontSize="sm" color="blue.900">
|
|
||||||
<AlertIcon />
|
|
||||||
{message}
|
|
||||||
</Alert>
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSearchNCStylesMessage(query, altStyles) {
|
|
||||||
const seriesMainNames = [
|
|
||||||
...new Set((altStyles ?? []).map((as) => as.seriesMainName)),
|
|
||||||
];
|
|
||||||
const queryWords = query.value.toLowerCase().split(/\s+/);
|
|
||||||
|
|
||||||
if (queryWords.includes("token")) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
If you're looking for NC Styles, check the emotion picker below the pet
|
|
||||||
preview!
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// NOTE: This won't work on multi-word series main names, of which there
|
|
||||||
// are currently none. (Some *series names* like Prismatics are
|
|
||||||
// multi-word, but their *main* name is not.)
|
|
||||||
const seriesWord = seriesMainNames.find((n) =>
|
|
||||||
queryWords.includes(n.toLowerCase()),
|
|
||||||
);
|
|
||||||
if (seriesWord != null) {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
If you're looking for {seriesWord} NC Styles, check the emotion picker
|
|
||||||
below the pet preview!
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* serializeQuery stably converts a search query object to a string, for easier
|
* serializeQuery stably converts a search query object to a string, for easier
|
||||||
* JS comparison.
|
* JS comparison.
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,11 @@ import React from "react";
|
||||||
import { Box, Center, DarkMode } from "@chakra-ui/react";
|
import { Box, Center, DarkMode } from "@chakra-ui/react";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
|
|
||||||
import OutfitThumbnail from "../components/OutfitThumbnail";
|
import OutfitThumbnail from "../components/OutfitThumbnail";
|
||||||
import { useOutfitPreview } from "../components/OutfitPreview";
|
import { useOutfitPreview } from "../components/OutfitPreview";
|
||||||
import { loadable, ErrorBoundary, TestErrorSender } from "../util";
|
import { loadable, MajorErrorMessage, TestErrorSender } from "../util";
|
||||||
|
|
||||||
const OutfitControls = loadable(() => import("./OutfitControls"));
|
const OutfitControls = loadable(() => import("./OutfitControls"));
|
||||||
|
|
||||||
|
|
@ -32,7 +33,7 @@ function WardrobePreviewAndControls({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||||
<TestErrorSender />
|
<TestErrorSender />
|
||||||
<Center position="absolute" top="0" bottom="0" left="0" right="0">
|
<Center position="absolute" top="0" bottom="0" left="0" right="0">
|
||||||
<DarkMode>{preview}</DarkMode>
|
<DarkMode>{preview}</DarkMode>
|
||||||
|
|
@ -45,7 +46,7 @@ function WardrobePreviewAndControls({
|
||||||
appearance={appearance}
|
appearance={appearance}
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</ErrorBoundary>
|
</Sentry.ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function normalizeAltStyle(altStyleData) {
|
||||||
speciesId: String(altStyleData.species_id),
|
speciesId: String(altStyleData.species_id),
|
||||||
colorId: String(altStyleData.color_id),
|
colorId: String(altStyleData.color_id),
|
||||||
bodyId: String(altStyleData.body_id),
|
bodyId: String(altStyleData.body_id),
|
||||||
seriesMainName: altStyleData.series_main_name,
|
seriesName: altStyleData.series_name,
|
||||||
adjectiveName: altStyleData.adjective_name,
|
adjectiveName: altStyleData.adjective_name,
|
||||||
thumbnailUrl: altStyleData.thumbnail_url,
|
thumbnailUrl: altStyleData.thumbnail_url,
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import {
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import loadableLibrary from "@loadable/component";
|
import loadableLibrary from "@loadable/component";
|
||||||
|
import * as Sentry from "@sentry/react";
|
||||||
import { WarningIcon } from "@chakra-ui/icons";
|
import { WarningIcon } from "@chakra-ui/icons";
|
||||||
|
|
||||||
import { buildImpress2020Url } from "./impress-2020-config";
|
import { buildImpress2020Url } from "./impress-2020-config";
|
||||||
|
|
@ -413,17 +414,14 @@ export function loadable(load, options) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* logAndCapture will print an error to the console.
|
* logAndCapture will print an error to the console, and send it to Sentry.
|
||||||
*
|
|
||||||
* NOTE: Previously, this would log to Sentry, but we actually just don't log
|
|
||||||
* JS errors anymore, because we haven't done in-depth JS debugging in a
|
|
||||||
* while.
|
|
||||||
*
|
*
|
||||||
* This is useful when there's a graceful recovery path, but it's still a
|
* This is useful when there's a graceful recovery path, but it's still a
|
||||||
* genuinely unexpected error worth logging.
|
* genuinely unexpected error worth logging.
|
||||||
*/
|
*/
|
||||||
export function logAndCapture(e) {
|
export function logAndCapture(e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
|
Sentry.captureException(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getGraphQLErrorMessage(error) {
|
export function getGraphQLErrorMessage(error) {
|
||||||
|
|
@ -477,8 +475,8 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
|
||||||
<Box gridArea="description" marginBottom="2">
|
<Box gridArea="description" marginBottom="2">
|
||||||
{variant === "unexpected" && (
|
{variant === "unexpected" && (
|
||||||
<>
|
<>
|
||||||
There was an error displaying this page. If it keeps happening,
|
There was an error displaying this page. I'll get info about it
|
||||||
you can tell me more at{" "}
|
automatically, but you can tell me more at{" "}
|
||||||
<Link href="mailto:matchu@openneo.net" color="green.400">
|
<Link href="mailto:matchu@openneo.net" color="green.400">
|
||||||
matchu@openneo.net
|
matchu@openneo.net
|
||||||
</Link>
|
</Link>
|
||||||
|
|
@ -525,29 +523,10 @@ export function MajorErrorMessage({ error = null, variant = "unexpected" }) {
|
||||||
|
|
||||||
export function TestErrorSender() {
|
export function TestErrorSender() {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (window.location.href.includes("send-test-error")) {
|
if (window.location.href.includes("send-test-error-for-sentry")) {
|
||||||
throw new Error("Test error for debugging <ErrorBoundary>s");
|
throw new Error("Test error for Sentry");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ErrorBoundary extends React.Component {
|
|
||||||
constructor(props) {
|
|
||||||
super(props);
|
|
||||||
this.state = { error: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
static getDerivedStateFromError(error) {
|
|
||||||
return { error };
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
if (this.state.error != null) {
|
|
||||||
return <MajorErrorMessage error={this.state.error} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.props.children;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -9,46 +9,18 @@ class AltStyle < ApplicationRecord
|
||||||
has_many :contributions, as: :contributed, inverse_of: :contributed
|
has_many :contributions, as: :contributed, inverse_of: :contributed
|
||||||
|
|
||||||
validates :body_id, presence: true
|
validates :body_id, presence: true
|
||||||
validates :full_name, presence: true, allow_nil: true
|
|
||||||
validates :series_name, presence: true, allow_nil: true
|
validates :series_name, presence: true, allow_nil: true
|
||||||
validates :thumbnail_url, presence: true
|
validates :thumbnail_url, presence: true
|
||||||
|
|
||||||
before_validation :infer_thumbnail_url, unless: :thumbnail_url?
|
before_validation :infer_thumbnail_url, unless: :thumbnail_url?
|
||||||
|
|
||||||
fallback_for(:full_name) { "#{series_name} #{pet_name}" }
|
|
||||||
fallback_for(:series_name) { AltStyle.placeholder_name }
|
|
||||||
|
|
||||||
scope :matching_name, ->(series_name, color_name, species_name) {
|
scope :matching_name, ->(series_name, color_name, species_name) {
|
||||||
color = Color.find_by_name!(color_name)
|
color = Color.find_by_name!(color_name)
|
||||||
species = Species.find_by_name!(species_name)
|
species = Species.find_by_name!(species_name)
|
||||||
where(series_name:, color_id: color.id, species_id: species.id)
|
where(series_name:, color_id: color.id, species_id: species.id)
|
||||||
}
|
}
|
||||||
scope :by_creation_date, -> {
|
scope :by_creation_date, -> {
|
||||||
# HACK: Setting up named time zones in MySQL takes effort, so we assume
|
order("DATE(created_at) DESC")
|
||||||
# it's not Daylight Savings. This will produce slightly incorrect
|
|
||||||
# sorting when it *is* Daylight Savings, and records happen to be
|
|
||||||
# created around midnight.
|
|
||||||
order(Arel.sql("DATE(CONVERT_TZ(created_at, '+00:00', '-08:00')) DESC"))
|
|
||||||
}
|
|
||||||
scope :by_series_main_name, -> {
|
|
||||||
# The main part of the series name, like "Nostalgic".
|
|
||||||
# If there's no colon, uses the whole string.
|
|
||||||
order(Arel.sql("SUBSTRING_INDEX(series_name, ': ', -1)"))
|
|
||||||
}
|
|
||||||
scope :by_series_variant_name, -> {
|
|
||||||
# The variant part of the series name, like "Prismatic Cyan".
|
|
||||||
# If there's no colon, uses an empty string.
|
|
||||||
order(Arel.sql("SUBSTRING(series_name, 1, LOCATE(': ', series_name) - 1)"))
|
|
||||||
}
|
|
||||||
scope :by_color_name, -> {
|
|
||||||
joins(:color).order(Color.arel_table[:name])
|
|
||||||
}
|
|
||||||
scope :by_name_grouped, -> {
|
|
||||||
# Sort by the color name, then the main part of the series name, then the
|
|
||||||
# variant part of the series name. This way, all the, say, Christmas colors
|
|
||||||
# and their Prismatic variants will be together, including both Festive and
|
|
||||||
# Nostalgic cases.
|
|
||||||
by_color_name.by_series_main_name.by_series_variant_name
|
|
||||||
}
|
}
|
||||||
scope :unlabeled, -> { where(series_name: nil) }
|
scope :unlabeled, -> { where(series_name: nil) }
|
||||||
scope :newest, -> { order(created_at: :desc) }
|
scope :newest, -> { order(created_at: :desc) }
|
||||||
|
|
@ -60,23 +32,41 @@ class AltStyle < ApplicationRecord
|
||||||
|
|
||||||
alias_method :name, :pet_name
|
alias_method :name, :pet_name
|
||||||
|
|
||||||
def series_main_name
|
# If the series_name hasn't yet been set manually by support staff, show the
|
||||||
series_name.split(': ').last
|
# string "<New?>" instead. But it won't be searchable by that string—that is,
|
||||||
|
# `fits:<New?>-faerie-draik` intentionally will not work, and the canonical
|
||||||
|
# filter name will be `fits:alt-style-IDNUMBER`, instead.
|
||||||
|
def series_name
|
||||||
|
real_series_name || "<New?>"
|
||||||
end
|
end
|
||||||
|
|
||||||
def series_variant_name
|
def real_series_name=(new_series_name)
|
||||||
series_name.split(': ').first
|
self[:series_name] = new_series_name
|
||||||
|
end
|
||||||
|
|
||||||
|
def real_series_name
|
||||||
|
self[:series_name]
|
||||||
|
end
|
||||||
|
|
||||||
|
# You can use this to check whether `series_name` is returning the actual
|
||||||
|
# value or its placeholder value.
|
||||||
|
def real_series_name?
|
||||||
|
real_series_name.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Returns the full name, with the species removed from the end (if present).
|
|
||||||
def adjective_name
|
def adjective_name
|
||||||
full_name.sub(/\s+#{Regexp.escape(species.name)}\Z/i, "")
|
"#{series_name} #{color.human_name}"
|
||||||
|
end
|
||||||
|
|
||||||
|
def full_name
|
||||||
|
"#{series_name} #{name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
EMPTY_IMAGE_URL = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="
|
|
||||||
def preview_image_url
|
def preview_image_url
|
||||||
# Use the image URL for the first asset. Or, fall back to an empty image.
|
swf_asset = swf_assets.first
|
||||||
swf_assets.first&.image_url || EMPTY_IMAGE_URL
|
return nil if swf_asset.nil?
|
||||||
|
|
||||||
|
swf_asset.image_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of items, return how they look on this alt style.
|
# Given a list of items, return how they look on this alt style.
|
||||||
|
|
@ -84,6 +74,15 @@ class AltStyle < ApplicationRecord
|
||||||
Item.appearances_for(items, self, ...)
|
Item.appearances_for(items, self, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def biology=(biology)
|
||||||
|
# TODO: This is very similar to what `PetState` does, but like… much much
|
||||||
|
# more compact? Idk if I'm missing something, or if I was just that much
|
||||||
|
# more clueless back when I wrote it, lol 😅
|
||||||
|
self.swf_assets = biology.values.map do |asset_data|
|
||||||
|
SwfAsset.from_biology_data(self.body_id, asset_data)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# At time of writing, most batches of Alt Styles thumbnails used a simple
|
# At time of writing, most batches of Alt Styles thumbnails used a simple
|
||||||
# pattern for the item thumbnail URL, but that's not always the case anymore.
|
# pattern for the item thumbnail URL, but that's not always the case anymore.
|
||||||
# For now, let's keep using this format as the default value when creating a
|
# For now, let's keep using this format as the default value when creating a
|
||||||
|
|
@ -104,28 +103,6 @@ class AltStyle < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def real_thumbnail_url?
|
|
||||||
thumbnail_url != DEFAULT_THUMBNAIL_URL
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.placeholder_name
|
|
||||||
"<New?>"
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.all_series_names
|
|
||||||
distinct.where.not(series_name: nil).
|
|
||||||
by_series_main_name.by_series_variant_name.
|
|
||||||
pluck(:series_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.all_supported_colors
|
|
||||||
Color.find(distinct.pluck(:color_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.all_supported_species
|
|
||||||
Species.find(distinct.pluck(:species_id))
|
|
||||||
end
|
|
||||||
|
|
||||||
# For convenience in the console!
|
# For convenience in the console!
|
||||||
def self.find_by_name(color_name, species_name)
|
def self.find_by_name(color_name, species_name)
|
||||||
color = Color.find_by_name(color_name)
|
color = Color.find_by_name(color_name)
|
||||||
|
|
|
||||||
|
|
@ -1,35 +1,3 @@
|
||||||
class ApplicationRecord < ActiveRecord::Base
|
class ApplicationRecord < ActiveRecord::Base
|
||||||
self.abstract_class = true
|
self.abstract_class = true
|
||||||
|
|
||||||
# When the given attribute's value is nil, return the given block's value
|
|
||||||
# instead. This is useful for fields where there's a sensible derived value
|
|
||||||
# to use as the default for display purposes, but it's distinctly *not* the
|
|
||||||
# true value, and should be recognizable as such.
|
|
||||||
#
|
|
||||||
# This also creates methods `real_<attr>`, `real_<attr>=`, and `real_<attr>?`,
|
|
||||||
# to work with the actual attribute when necessary.
|
|
||||||
#
|
|
||||||
# It also creates `fallback_<attr>`, to find what the fallback value *would*
|
|
||||||
# be if the attribute's value were nil.
|
|
||||||
def self.fallback_for(attribute_name, &block)
|
|
||||||
define_method attribute_name do
|
|
||||||
read_attribute(attribute_name) || instance_eval(&block)
|
|
||||||
end
|
|
||||||
|
|
||||||
define_method "real_#{attribute_name}" do
|
|
||||||
read_attribute(attribute_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
define_method "real_#{attribute_name}?" do
|
|
||||||
read_attribute(attribute_name).present?
|
|
||||||
end
|
|
||||||
|
|
||||||
define_method "real_#{attribute_name}=" do |new_value|
|
|
||||||
write_attribute(attribute_name, new_value)
|
|
||||||
end
|
|
||||||
|
|
||||||
define_method "fallback_#{attribute_name}" do
|
|
||||||
instance_eval(&block)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
@ -156,7 +156,7 @@ class ClosetHanger < ApplicationRecord
|
||||||
#
|
#
|
||||||
# We don't preload anything here - if you want user names or list names, you
|
# We don't preload anything here - if you want user names or list names, you
|
||||||
# should `includes` them in the hanger scope first, to avoid extra queries!
|
# should `includes` them in the hanger scope first, to avoid extra queries!
|
||||||
def self.to_trades(current_user, remote_ip)
|
def self.to_trades
|
||||||
# Let's ensure that the `trading` filter is applied, to avoid data leaks.
|
# Let's ensure that the `trading` filter is applied, to avoid data leaks.
|
||||||
# (I still recommend doing it at the call site too for clarity, though!)
|
# (I still recommend doing it at the call site too for clarity, though!)
|
||||||
all_trading_hangers = trading.to_a
|
all_trading_hangers = trading.to_a
|
||||||
|
|
@ -164,20 +164,17 @@ class ClosetHanger < ApplicationRecord
|
||||||
owned_hangers = all_trading_hangers.filter(&:owned?)
|
owned_hangers = all_trading_hangers.filter(&:owned?)
|
||||||
wanted_hangers = all_trading_hangers.filter(&:wanted?)
|
wanted_hangers = all_trading_hangers.filter(&:wanted?)
|
||||||
|
|
||||||
# Group first into offering vs seeking, then by user. Only include trades
|
# Group first into offering vs seeking, then by user.
|
||||||
# visible to the current user.
|
|
||||||
offering, seeking = [owned_hangers, wanted_hangers].map do |hangers|
|
offering, seeking = [owned_hangers, wanted_hangers].map do |hangers|
|
||||||
hangers.group_by(&:user_id).
|
hangers.group_by(&:user_id).map do |user_id, user_hangers|
|
||||||
map { |user_id, user_hangers| Trade.new(user_id, user_hangers) }.
|
Trade.new(user_id, user_hangers)
|
||||||
filter { |trade| trade.visible_to?(current_user, remote_ip) }
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
{offering: offering, seeking: seeking}
|
{offering: offering, seeking: seeking}
|
||||||
end
|
end
|
||||||
|
|
||||||
Trade = Struct.new('Trade', :user_id, :hangers) do
|
Trade = Struct.new('Trade', :user_id, :hangers) do
|
||||||
delegate :visible_to?, to: :user
|
|
||||||
|
|
||||||
def user
|
def user
|
||||||
# Take advantage of `includes(:user)` on the hangers, if applied.
|
# Take advantage of `includes(:user)` on the hangers, if applied.
|
||||||
hangers.first.user
|
hangers.first.user
|
||||||
|
|
@ -186,10 +183,6 @@ class ClosetHanger < ApplicationRecord
|
||||||
def lists
|
def lists
|
||||||
hangers.map(&:list).filter(&:present?)
|
hangers.map(&:list).filter(&:present?)
|
||||||
end
|
end
|
||||||
|
|
||||||
def last_activity_at
|
|
||||||
user.last_trade_activity_at
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
protected
|
protected
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,6 @@ class Color < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_param
|
|
||||||
name? ? human_name : id.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
def example_pet_type(preferred_species: nil)
|
def example_pet_type(preferred_species: nil)
|
||||||
preferred_species ||= Species.first
|
preferred_species ||= Species.first
|
||||||
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
|
pet_types.order([Arel.sql("species_id = ? DESC"), preferred_species.id],
|
||||||
|
|
@ -40,8 +36,4 @@ class Color < ApplicationRecord
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.param_to_id(param)
|
|
||||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
|
require "async"
|
||||||
|
require "async/barrier"
|
||||||
|
|
||||||
class Item < ApplicationRecord
|
class Item < ApplicationRecord
|
||||||
include PrettyParam
|
include PrettyParam
|
||||||
include Item::Dyeworks
|
include Item::Dyeworks
|
||||||
|
|
@ -20,21 +23,13 @@ class Item < ApplicationRecord
|
||||||
has_many :dyeworks_variants, class_name: "Item",
|
has_many :dyeworks_variants, class_name: "Item",
|
||||||
inverse_of: :dyeworks_base_item
|
inverse_of: :dyeworks_base_item
|
||||||
|
|
||||||
# We require a name field. A number of other fields must be *specified*: they
|
validates_presence_of :name, :description, :thumbnail_url, :rarity, :price,
|
||||||
# can't be nil, to help ensure we aren't forgetting any fields when importing
|
:zones_restrict
|
||||||
# items. But sometimes they happen to be blank (e.g. when TNT leaves an item
|
|
||||||
# description empty, oops), in which case we want to accept that reality!
|
|
||||||
validates_presence_of :name
|
|
||||||
validates :description, :thumbnail_url, :rarity, :price, :zones_restrict,
|
|
||||||
exclusion: {in: [nil], message: "must be specified"}
|
|
||||||
|
|
||||||
after_save :update_cached_fields,
|
|
||||||
if: :modeling_status_hint_previously_changed?
|
|
||||||
|
|
||||||
attr_writer :current_body_id, :owned, :wanted
|
attr_writer :current_body_id, :owned, :wanted
|
||||||
|
|
||||||
NCRarities = [0, 500]
|
NCRarities = [0, 500]
|
||||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set'
|
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
||||||
|
|
||||||
scope :newest, -> {
|
scope :newest, -> {
|
||||||
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
||||||
|
|
@ -70,12 +65,6 @@ class Item < ApplicationRecord
|
||||||
where('description NOT LIKE ?',
|
where('description NOT LIKE ?',
|
||||||
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
|
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
|
||||||
}
|
}
|
||||||
scope :is_modeled, -> {
|
|
||||||
where(cached_predicted_fully_modeled: true)
|
|
||||||
}
|
|
||||||
scope :is_not_modeled, -> {
|
|
||||||
where(cached_predicted_fully_modeled: false)
|
|
||||||
}
|
|
||||||
scope :occupies, ->(zone_label) {
|
scope :occupies, ->(zone_label) {
|
||||||
Zone.matching_label(zone_label).
|
Zone.matching_label(zone_label).
|
||||||
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
|
map { |z| occupies_zone_id(z.id) }.reduce(none, &:or)
|
||||||
|
|
@ -118,11 +107,12 @@ class Item < ApplicationRecord
|
||||||
return @nc_trade_value if @nc_trade_value_loaded
|
return @nc_trade_value if @nc_trade_value_loaded
|
||||||
|
|
||||||
@nc_trade_value = begin
|
@nc_trade_value = begin
|
||||||
LebronNCValues.find_by_name(name)
|
Rails.logger.debug "Item #{id} (#{name}) <lookup>"
|
||||||
rescue LebronNCValues::NotFound => error
|
OwlsValueGuide.find_by_name(name)
|
||||||
|
rescue OwlsValueGuide::NotFound => error
|
||||||
Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
|
Rails.logger.debug("No NC trade value listed for #{name} (#{id})")
|
||||||
nil
|
nil
|
||||||
rescue LebronNCValues::NetworkError => error
|
rescue OwlsValueGuide::NetworkError => error
|
||||||
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
|
Rails.logger.error("Couldn't load nc_trade_value: #{error.full_message}")
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
@ -162,7 +152,7 @@ class Item < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def pb?
|
def pb?
|
||||||
I18n.with_locale(:en) { self.description.include?(PAINTBRUSH_SET_DESCRIPTION) }
|
I18n.with_locale(:en) { self.description == PAINTBRUSH_SET_DESCRIPTION }
|
||||||
end
|
end
|
||||||
|
|
||||||
def np?
|
def np?
|
||||||
|
|
@ -273,19 +263,8 @@ class Item < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def update_cached_fields
|
def update_cached_fields
|
||||||
# First, clear out some cached instance variables we use for performance,
|
|
||||||
# to ensure we recompute the latest values.
|
|
||||||
@predicted_body_ids = nil
|
|
||||||
@predicted_missing_body_ids = nil
|
|
||||||
|
|
||||||
# We also need to reload our associations, so they include any new records.
|
|
||||||
swf_assets.reload
|
|
||||||
|
|
||||||
# Finally, compute and save our cached fields.
|
|
||||||
self.cached_occupied_zone_ids = occupied_zone_ids
|
self.cached_occupied_zone_ids = occupied_zone_ids
|
||||||
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
|
self.cached_compatible_body_ids = compatible_body_ids(use_cached: false)
|
||||||
self.cached_predicted_fully_modeled =
|
|
||||||
predicted_fully_modeled?(use_cached: false)
|
|
||||||
self.save!
|
self.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -299,16 +278,8 @@ class Item < ApplicationRecord
|
||||||
write_attribute('species_support_ids', replacement)
|
write_attribute('species_support_ids', replacement)
|
||||||
end
|
end
|
||||||
|
|
||||||
def modeling_hinted_done?
|
|
||||||
modeling_status_hint == "done" || modeling_status_hint == "glitchy"
|
|
||||||
end
|
|
||||||
|
|
||||||
def predicted_body_ids
|
def predicted_body_ids
|
||||||
@predicted_body_ids ||= if modeling_hinted_done?
|
@predicted_body_ids ||= if compatible_body_ids.include?(0)
|
||||||
# If we've manually set this item to no longer report as needing modeling,
|
|
||||||
# predict that the current bodies are all of the compatible bodies.
|
|
||||||
compatible_body_ids
|
|
||||||
elsif compatible_body_ids.include?(0)
|
|
||||||
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This
|
# Oh, look, it's already known to fit everybody! Sweet. We're done. (This
|
||||||
# isn't folded into the case below, in case this item somehow got a
|
# isn't folded into the case below, in case this item somehow got a
|
||||||
# body-specific and non-body-specific asset. In all the cases I've seen
|
# body-specific and non-body-specific asset. In all the cases I've seen
|
||||||
|
|
@ -320,18 +291,7 @@ class Item < ApplicationRecord
|
||||||
# This might just be a species-specific item. Let's be conservative in
|
# This might just be a species-specific item. Let's be conservative in
|
||||||
# our prediction, though we'll revise it if we see another body ID.
|
# our prediction, though we'll revise it if we see another body ID.
|
||||||
compatible_body_ids
|
compatible_body_ids
|
||||||
elsif compatible_body_ids.size == 0
|
|
||||||
# If somehow we have this item, but not any modeling data for it (weird!),
|
|
||||||
# consider it to fit all standard pet types until shown otherwise.
|
|
||||||
PetType.basic.released_before(released_at_estimate).
|
|
||||||
distinct.pluck(:body_id).sort
|
|
||||||
else
|
else
|
||||||
# The core challenge: distinguish "item for Maraquan pets" from "item that
|
|
||||||
# happens to fit the Maraquan Mynci" (which shares a body with basic Myncis).
|
|
||||||
# We use a general rule: a color is "modelable" only if it has at least one
|
|
||||||
# *unique* body (not shared with other colors). This filters out false
|
|
||||||
# positives while remaining self-sustaining.
|
|
||||||
|
|
||||||
# First, find our compatible pet types, then pair each body ID with its
|
# First, find our compatible pet types, then pair each body ID with its
|
||||||
# color. (As an optimization, we omit standard colors, other than the
|
# color. (As an optimization, we omit standard colors, other than the
|
||||||
# basic colors. We also flatten the basic colors into the single color
|
# basic colors. We also flatten the basic colors into the single color
|
||||||
|
|
@ -342,7 +302,6 @@ class Item < ApplicationRecord
|
||||||
Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id)
|
Arel.sql("IF(colors.basic, 'basic', colors.id)"), :body_id)
|
||||||
|
|
||||||
# Group colors by body, to help us find bodies unique to certain colors.
|
# Group colors by body, to help us find bodies unique to certain colors.
|
||||||
# Example: {93 => ["basic"], 112 => ["maraquan"], 47 => ["basic", "maraquan"]}
|
|
||||||
compatible_color_ids_by_body_id = {}.tap do |h|
|
compatible_color_ids_by_body_id = {}.tap do |h|
|
||||||
compatible_pairs.each do |(color_id, body_id)|
|
compatible_pairs.each do |(color_id, body_id)|
|
||||||
h[body_id] ||= []
|
h[body_id] ||= []
|
||||||
|
|
@ -350,34 +309,25 @@ class Item < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Find non-basic colors with at least one unique compatible body (size == 1).
|
# Find non-basic colors with at least one unique compatible body. (This
|
||||||
# This means we'll predict "all Maraquan pets" only if the item fits a
|
# means we'll ignore e.g. the Maraquan Mynci, which has the same body as
|
||||||
# Maraquan pet with a unique body (like the Maraquan Acara), not if it only
|
# the Blue Mynci, as not indicating Maraquan compatibility in general.)
|
||||||
# fits the Maraquan Mynci (which shares its body with basic Myncis).
|
|
||||||
modelable_color_ids =
|
modelable_color_ids =
|
||||||
compatible_color_ids_by_body_id.
|
compatible_color_ids_by_body_id.
|
||||||
filter { |k, v| v.size == 1 && v.first != "basic" }.
|
filter { |k, v| v.size == 1 && v.first != "basic" }.
|
||||||
values.map(&:first).uniq
|
values.map(&:first).uniq
|
||||||
|
|
||||||
# We can model on basic pets if we find a basic body that doesn't also fit
|
# We can model on basic pets (perhaps in addition to the above) if we
|
||||||
# any modelable colors. This way, if an item fits both basic Mynci and
|
# find at least one compatible basic body that doesn't *also* fit any of
|
||||||
# Maraquan Acara (a modelable color), we treat it as "Maraquan item" not
|
# the modelable colors we identified above.
|
||||||
# "basic item", avoiding false predictions for all basic pets.
|
|
||||||
basic_is_modelable =
|
basic_is_modelable =
|
||||||
compatible_color_ids_by_body_id.values.
|
compatible_color_ids_by_body_id.values.
|
||||||
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
|
any? { |v| v.include?("basic") && (v & modelable_color_ids).empty? }
|
||||||
|
|
||||||
# Filter to pet types that match the colors that seem compatible.
|
# Get all body IDs for the colors we decided are modelable.
|
||||||
predicted_pet_types =
|
predicted_pet_types =
|
||||||
(basic_is_modelable ? PetType.basic : PetType.none).
|
(basic_is_modelable ? PetType.basic : PetType.none).
|
||||||
or(PetType.where(color_id: modelable_color_ids))
|
or(PetType.where(color_id: modelable_color_ids))
|
||||||
|
|
||||||
# Only include species that were released when this item was. If we don't
|
|
||||||
# know our creation date (we don't have it for some old records), assume
|
|
||||||
# it's pretty old.
|
|
||||||
predicted_pet_types.merge! PetType.released_before(released_at_estimate)
|
|
||||||
|
|
||||||
# Get all body IDs for the pet types we decided are modelable.
|
|
||||||
predicted_pet_types.distinct.pluck(:body_id).sort
|
predicted_pet_types.distinct.pluck(:body_id).sort
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -429,8 +379,7 @@ class Item < ApplicationRecord
|
||||||
body_ids_by_species_by_color
|
body_ids_by_species_by_color
|
||||||
end
|
end
|
||||||
|
|
||||||
def predicted_fully_modeled?(use_cached: true)
|
def predicted_fully_modeled?
|
||||||
return cached_predicted_fully_modeled? if use_cached
|
|
||||||
predicted_missing_body_ids.empty?
|
predicted_missing_body_ids.empty?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -438,40 +387,11 @@ class Item < ApplicationRecord
|
||||||
compatible_body_ids.size.to_f / predicted_body_ids.size
|
compatible_body_ids.size.to_f / predicted_body_ids.size
|
||||||
end
|
end
|
||||||
|
|
||||||
# We estimate the item's release time as either when we first saw it, or 2010
|
|
||||||
# if it's so old that we don't have a record.
|
|
||||||
def released_at_estimate
|
|
||||||
created_at || Time.new(2010)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Returns the visible trades for this item, filtered by user visibility.
|
|
||||||
# Accepts an optional scope to add additional query constraints (e.g., includes, order).
|
|
||||||
def visible_trades(scope: nil, user: nil, remote_ip: nil)
|
|
||||||
base = closet_hangers.trading.user_is_active
|
|
||||||
base = base.merge(scope) if scope
|
|
||||||
base.to_trades(user, remote_ip)
|
|
||||||
end
|
|
||||||
|
|
||||||
def as_json(options={})
|
def as_json(options={})
|
||||||
result = super({
|
super({
|
||||||
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
only: [:id, :name, :description, :thumbnail_url, :rarity_index],
|
||||||
methods: [:zones_restrict],
|
methods: [:zones_restrict],
|
||||||
}.merge(options))
|
}.merge(options))
|
||||||
|
|
||||||
if options[:include_trade_counts]
|
|
||||||
trades = visible_trades(
|
|
||||||
user: options[:current_user],
|
|
||||||
remote_ip: options[:remote_ip]
|
|
||||||
)
|
|
||||||
result['num_trades_offering'] = trades[:offering].size
|
|
||||||
result['num_trades_seeking'] = trades[:seeking].size
|
|
||||||
end
|
|
||||||
|
|
||||||
if options[:include_nc_trade_value]
|
|
||||||
result['nc_trade_value'] = nc_trade_value
|
|
||||||
end
|
|
||||||
|
|
||||||
result
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def compatible_body_ids(use_cached: true)
|
def compatible_body_ids(use_cached: true)
|
||||||
|
|
@ -623,19 +543,22 @@ class Item < ApplicationRecord
|
||||||
Item.appearances_for([self], target, ...)[id]
|
Item.appearances_for([self], target, ...)[id]
|
||||||
end
|
end
|
||||||
|
|
||||||
def appearances_by_occupied_zone_label
|
def appearances_by_occupied_zone_id
|
||||||
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
|
|
||||||
{}.tap do |h|
|
{}.tap do |h|
|
||||||
appearances.each do |appearance|
|
appearances.each do |appearance|
|
||||||
appearance.occupied_zone_ids.each do |zone_id|
|
appearance.occupied_zone_ids.each do |zone_id|
|
||||||
zone_label = zones_by_id[zone_id].label
|
h[zone_id] ||= []
|
||||||
h[zone_label] ||= []
|
h[zone_id] << appearance
|
||||||
h[zone_label] << appearance
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def appearances_by_occupied_zone
|
||||||
|
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
|
||||||
|
appearances_by_occupied_zone_id.transform_keys { |zid| zones_by_id[zid] }
|
||||||
|
end
|
||||||
|
|
||||||
# Given a list of items, return how they look on the given target (either a
|
# Given a list of items, return how they look on the given target (either a
|
||||||
# pet type or an alt style).
|
# pet type or an alt style).
|
||||||
def self.appearances_for(items, target, swf_asset_includes: [])
|
def self.appearances_for(items, target, swf_asset_includes: [])
|
||||||
|
|
@ -696,10 +619,21 @@ class Item < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.preload_nc_trade_values(items)
|
def self.preload_nc_trade_values(items)
|
||||||
DTIRequests.load_many(max_at_once: 10) do |task|
|
# Only allow 10 trade values to be loaded at a time.
|
||||||
|
barrier = Async::Barrier.new
|
||||||
|
semaphore = Async::Semaphore.new(10, parent: barrier)
|
||||||
|
|
||||||
|
Sync do
|
||||||
# Load all the trade values in concurrent async tasks. (The
|
# Load all the trade values in concurrent async tasks. (The
|
||||||
# `nc_trade_value` caches the value in the Item object.)
|
# `nc_trade_value` caches the value in the Item object.)
|
||||||
items.each { |item| task.async { item.nc_trade_value } }
|
items.each do |item|
|
||||||
|
semaphore.async { item.nc_trade_value }
|
||||||
|
end
|
||||||
|
|
||||||
|
# Wait until all tasks are done.
|
||||||
|
barrier.wait
|
||||||
|
ensure
|
||||||
|
barrier.stop # If something goes wrong, clean up all tasks.
|
||||||
end
|
end
|
||||||
|
|
||||||
items
|
items
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class Item
|
||||||
end
|
end
|
||||||
|
|
||||||
# Whether this is a Dyeworks item whose base item can currently be purchased
|
# Whether this is a Dyeworks item whose base item can currently be purchased
|
||||||
# in the NC Mall, then dyed via Dyeworks. (Lebron tracks this last part!)
|
# in the NC Mall, then dyed via Dyeworks. (Owls tracks this last part!)
|
||||||
def dyeworks_buyable?
|
def dyeworks_buyable?
|
||||||
dyeworks_base_buyable? && dyeworks_dyeable?
|
dyeworks_base_buyable? && dyeworks_dyeable?
|
||||||
end
|
end
|
||||||
|
|
@ -18,14 +18,14 @@ class Item
|
||||||
end
|
end
|
||||||
|
|
||||||
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
|
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right now,
|
||||||
# either at any time or as a limited-time event. (Lebron tracks this, not us!)
|
# either at any time or as a limited-time event. (Owls tracks this, not us!)
|
||||||
def dyeworks_dyeable?
|
def dyeworks_dyeable?
|
||||||
dyeworks_permanent? || dyeworks_limited_active?
|
dyeworks_permanent? || dyeworks_limited_active?
|
||||||
end
|
end
|
||||||
|
|
||||||
# Whether this is one of the few Dyeworks items that can be dyed in the NC
|
# Whether this is one of the few Dyeworks items that can be dyed in the NC
|
||||||
# Mall at any time, rather than as part of a limited-time event. (Lebron
|
# Mall at any time, rather than as part of a limited-time event. (Owls tracks
|
||||||
# tracks this, not us!)
|
# this, not us!)
|
||||||
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
|
DYEWORKS_PERMANENT_PATTERN = /Permanent\s*Dyeworks/i
|
||||||
def dyeworks_permanent?
|
def dyeworks_permanent?
|
||||||
return false if nc_trade_value.nil?
|
return false if nc_trade_value.nil?
|
||||||
|
|
@ -33,11 +33,11 @@ class Item
|
||||||
end
|
end
|
||||||
|
|
||||||
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right
|
# Whether this is a Dyeworks item that can be dyed in the NC Mall ~right
|
||||||
# now, as part of a limited-time event. (Lebron tracks this, not us!)
|
# now, as part of a limited-time event. (Owls tracks this, not us!)
|
||||||
#
|
#
|
||||||
# If we aren't sure of the final date, this will still return `true`, on
|
# If we aren't sure of the final date, this will still return `true`, on
|
||||||
# the assumption it *is* dyeable right now and we just don't understand the
|
# the assumption it *is* dyeable right now and we just don't understand the
|
||||||
# details of what Lebron told us.
|
# details of what Owls told us.
|
||||||
def dyeworks_limited_active?
|
def dyeworks_limited_active?
|
||||||
return false unless dyeworks_limited?
|
return false unless dyeworks_limited?
|
||||||
return true if dyeworks_limited_final_date.nil?
|
return true if dyeworks_limited_final_date.nil?
|
||||||
|
|
@ -51,8 +51,8 @@ class Item
|
||||||
|
|
||||||
# Whether this is a Dyeworks item that can only be dyed as part of a
|
# Whether this is a Dyeworks item that can only be dyed as part of a
|
||||||
# limited-time event. (This may return true even if the end date has
|
# limited-time event. (This may return true even if the end date has
|
||||||
# passed, see `dyeworks_limited_active?`.) (Lebron tracks this, not us!)
|
# passed, see `dyeworks_limited_active?`.) (Owls tracks this, not us!)
|
||||||
DYEWORKS_LIMITED_PATTERN = /Dyeworks\s*Thru/i
|
DYEWORKS_LIMITED_PATTERN = /Limited\s*Dyeworks/i
|
||||||
def dyeworks_limited?
|
def dyeworks_limited?
|
||||||
return false if nc_trade_value.nil?
|
return false if nc_trade_value.nil?
|
||||||
nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN)
|
nc_trade_value.value_text.match?(DYEWORKS_LIMITED_PATTERN)
|
||||||
|
|
@ -60,9 +60,9 @@ class Item
|
||||||
|
|
||||||
# If this is a limited-time Dyeworks item, this is the date we think the
|
# If this is a limited-time Dyeworks item, this is the date we think the
|
||||||
# event will end on. Even if `dyeworks_limited?` returns true, this could
|
# event will end on. Even if `dyeworks_limited?` returns true, this could
|
||||||
# still be `nil`, if we fail to parse this. (Lebron tracks this, not us!)
|
# still be `nil`, if we fail to parse this. (Owls tracks this, not us!)
|
||||||
DYEWORKS_LIMITED_FINAL_DATE_PATTERN =
|
DYEWORKS_LIMITED_FINAL_DATE_PATTERN =
|
||||||
/Dyeworks\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
|
/Dyeable\s*Thru\s*(?<month>[a-z]+)\s*(?<day>[0-9]+)/i
|
||||||
def dyeworks_limited_final_date
|
def dyeworks_limited_final_date
|
||||||
return nil unless dyeworks_limited?
|
return nil unless dyeworks_limited?
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -132,8 +132,6 @@ class Item
|
||||||
is_positive ? Filter.is_np : Filter.is_not_np
|
is_positive ? Filter.is_np : Filter.is_not_np
|
||||||
when 'pb'
|
when 'pb'
|
||||||
is_positive ? Filter.is_pb : Filter.is_not_pb
|
is_positive ? Filter.is_pb : Filter.is_not_pb
|
||||||
when 'modeled'
|
|
||||||
is_positive ? Filter.is_modeled : Filter.is_not_modeled
|
|
||||||
else
|
else
|
||||||
raise_search_error "not_found.label", label: "is:#{value}"
|
raise_search_error "not_found.label", label: "is:#{value}"
|
||||||
end
|
end
|
||||||
|
|
@ -348,14 +346,6 @@ class Item
|
||||||
self.new Item.is_not_pb, '-is:pb'
|
self.new Item.is_not_pb, '-is:pb'
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.is_modeled
|
|
||||||
self.new Item.is_modeled, 'is:modeled'
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.is_not_modeled
|
|
||||||
self.new Item.is_not_modeled, '-is:modeled'
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
# Add quotes around the value, if needed.
|
# Add quotes around the value, if needed.
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
class NeopetsConnection < ApplicationRecord
|
class NeopetsConnection < ApplicationRecord
|
||||||
belongs_to :user
|
belongs_to :user
|
||||||
|
|
||||||
validates :neopets_username, uniqueness: {scope: :user_id},
|
validates :neopets_username, uniqueness: {scope: :user_id}
|
||||||
format: { without: /@/, message: 'must not be an email address, for user safety' }
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -170,67 +170,47 @@ class Outfit < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def visible_layers
|
def visible_layers
|
||||||
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
|
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
||||||
if alt_style
|
|
||||||
biology_layers = alt_style.swf_assets.includes(:zone).to_a
|
|
||||||
body = alt_style
|
|
||||||
using_alt_style = true
|
|
||||||
else
|
|
||||||
biology_layers = pet_state.swf_assets.includes(:zone).to_a
|
|
||||||
body = pet_type
|
|
||||||
using_alt_style = false
|
|
||||||
end
|
|
||||||
|
|
||||||
# Step 2: Load item appearances for the appropriate body
|
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||||
item_appearances = Item.appearances_for(
|
|
||||||
worn_items,
|
|
||||||
body,
|
|
||||||
swf_asset_includes: [:zone]
|
|
||||||
).values
|
|
||||||
item_layers = item_appearances.map(&:swf_assets).flatten
|
item_layers = item_appearances.map(&:swf_assets).flatten
|
||||||
|
|
||||||
# For alt styles, only body_id=0 items are compatible
|
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
||||||
if using_alt_style
|
|
||||||
item_layers.reject! { |sa| sa.body_id != 0 }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Step 3: Apply restriction rules
|
|
||||||
biology_restricted_zone_ids = biology_layers.map(&:restricted_zone_ids).
|
|
||||||
flatten.to_set
|
flatten.to_set
|
||||||
item_restricted_zone_ids = item_appearances.
|
item_restricted_zone_ids = item_appearances.
|
||||||
map(&:restricted_zone_ids).flatten.to_set
|
map(&:restricted_zone_ids).flatten.to_set
|
||||||
|
|
||||||
# Rule 3a: When an item restricts a zone, it hides biology layers of the same zone.
|
# When an item restricts a zone, it hides pet layers of the same zone.
|
||||||
# We use this to e.g. make a hat hide a hair ruff.
|
# We use this to e.g. make a hat hide a hair ruff.
|
||||||
#
|
#
|
||||||
# NOTE: Items' restricted layers also affect what items you can wear at
|
# NOTE: Items' restricted layers also affect what items you can wear at
|
||||||
# the same time. We don't enforce anything about that here, and
|
# the same time. We don't enforce anything about that here, and
|
||||||
# instead assume that the input by this point is valid!
|
# instead assume that the input by this point is valid!
|
||||||
biology_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
|
|
||||||
# Rule 3b: When a biology appearance restricts a zone, or when the pet is
|
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||||
# Unconverted, it makes body-specific items incompatible. We use this to
|
# it makes body-specific items incompatible. We use this to disallow UCs
|
||||||
# disallow UCs from wearing certain body-specific Biology Effects, Statics,
|
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||||
# etc, while still allowing non-body-specific items in those zones! (I think
|
# still allowing non-body-specific items in those zones! (I think this
|
||||||
# this happens for some Invisible pet stuff, too?)
|
# happens for some Invisible pet stuff, too?)
|
||||||
#
|
#
|
||||||
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||||
# should be doing this way earlier, to prevent the item from even
|
# should be doing this way earlier, to prevent the item from even
|
||||||
# showing up even in search results!
|
# showing up even in search results!
|
||||||
#
|
#
|
||||||
# NOTE: This can result in both biology layers and items occupying the same
|
# NOTE: This can result in both pet layers and items occupying the same
|
||||||
# zone, like Static, so long as the item isn't body-specific! That's
|
# zone, like Static, so long as the item isn't body-specific! That's
|
||||||
# correct, and the item layer should be on top! (Here, we implement
|
# correct, and the item layer should be on top! (Here, we implement
|
||||||
# it by placing item layers second in the list, and rely on JS sort
|
# it by placing item layers second in the list, and rely on JS sort
|
||||||
# stability, and *then* rely on the UI to respect that ordering when
|
# stability, and *then* rely on the UI to respect that ordering when
|
||||||
# rendering them by depth. Not great! 😅)
|
# rendering them by depth. Not great! 😅)
|
||||||
#
|
#
|
||||||
# NOTE: We used to also include the biology appearance's *occupied* zones in
|
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||||
# this condition, not just the restricted zones, as a sensible
|
# this condition, not just the restricted zones, as a sensible
|
||||||
# defensive default, even though we weren't aware of any relevant
|
# defensive default, even though we weren't aware of any relevant
|
||||||
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||||
# occupies the real Mouth zone, and still should be visible and
|
# occupies the real Mouth zone, and still should be visible and
|
||||||
# above biology layers! So, we now only check *restricted* zones.
|
# above pet layers! So, we now only check *restricted* zones.
|
||||||
#
|
#
|
||||||
# NOTE: UCs used to implement their restrictions by listing specific
|
# NOTE: UCs used to implement their restrictions by listing specific
|
||||||
# zones, but it seems that the logic has changed to just be about
|
# zones, but it seems that the logic has changed to just be about
|
||||||
|
|
@ -247,20 +227,18 @@ class Outfit < ApplicationRecord
|
||||||
item_layers.reject! { |sa| sa.body_specific? }
|
item_layers.reject! { |sa| sa.body_specific? }
|
||||||
else
|
else
|
||||||
item_layers.reject! { |sa| sa.body_specific? &&
|
item_layers.reject! { |sa| sa.body_specific? &&
|
||||||
biology_restricted_zone_ids.include?(sa.zone_id) }
|
pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
end
|
end
|
||||||
|
|
||||||
# Rule 3c: A biology appearance can also restrict its own zones. The Wraith
|
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||||
# Uni is an interesting example: it has a horn, but its zone restrictions
|
# interesting example: it has a horn, but its zone restrictions hide it!
|
||||||
# hide it!
|
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
biology_layers.reject! { |sa| biology_restricted_zone_ids.include?(sa.zone_id) }
|
|
||||||
|
|
||||||
# Step 4: Sort by depth and return
|
(pet_layers + item_layers).sort_by(&:depth)
|
||||||
(biology_layers + item_layers).sort_by(&:depth)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def wardrobe_params
|
def wardrobe_params
|
||||||
params = {
|
{
|
||||||
name: name,
|
name: name,
|
||||||
color: color_id,
|
color: color_id,
|
||||||
species: species_id,
|
species: species_id,
|
||||||
|
|
@ -269,8 +247,6 @@ class Outfit < ApplicationRecord
|
||||||
objects: worn_item_ids,
|
objects: worn_item_ids,
|
||||||
closet: closeted_item_ids,
|
closet: closeted_item_ids,
|
||||||
}
|
}
|
||||||
params[:style] = alt_style_id if alt_style_id.present?
|
|
||||||
params
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def ensure_unique_name
|
def ensure_unique_name
|
||||||
|
|
|
||||||
|
|
@ -3,18 +3,71 @@ class Pet < ApplicationRecord
|
||||||
|
|
||||||
attr_reader :items, :pet_state, :alt_style
|
attr_reader :items, :pet_state, :alt_style
|
||||||
|
|
||||||
def load!(timeout: nil)
|
scope :with_pet_type_color_ids, ->(color_ids) {
|
||||||
raise ModelingDisabled unless Rails.configuration.modeling_enabled
|
joins(:pet_type).where(PetType.arel_table[:id].in(color_ids))
|
||||||
|
}
|
||||||
|
|
||||||
viewer_data_hash = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
|
def load!(timeout: nil)
|
||||||
use_modeling_snapshot(ModelingSnapshot.new(viewer_data_hash))
|
viewer_data = Neopets::CustomPets.fetch_viewer_data(name, timeout:)
|
||||||
|
use_viewer_data(viewer_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
def use_modeling_snapshot(snapshot)
|
def use_viewer_data(viewer_data)
|
||||||
self.pet_type = snapshot.pet_type
|
pet_data = viewer_data[:custom_pet]
|
||||||
@pet_state = snapshot.pet_state
|
|
||||||
@alt_style = snapshot.alt_style
|
raise UnexpectedDataFormat unless pet_data[:species_id]
|
||||||
@items = snapshot.items
|
raise UnexpectedDataFormat unless pet_data[:color_id]
|
||||||
|
raise UnexpectedDataFormat unless pet_data[:body_id]
|
||||||
|
|
||||||
|
has_alt_style = pet_data[:alt_style].present?
|
||||||
|
|
||||||
|
self.pet_type = PetType.find_or_initialize_by(
|
||||||
|
species_id: pet_data[:species_id].to_i,
|
||||||
|
color_id: pet_data[:color_id].to_i
|
||||||
|
)
|
||||||
|
|
||||||
|
begin
|
||||||
|
new_image_hash = Neopets::CustomPets.fetch_image_hash(self.name)
|
||||||
|
rescue => error
|
||||||
|
Rails.logger.warn "Failed to load image hash: #{error.full_message}"
|
||||||
|
end
|
||||||
|
self.pet_type.image_hash = new_image_hash if new_image_hash.present?
|
||||||
|
|
||||||
|
# With an alt style, `body_id` in the biology data refers to the body ID of
|
||||||
|
# the *alt* style, not the usual pet type. (We have `original_biology` for
|
||||||
|
# *some* of the pet type's situation, but not it's body ID!)
|
||||||
|
#
|
||||||
|
# So, in the alt style case, don't update `body_id` - but if this is our
|
||||||
|
# first time seeing this pet type and it doesn't *have* a `body_id` yet,
|
||||||
|
# let's not be creating it without one. We'll need to model it without the
|
||||||
|
# alt style first. (I don't bother with a clear error message though 😅)
|
||||||
|
self.pet_type.body_id = pet_data[:body_id] unless has_alt_style
|
||||||
|
if self.pet_type.body_id.nil?
|
||||||
|
raise UnexpectedDataFormat,
|
||||||
|
"can't process alt style on first occurrence of pet type"
|
||||||
|
end
|
||||||
|
|
||||||
|
pet_state_biology = has_alt_style ? pet_data[:original_biology] :
|
||||||
|
pet_data[:biology_by_zone]
|
||||||
|
raise UnexpectedDataFormat if pet_state_biology.empty?
|
||||||
|
pet_state_biology[0] = nil # remove effects if present
|
||||||
|
@pet_state = self.pet_type.add_pet_state_from_biology! pet_state_biology
|
||||||
|
|
||||||
|
if has_alt_style
|
||||||
|
raise UnexpectedDataFormat unless pet_data[:alt_color]
|
||||||
|
raise UnexpectedDataFormat if pet_data[:biology_by_zone].empty?
|
||||||
|
|
||||||
|
@alt_style = AltStyle.find_or_initialize_by(id: pet_data[:alt_style].to_i)
|
||||||
|
@alt_style.assign_attributes(
|
||||||
|
color_id: pet_data[:alt_color].to_i,
|
||||||
|
species_id: pet_data[:species_id].to_i,
|
||||||
|
body_id: pet_data[:body_id].to_i,
|
||||||
|
biology: pet_data[:biology_by_zone],
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
@items = Item.collection_from_pet_type_and_registries(self.pet_type,
|
||||||
|
viewer_data[:object_info_registry], viewer_data[:object_asset_registry])
|
||||||
end
|
end
|
||||||
|
|
||||||
def wardrobe_query
|
def wardrobe_query
|
||||||
|
|
@ -40,8 +93,11 @@ class Pet < ApplicationRecord
|
||||||
|
|
||||||
before_validation do
|
before_validation do
|
||||||
pet_type.save!
|
pet_type.save!
|
||||||
@pet_state.save! if @pet_state
|
if @pet_state
|
||||||
|
@pet_state.save!
|
||||||
|
@pet_state.handle_assets!
|
||||||
|
end
|
||||||
|
|
||||||
if @items
|
if @items
|
||||||
@items.each do |item|
|
@items.each do |item|
|
||||||
item.save! if item.changed?
|
item.save! if item.changed?
|
||||||
|
|
@ -61,5 +117,5 @@ class Pet < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
class UnexpectedDataFormat < RuntimeError;end
|
class UnexpectedDataFormat < RuntimeError;end
|
||||||
class ModelingDisabled < RuntimeError;end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
# A representation of a Neopets::CustomPets viewer data response, translated
|
|
||||||
# to DTI's database models!
|
|
||||||
class Pet::ModelingSnapshot
|
|
||||||
def initialize(viewer_data_hash)
|
|
||||||
@custom_pet = viewer_data_hash[:custom_pet]
|
|
||||||
@object_info_registry = viewer_data_hash[:object_info_registry]
|
|
||||||
@object_asset_registry = viewer_data_hash[:object_asset_registry]
|
|
||||||
end
|
|
||||||
|
|
||||||
def pet_type
|
|
||||||
@pet_type ||= begin
|
|
||||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:species_id]
|
|
||||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:color_id]
|
|
||||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:body_id]
|
|
||||||
|
|
||||||
@custom_pet => {species_id:, color_id:}
|
|
||||||
PetType.find_or_initialize_by(species_id:, color_id:).tap do |pet_type|
|
|
||||||
# Apply the pet's body ID to the pet type, unless it's wearing an alt
|
|
||||||
# style, in which case ignore it, because it's the *alt style*'s body ID.
|
|
||||||
# (This can theoretically cause a problem saving a new pet type when
|
|
||||||
# there's an alt style too!)
|
|
||||||
pet_type.body_id = @custom_pet[:body_id] unless @custom_pet[:alt_style]
|
|
||||||
if pet_type.body_id.nil?
|
|
||||||
raise Pet::UnexpectedDataFormat,
|
|
||||||
"can't process alt style on first occurrence of pet type"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Try using this pet for the pet type's thumbnail, but don't worry
|
|
||||||
# if it fails.
|
|
||||||
begin
|
|
||||||
pet_type.consider_pet_image(@custom_pet[:name])
|
|
||||||
rescue => error
|
|
||||||
Rails.logger.warn "Failed to load pet image: #{error.full_message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def pet_state
|
|
||||||
@pet_state ||= begin
|
|
||||||
swf_asset_ids = biology_assets.map(&:remote_id)
|
|
||||||
pet_type.pet_states.find_or_initialize_by(swf_asset_ids:).tap do |pet_state|
|
|
||||||
pet_state.swf_assets = biology_assets
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def alt_style
|
|
||||||
@alt_style ||= begin
|
|
||||||
return nil unless @custom_pet[:alt_style]
|
|
||||||
raise Pet::UnexpectedDataFormat unless @custom_pet[:alt_color]
|
|
||||||
|
|
||||||
id = @custom_pet[:alt_style].to_i
|
|
||||||
AltStyle.find_or_initialize_by(id:).tap do |alt_style|
|
|
||||||
pet_name = @custom_pet[:name]
|
|
||||||
|
|
||||||
# Capture old asset IDs before assignment
|
|
||||||
old_asset_ids = alt_style.swf_assets.map(&:remote_id).sort
|
|
||||||
|
|
||||||
# Assign new attributes and assets
|
|
||||||
new_asset_ids = alt_style_assets.map(&:remote_id).sort
|
|
||||||
alt_style.assign_attributes(
|
|
||||||
color_id: @custom_pet[:alt_color].to_i,
|
|
||||||
species_id: @custom_pet[:species_id].to_i,
|
|
||||||
body_id: @custom_pet[:body_id].to_i,
|
|
||||||
swf_assets: alt_style_assets,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Log the modeling event using Rails' change tracking
|
|
||||||
if alt_style.new_record?
|
|
||||||
Rails.logger.info "[Alt Style Modeling] Created alt style " \
|
|
||||||
"ID=#{id} for pet=#{pet_name}: " \
|
|
||||||
"species_id=#{alt_style.species_id}, " \
|
|
||||||
"color_id=#{alt_style.color_id}, " \
|
|
||||||
"body_id=#{alt_style.body_id}, " \
|
|
||||||
"asset_ids=#{new_asset_ids.inspect}"
|
|
||||||
elsif alt_style.changes.any? || old_asset_ids != new_asset_ids
|
|
||||||
changes = []
|
|
||||||
changes << "species_id: #{alt_style.species_id_was} -> #{alt_style.species_id}" if alt_style.species_id_changed?
|
|
||||||
changes << "color_id: #{alt_style.color_id_was} -> #{alt_style.color_id}" if alt_style.color_id_changed?
|
|
||||||
changes << "body_id: #{alt_style.body_id_was} -> #{alt_style.body_id}" if alt_style.body_id_changed?
|
|
||||||
changes << "asset_ids: #{old_asset_ids.inspect} -> #{new_asset_ids.inspect}" if old_asset_ids != new_asset_ids
|
|
||||||
|
|
||||||
Rails.logger.warn "[Alt Style Modeling] Updated alt style " \
|
|
||||||
"ID=#{id} for pet=#{pet_name}. " \
|
|
||||||
"CHANGED: #{changes.join(', ')}"
|
|
||||||
else
|
|
||||||
Rails.logger.info "[Alt Style Modeling] Loaded alt style " \
|
|
||||||
"ID=#{id} for pet=#{pet_name} (no changes)"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def items
|
|
||||||
@items ||= Item.collection_from_pet_type_and_registries(
|
|
||||||
pet_type, @object_info_registry, @object_asset_registry
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def biology_assets
|
|
||||||
@biology_assets ||= begin
|
|
||||||
biology = @custom_pet[:alt_style].present? ?
|
|
||||||
@custom_pet[:original_biology] :
|
|
||||||
@custom_pet[:biology_by_zone]
|
|
||||||
assets_from_biology(biology)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def item_assets_for(item_id)
|
|
||||||
all_infos = @object_asset_registry.values
|
|
||||||
infos = all_infos.select { |a| a[:obj_info_id].to_i == item_id.to_i }
|
|
||||||
infos.map do |asset_data|
|
|
||||||
remote_id = asset_data[:asset_id].to_i
|
|
||||||
SwfAsset.find_or_initialize_by(type: "object", remote_id:).tap do |swf_asset|
|
|
||||||
swf_asset.origin_pet_type = pet_type
|
|
||||||
swf_asset.origin_object_data = asset_data
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def alt_style_assets
|
|
||||||
raise Pet::UnexpectedDataFormat if @custom_pet[:biology_by_zone].empty?
|
|
||||||
assets_from_biology(@custom_pet[:biology_by_zone])
|
|
||||||
end
|
|
||||||
|
|
||||||
def assets_from_biology(biology)
|
|
||||||
raise Pet::UnexpectedDataFormat if biology.empty?
|
|
||||||
body_id = @custom_pet[:body_id].to_i
|
|
||||||
biology.values.map { |b| SwfAsset.from_biology_data(body_id, b) }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -6,25 +6,17 @@ class PetState < ApplicationRecord
|
||||||
has_many :contributions, :as => :contributed,
|
has_many :contributions, :as => :contributed,
|
||||||
:inverse_of => :contributed # in case of duplicates being merged
|
:inverse_of => :contributed # in case of duplicates being merged
|
||||||
has_many :outfits
|
has_many :outfits
|
||||||
has_many :parent_swf_asset_relationships, :as => :parent
|
has_many :parent_swf_asset_relationships, :as => :parent,
|
||||||
|
:autosave => false
|
||||||
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
has_many :swf_assets, :through => :parent_swf_asset_relationships
|
||||||
|
|
||||||
serialize :swf_asset_ids, coder: Serializers::IntegerSet, type: Array
|
|
||||||
|
|
||||||
belongs_to :pet_type
|
belongs_to :pet_type
|
||||||
|
|
||||||
delegate :species_id, :species, :color_id, :color, to: :pet_type
|
delegate :species_id, :species, :color_id, :color, to: :pet_type
|
||||||
|
|
||||||
alias_method :swf_asset_ids_from_association, :swf_asset_ids
|
alias_method :swf_asset_ids_from_association, :swf_asset_ids
|
||||||
|
|
||||||
scope :glitched, -> { where(glitched: true) }
|
attr_writer :parent_swf_asset_relationships_to_update
|
||||||
scope :needs_labeling, -> { unlabeled.where(glitched: false) }
|
|
||||||
scope :unlabeled, -> { with_pose("UNKNOWN") }
|
|
||||||
scope :usable, -> { where(labeled: true, glitched: false) }
|
|
||||||
|
|
||||||
scope :newest, -> { order(created_at: :desc) }
|
|
||||||
scope :newest_pet_type, -> { joins(:pet_type).merge(PetType.newest) }
|
|
||||||
scope :created_before, ->(time) { where(arel_table[:created_at].lt(time)) }
|
|
||||||
|
|
||||||
# A simple ordering that tries to bring reliable pet states to the front.
|
# A simple ordering that tries to bring reliable pet states to the front.
|
||||||
scope :emotion_order, -> {
|
scope :emotion_order, -> {
|
||||||
|
|
@ -103,16 +95,109 @@ class PetState < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def reassign_children_to!(main_pet_state)
|
||||||
|
self.contributions.each do |contribution|
|
||||||
|
contribution.contributed = main_pet_state
|
||||||
|
contribution.save
|
||||||
|
end
|
||||||
|
self.outfits.each do |outfit|
|
||||||
|
outfit.pet_state = main_pet_state
|
||||||
|
outfit.save
|
||||||
|
end
|
||||||
|
ParentSwfAssetRelationship.where(ParentSwfAssetRelationship.arel_table[:parent_id].eq(self.id)).delete_all
|
||||||
|
end
|
||||||
|
|
||||||
|
def reassign_duplicates!
|
||||||
|
raise "This may only be applied to pet states that represent many duplicate entries" unless duplicate_ids
|
||||||
|
pet_states = duplicate_ids.split(',').map do |id|
|
||||||
|
PetState.find(id.to_i)
|
||||||
|
end
|
||||||
|
main_pet_state = pet_states.shift
|
||||||
|
pet_states.each do |pet_state|
|
||||||
|
pet_state.reassign_children_to!(main_pet_state)
|
||||||
|
pet_state.destroy
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def sort_swf_asset_ids!
|
||||||
|
self.swf_asset_ids = swf_asset_ids_array.sort.join(',')
|
||||||
|
end
|
||||||
|
|
||||||
|
def swf_asset_ids
|
||||||
|
self['swf_asset_ids']
|
||||||
|
end
|
||||||
|
|
||||||
|
def swf_asset_ids_array
|
||||||
|
swf_asset_ids.split(',').map(&:to_i)
|
||||||
|
end
|
||||||
|
|
||||||
|
def swf_asset_ids=(ids)
|
||||||
|
self['swf_asset_ids'] = ids
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_assets!
|
||||||
|
@parent_swf_asset_relationships_to_update.each do |rel|
|
||||||
|
rel.swf_asset.save!
|
||||||
|
rel.save!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def to_param
|
def to_param
|
||||||
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
|
"#{id}-#{pose.split('_').map(&:capitalize).join('-')}"
|
||||||
end
|
end
|
||||||
|
|
||||||
# Because our column is named `swf_asset_ids`, we need to ensure writes to
|
def self.from_pet_type_and_biology_info(pet_type, info)
|
||||||
# it go to the attribute, and not the thing ActiveRecord does of finding the
|
swf_asset_ids = []
|
||||||
# relevant `swf_assets`.
|
info.each do |zone_id, asset_info|
|
||||||
# TODO: Consider renaming the column to `cached_swf_asset_ids`?
|
if zone_id.present? && asset_info
|
||||||
def swf_asset_ids=(new_swf_asset_ids)
|
swf_asset_ids << asset_info[:part_id].to_i
|
||||||
write_attribute(:swf_asset_ids, new_swf_asset_ids)
|
end
|
||||||
|
end
|
||||||
|
swf_asset_ids_str = swf_asset_ids.sort.join(',')
|
||||||
|
if pet_type.new_record?
|
||||||
|
pet_state = self.new :swf_asset_ids => swf_asset_ids_str
|
||||||
|
else
|
||||||
|
pet_state = self.find_or_initialize_by(
|
||||||
|
pet_type_id: pet_type.id,
|
||||||
|
swf_asset_ids: swf_asset_ids_str
|
||||||
|
)
|
||||||
|
end
|
||||||
|
existing_swf_assets = SwfAsset.biology_assets.includes(:zone).
|
||||||
|
where(remote_id: swf_asset_ids)
|
||||||
|
existing_swf_assets_by_id = {}
|
||||||
|
existing_swf_assets.each do |swf_asset|
|
||||||
|
existing_swf_assets_by_id[swf_asset.remote_id] = swf_asset
|
||||||
|
end
|
||||||
|
existing_relationships_by_swf_asset_id = {}
|
||||||
|
unless pet_state.new_record?
|
||||||
|
pet_state.parent_swf_asset_relationships.each do |relationship|
|
||||||
|
existing_relationships_by_swf_asset_id[relationship.swf_asset_id] = relationship
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pet_state.pet_type = pet_type # save the second case from having to look it up by ID
|
||||||
|
relationships = []
|
||||||
|
info.each do |zone_id, asset_info|
|
||||||
|
if zone_id.present? && asset_info
|
||||||
|
swf_asset_id = asset_info[:part_id].to_i
|
||||||
|
swf_asset = existing_swf_assets_by_id[swf_asset_id]
|
||||||
|
unless swf_asset
|
||||||
|
swf_asset = SwfAsset.new
|
||||||
|
swf_asset.remote_id = swf_asset_id
|
||||||
|
end
|
||||||
|
swf_asset.origin_biology_data = asset_info
|
||||||
|
swf_asset.origin_pet_type = pet_type
|
||||||
|
relationship = existing_relationships_by_swf_asset_id[swf_asset.id]
|
||||||
|
unless relationship
|
||||||
|
relationship ||= ParentSwfAssetRelationship.new
|
||||||
|
relationship.parent = pet_state
|
||||||
|
relationship.swf_asset_id = swf_asset.id
|
||||||
|
end
|
||||||
|
relationship.swf_asset = swf_asset
|
||||||
|
relationships << relationship
|
||||||
|
end
|
||||||
|
end
|
||||||
|
pet_state.parent_swf_asset_relationships_to_update = relationships
|
||||||
|
pet_state
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
@ -142,40 +227,5 @@ class PetState < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.next_unlabeled_appearance(after_id: nil)
|
|
||||||
# Rather than just getting the newest unlabeled pet state, prioritize the
|
|
||||||
# newest *pet type*. This better matches the user's perception of what the
|
|
||||||
# newest state is, because the Rainbow Pool UI is grouped by pet type!
|
|
||||||
pet_states = needs_labeling.newest_pet_type.newest
|
|
||||||
|
|
||||||
# If `after_id` is given, convert it from a PetState ID to creation
|
|
||||||
# timestamps, and find the next record prior to those timestamps. This
|
|
||||||
# enables skipping past records the user doesn't want to label.
|
|
||||||
if after_id
|
|
||||||
begin
|
|
||||||
after_pet_state = PetState.find(after_id)
|
|
||||||
before_pt_created_at = after_pet_state.pet_type.created_at
|
|
||||||
before_ps_created_at = after_pet_state.created_at
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
|
||||||
Rails.logger.warn "PetState.next_unlabeled_appearance: Could not " +
|
|
||||||
"find pet state ##{after_id}"
|
|
||||||
return nil
|
|
||||||
end
|
|
||||||
|
|
||||||
# Because we sort by `newest_pet_type` first, then breaks ties by
|
|
||||||
# `newest`, our filter needs to operate the same way. Kudos to:
|
|
||||||
# https://brunoscheufler.com/blog/2022-01-01-paginating-large-ordered-datasets-with-cursor-based-pagination
|
|
||||||
pet_states.merge!(
|
|
||||||
PetType.created_before(before_pt_created_at).or(
|
|
||||||
PetType.created_at(before_pt_created_at).and(
|
|
||||||
PetState.created_before(before_ps_created_at)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
pet_states.first
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,10 @@ class PetType < ApplicationRecord
|
||||||
species = Species.find_by_name!(species_name)
|
species = Species.find_by_name!(species_name)
|
||||||
where(color_id: color.id, species_id: species.id)
|
where(color_id: color.id, species_id: species.id)
|
||||||
}
|
}
|
||||||
scope :newest, -> { order(created_at: :desc) }
|
scope :matching_name_param, ->(name_param) {
|
||||||
|
color_name, _, species_name = name_param.rpartition("-")
|
||||||
|
matching_name(color_name, species_name)
|
||||||
|
}
|
||||||
scope :preferring_species, ->(species_id) {
|
scope :preferring_species, ->(species_id) {
|
||||||
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
|
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
|
||||||
}
|
}
|
||||||
|
|
@ -27,16 +30,6 @@ class PetType < ApplicationRecord
|
||||||
merge(Species.order(name: :asc)).
|
merge(Species.order(name: :asc)).
|
||||||
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
|
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
|
||||||
}
|
}
|
||||||
scope :released_before, ->(time) {
|
|
||||||
# We use DTI's creation timestamp as an estimate of when it was released.
|
|
||||||
where('created_at <= ?', time)
|
|
||||||
}
|
|
||||||
scope :created_before, ->(time) {
|
|
||||||
where(arel_table[:created_at].lt(time))
|
|
||||||
}
|
|
||||||
scope :created_at, ->(time) {
|
|
||||||
where(arel_table[:created_at].eq(time))
|
|
||||||
}
|
|
||||||
|
|
||||||
def self.random_basic_per_species(species_ids)
|
def self.random_basic_per_species(species_ids)
|
||||||
random_pet_types = []
|
random_pet_types = []
|
||||||
|
|
@ -64,14 +57,6 @@ class PetType < ApplicationRecord
|
||||||
basic_image_hash || self['image_hash'] || 'deadbeef'
|
basic_image_hash || self['image_hash'] || 'deadbeef'
|
||||||
end
|
end
|
||||||
|
|
||||||
def consider_pet_image(pet_name)
|
|
||||||
# If we already have a basic image hash, don't worry about it!
|
|
||||||
return if basic_image_hash?
|
|
||||||
|
|
||||||
# Otherwise, use this as the new image hash for this pet type.
|
|
||||||
self.image_hash = Neopets::CustomPets.fetch_image_hash(pet_name)
|
|
||||||
end
|
|
||||||
|
|
||||||
def possibly_new_color
|
def possibly_new_color
|
||||||
self.color || Color.new(id: self.color_id)
|
self.color || Color.new(id: self.color_id)
|
||||||
end
|
end
|
||||||
|
|
@ -86,6 +71,11 @@ class PetType < ApplicationRecord
|
||||||
species_human_name: possibly_new_species.human_name)
|
species_human_name: possibly_new_species.human_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def add_pet_state_from_biology!(biology)
|
||||||
|
pet_state = PetState.from_pet_type_and_biology_info(self, biology)
|
||||||
|
pet_state
|
||||||
|
end
|
||||||
|
|
||||||
def canonical_pet_state
|
def canonical_pet_state
|
||||||
# For consistency (randomness is always scary!), we use the PetType ID to
|
# For consistency (randomness is always scary!), we use the PetType ID to
|
||||||
# determine which gender to prefer, if it's not built into the color. That
|
# determine which gender to prefer, if it's not built into the color. That
|
||||||
|
|
@ -123,7 +113,7 @@ class PetType < ApplicationRecord
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_param
|
def to_param
|
||||||
"#{possibly_new_color.to_param}-#{possibly_new_species.to_param}"
|
"#{color.human_name}-#{species.human_name}"
|
||||||
end
|
end
|
||||||
|
|
||||||
def fully_labeled?
|
def fully_labeled?
|
||||||
|
|
@ -143,19 +133,6 @@ class PetType < ApplicationRecord
|
||||||
pet_states.count { |ps| ps.pose == "UNKNOWN" }
|
pet_states.count { |ps| ps.pose == "UNKNOWN" }
|
||||||
end
|
end
|
||||||
|
|
||||||
def reference
|
|
||||||
PetType.where(species_id: species).basic.merge(Color.alphabetical).first
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.find_by_param!(param)
|
|
||||||
raise ActiveRecord::RecordNotFound unless param.include?("-")
|
|
||||||
color_param, _, species_param = param.rpartition("-")
|
|
||||||
where(
|
|
||||||
color_id: Color.param_to_id(color_param),
|
|
||||||
species_id: Species.param_to_id(species_param),
|
|
||||||
).first!
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.basic_body_ids
|
def self.basic_body_ids
|
||||||
PetType.basic.distinct.pluck(:body_id)
|
PetType.basic.distinct.pluck(:body_id)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -16,10 +16,6 @@ class Species < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def to_param
|
|
||||||
name? ? human_name : id.to_s
|
|
||||||
end
|
|
||||||
|
|
||||||
# Given a list of body IDs, return a hash from body ID to Species.
|
# Given a list of body IDs, return a hash from body ID to Species.
|
||||||
# (We assume that each body ID belongs to just one species; if not, which
|
# (We assume that each body ID belongs to just one species; if not, which
|
||||||
# species we return for that body ID is undefined.)
|
# species we return for that body ID is undefined.)
|
||||||
|
|
@ -30,8 +26,4 @@ class Species < ApplicationRecord
|
||||||
to_h { |s| [s.id, s] }
|
to_h { |s| [s.id, s] }
|
||||||
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
|
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.param_to_id(param)
|
|
||||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,7 @@
|
||||||
require 'addressable/template'
|
require 'addressable/template'
|
||||||
|
require 'async'
|
||||||
|
require 'async/barrier'
|
||||||
|
require 'async/semaphore'
|
||||||
|
|
||||||
class SwfAsset < ApplicationRecord
|
class SwfAsset < ApplicationRecord
|
||||||
# We use the `type` column to mean something other than what Rails means!
|
# We use the `type` column to mean something other than what Rails means!
|
||||||
|
|
@ -38,7 +41,7 @@ class SwfAsset < ApplicationRecord
|
||||||
{
|
{
|
||||||
swf: url,
|
swf: url,
|
||||||
png: image_url,
|
png: image_url,
|
||||||
svg: svg_url,
|
svg: manifest_asset_urls[:svg],
|
||||||
canvas_library: manifest_asset_urls[:js],
|
canvas_library: manifest_asset_urls[:js],
|
||||||
manifest: manifest_url,
|
manifest: manifest_url,
|
||||||
}
|
}
|
||||||
|
|
@ -183,18 +186,6 @@ class SwfAsset < ApplicationRecord
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def image_url?
|
|
||||||
image_url.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def svg_url
|
|
||||||
manifest_asset_urls[:svg]
|
|
||||||
end
|
|
||||||
|
|
||||||
def svg_url?
|
|
||||||
svg_url.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def canvas_movie?
|
def canvas_movie?
|
||||||
canvas_movie_library_url.present?
|
canvas_movie_library_url.present?
|
||||||
end
|
end
|
||||||
|
|
@ -329,12 +320,30 @@ class SwfAsset < ApplicationRecord
|
||||||
swf_asset
|
swf_asset
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.from_wardrobe_link_params(ids)
|
||||||
|
where((
|
||||||
|
arel_table[:remote_id].in(ids[:biology]).and(arel_table[:type].eq('biology'))
|
||||||
|
).or(
|
||||||
|
arel_table[:remote_id].in(ids[:object]).and(arel_table[:type].eq('object'))
|
||||||
|
))
|
||||||
|
end
|
||||||
|
|
||||||
# Given a list of SWF assets, ensure all of their manifests are loaded, with
|
# Given a list of SWF assets, ensure all of their manifests are loaded, with
|
||||||
# fast concurrent execution!
|
# fast concurrent execution!
|
||||||
def self.preload_manifests(swf_assets)
|
def self.preload_manifests(swf_assets)
|
||||||
DTIRequests.load_many(max_at_once: 10) do |task|
|
# Blocks all tasks beneath it.
|
||||||
swf_assets.each do |swf_asset|
|
barrier = Async::Barrier.new
|
||||||
task.async do
|
|
||||||
|
Sync do
|
||||||
|
# Only allow 10 manifests to be loaded at a time.
|
||||||
|
semaphore = Async::Semaphore.new(10, parent: barrier)
|
||||||
|
|
||||||
|
# Load all the manifests in async tasks. This will load them 10 at a time
|
||||||
|
# rather than all at once (because of the semaphore), and the
|
||||||
|
# NeopetsMediaArchive will share a pool of persistent connections for
|
||||||
|
# them.
|
||||||
|
swf_assets.map do |swf_asset|
|
||||||
|
semaphore.async do
|
||||||
begin
|
begin
|
||||||
# Don't save changes in this big async situation; we'll do it all
|
# Don't save changes in this big async situation; we'll do it all
|
||||||
# in one batch after, to avoid too much database concurrency!
|
# in one batch after, to avoid too much database concurrency!
|
||||||
|
|
@ -345,6 +354,11 @@ class SwfAsset < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Wait until all tasks are done.
|
||||||
|
barrier.wait
|
||||||
|
ensure
|
||||||
|
barrier.stop # If something goes wrong, clean up all tasks.
|
||||||
end
|
end
|
||||||
|
|
||||||
SwfAsset.transaction do
|
SwfAsset.transaction do
|
||||||
|
|
|
||||||
|
|
@ -198,17 +198,6 @@ class User < ApplicationRecord
|
||||||
touch(:last_trade_activity_at)
|
touch(:last_trade_activity_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
def visible_to?(current_user, remote_ip)
|
|
||||||
# Everyone is visible to support staff.
|
|
||||||
return true if current_user&.support_staff?
|
|
||||||
|
|
||||||
# Shadowbanned users are only visible to themselves.
|
|
||||||
return false if shadowbanned? && !likely_is?(current_user, remote_ip)
|
|
||||||
|
|
||||||
# Other than that, users are visible to everyone by default.
|
|
||||||
return true
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.points_required_to_pass_top_contributor(offset)
|
def self.points_required_to_pass_top_contributor(offset)
|
||||||
user = User.top_contributors.select(:points).limit(1).offset(offset).first
|
user = User.top_contributors.select(:points).limit(1).offset(offset).first
|
||||||
user ? user.points : 0
|
user ? user.points : 0
|
||||||
|
|
|
||||||
|
|
@ -1,50 +0,0 @@
|
||||||
module LebronNCValues
|
|
||||||
# NOTE: While we still have `updated_at` infra lying around from the Owls
|
|
||||||
# days, the current JSON file doesn't tell us that info, so it's always
|
|
||||||
# `nil` for now.
|
|
||||||
Value = Struct.new(:value_text, :updated_at)
|
|
||||||
|
|
||||||
class Error < StandardError;end
|
|
||||||
class NetworkError < Error;end
|
|
||||||
class NotFound < Error;end
|
|
||||||
|
|
||||||
class << self
|
|
||||||
def find_by_name(name)
|
|
||||||
value_text = all_values[name.downcase]
|
|
||||||
raise NotFound if value_text.nil? || value_text.strip == '-'
|
|
||||||
|
|
||||||
Value.new(value_text, nil)
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def all_values
|
|
||||||
Rails.cache.fetch('LebronNCValues.load_all_values', expires_in: 1.day) do
|
|
||||||
Sync { load_all_values }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
ALL_VALUES_URL = "https://lebron-values.netlify.app/item_values.json"
|
|
||||||
def load_all_values
|
|
||||||
begin
|
|
||||||
DTIRequests.get(ALL_VALUES_URL) do |response|
|
|
||||||
if response.status != 200
|
|
||||||
raise NetworkError,
|
|
||||||
"Lebron returned status code #{response.status} (expected 200)"
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
JSON.parse(response.read)
|
|
||||||
rescue JSON::ParserError => error
|
|
||||||
raise NetworkError,
|
|
||||||
"Lebron returned unsupported data format: #{error.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
rescue Async::TimeoutError => error
|
|
||||||
raise NetworkError, "Lebron timed out: #{error.message}"
|
|
||||||
rescue SocketError => error
|
|
||||||
raise NetworkError, "Could not connected to Lebron: #{error.message}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -49,7 +49,9 @@ module Neopets::CustomPets
|
||||||
# Return the response body as a `HashWithIndifferentAccess`.
|
# Return the response body as a `HashWithIndifferentAccess`.
|
||||||
def send_amfphp_request(request, timeout: 10)
|
def send_amfphp_request(request, timeout: 10)
|
||||||
begin
|
begin
|
||||||
response_data = request.post(timeout: timeout)
|
response_data = request.post(timeout: timeout, headers: {
|
||||||
|
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
||||||
|
})
|
||||||
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
rescue RocketAMFExtensions::RemoteGateway::AMFError => e
|
||||||
raise DownloadError, e.message
|
raise DownloadError, e.message
|
||||||
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
rescue RocketAMFExtensions::RemoteGateway::ConnectionError => e
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,57 @@
|
||||||
require "addressable/template"
|
require "addressable/template"
|
||||||
|
require "async/http/internet/instance"
|
||||||
|
|
||||||
# Neopets::NCMall integrates with the Neopets NC Mall to fetch currently
|
|
||||||
# available items and their pricing.
|
|
||||||
#
|
|
||||||
# The integration works in two steps:
|
|
||||||
#
|
|
||||||
# 1. Category Discovery: We fetch the NC Mall homepage and extract the
|
|
||||||
# browsable categories from the embedded `window.ncmall_menu` JSON data.
|
|
||||||
# We filter out special feature categories (those with external URLs) and
|
|
||||||
# structural parent nodes (those without a cat_id).
|
|
||||||
#
|
|
||||||
# 2. Item Fetching: For each category, we call the v2 category API with
|
|
||||||
# pagination support. Large categories may span multiple pages, which we
|
|
||||||
# fetch in parallel and combine. Items can appear in multiple categories,
|
|
||||||
# so the rake task de-duplicates by item ID.
|
|
||||||
#
|
|
||||||
# The parsed item data includes:
|
|
||||||
# - id: Neopets item ID
|
|
||||||
# - name: Item display name
|
|
||||||
# - description: Item description
|
|
||||||
# - price: Regular price in NC (NeoCash)
|
|
||||||
# - discount: Optional discount info (price, begins_at, ends_at)
|
|
||||||
# - is_available: Whether the item is currently purchasable
|
|
||||||
#
|
|
||||||
# This module is used by the `neopets:import:nc_mall` rake task to sync our
|
|
||||||
# NCMallRecord table with the live NC Mall.
|
|
||||||
module Neopets::NCMall
|
module Neopets::NCMall
|
||||||
# Load the NC Mall page for a specific type and category ID, with pagination.
|
# Share a pool of persistent connections, rather than reconnecting on
|
||||||
|
# each request. (This library does that automatically!)
|
||||||
|
INTERNET = Async::HTTP::Internet.instance
|
||||||
|
|
||||||
|
# Load the NC Mall home page content area, and return its useful data.
|
||||||
|
HOME_PAGE_URL = "https://ncmall.neopets.com/mall/ajax/home_page.phtml"
|
||||||
|
def self.load_home_page
|
||||||
|
load_page_by_url HOME_PAGE_URL
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load the NC Mall page for a specific type and category ID.
|
||||||
CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new(
|
CATEGORY_PAGE_URL_TEMPLATE = Addressable::Template.new(
|
||||||
"https://ncmall.neopets.com/mall/ajax/v2/category/index.phtml{?type,cat,page,limit}"
|
"https://ncmall.neopets.com/mall/ajax/load_page.phtml?lang=en{&type,cat}"
|
||||||
)
|
)
|
||||||
def self.load_page(type, cat, page: 1, limit: 24)
|
def self.load_page(type, cat)
|
||||||
url = CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:, page:, limit:)
|
load_page_by_url CATEGORY_PAGE_URL_TEMPLATE.expand(type:, cat:)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load the NC Mall root document HTML, and extract the list of links to
|
||||||
|
# other pages ("New", "Popular", etc.)
|
||||||
|
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
|
||||||
|
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
|
||||||
|
def self.load_page_links
|
||||||
|
html = Sync do
|
||||||
|
INTERNET.get(ROOT_DOCUMENT_URL, [
|
||||||
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
|
]) do |response|
|
||||||
|
if response.status != 200
|
||||||
|
raise ResponseNotOK.new(response.status),
|
||||||
|
"expected status 200 but got #{response.status} (#{url})"
|
||||||
|
end
|
||||||
|
|
||||||
|
response.read
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Extract `load_items_pane` calls from the root document's HTML. (We use
|
||||||
|
# a very simplified regex, rather than actually parsing the full HTML!)
|
||||||
|
html.scan(PAGE_LINK_PATTERN).
|
||||||
|
map { |type, cat, label| {type:, cat:, label:} }.
|
||||||
|
uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.load_page_by_url(url)
|
||||||
Sync do
|
Sync do
|
||||||
DTIRequests.get(url) do |response|
|
INTERNET.get(url, [
|
||||||
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
|
]) do |response|
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
raise ResponseNotOK.new(response.status),
|
raise ResponseNotOK.new(response.status),
|
||||||
"expected status 200 but got #{response.status} (#{url})"
|
"expected status 200 but got #{response.status} (#{url})"
|
||||||
|
|
@ -44,174 +62,7 @@ module Neopets::NCMall
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load all pages for a specific category.
|
# Given a string of NC page data, parse the useful data out of it!
|
||||||
def self.load_category_all_pages(type, cat, limit: 24)
|
|
||||||
# First, load page 1 to get total page count
|
|
||||||
first_page = load_page(type, cat, page: 1, limit:)
|
|
||||||
total_pages = first_page[:total_pages]
|
|
||||||
|
|
||||||
# If there's only one page, return it
|
|
||||||
return first_page[:items] if total_pages <= 1
|
|
||||||
|
|
||||||
# Otherwise, load remaining pages in parallel
|
|
||||||
Sync do
|
|
||||||
remaining_page_tasks = (2..total_pages).map do |page_num|
|
|
||||||
Async { load_page(type, cat, page: page_num, limit:) }
|
|
||||||
end
|
|
||||||
|
|
||||||
all_pages = [first_page] + remaining_page_tasks.map(&:wait)
|
|
||||||
all_pages.flat_map { |page| page[:items] }
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Load the NC Mall root document HTML, and extract categories from the
|
|
||||||
# embedded menu JSON.
|
|
||||||
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
|
|
||||||
MENU_JSON_PATTERN = /window\.ncmall_menu = (\[.*?\]);/m
|
|
||||||
def self.load_categories
|
|
||||||
html = Sync do
|
|
||||||
DTIRequests.get(ROOT_DOCUMENT_URL) do |response|
|
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{ROOT_DOCUMENT_URL})"
|
|
||||||
end
|
|
||||||
|
|
||||||
response.read
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Extract the ncmall_menu JSON from the script tag
|
|
||||||
match = html.match(MENU_JSON_PATTERN)
|
|
||||||
unless match
|
|
||||||
raise UnexpectedResponseFormat,
|
|
||||||
"could not find window.ncmall_menu in homepage HTML"
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
menu = JSON.parse(match[1])
|
|
||||||
rescue JSON::ParserError => e
|
|
||||||
Rails.logger.debug "Failed to parse ncmall_menu JSON: #{e.message}"
|
|
||||||
raise UnexpectedResponseFormat,
|
|
||||||
"failed to parse ncmall_menu as JSON"
|
|
||||||
end
|
|
||||||
|
|
||||||
# Flatten the menu structure, and filter to browsable categories
|
|
||||||
browsable_categories = flatten_categories(menu).
|
|
||||||
# Skip categories without a cat_id (structural parent nodes)
|
|
||||||
reject { |cat| cat['cat_id'].blank? }.
|
|
||||||
# Skip categories with external URLs (special features)
|
|
||||||
reject { |cat| cat['url'].present? }
|
|
||||||
|
|
||||||
# Map each category to include the API type (and remove load_type)
|
|
||||||
browsable_categories.map do |cat|
|
|
||||||
cat.except("load_type").merge(
|
|
||||||
"type" => map_load_type_to_api_type(cat["load_type"])
|
|
||||||
)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.load_styles(species_id:, neologin:)
|
|
||||||
Sync do
|
|
||||||
tabs = [
|
|
||||||
Async { load_styles_tab(species_id:, neologin:, tab: 1) },
|
|
||||||
Async { load_styles_tab(species_id:, neologin:, tab: 2) },
|
|
||||||
]
|
|
||||||
tabs.map(&:wait).flatten(1)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Generate a new image hash for a pet wearing specific items. Takes a base
|
|
||||||
# pet sci (species/color image hash) and optional item IDs, and returns a
|
|
||||||
# response containing the combined image hash in the :newsci field.
|
|
||||||
# Use the returned hash with Neopets::CustomPets.fetch_viewer_data("@#{newsci}")
|
|
||||||
# to get the full appearance data.
|
|
||||||
PET_DATA_URL = "https://ncmall.neopets.com/mall/ajax/petview/getPetData.php"
|
|
||||||
def self.fetch_pet_data(pet_sci, item_ids = [])
|
|
||||||
Sync do
|
|
||||||
params = {"selPetsci" => pet_sci}
|
|
||||||
item_ids.each { |id| params["itemsList[]"] = id.to_s }
|
|
||||||
|
|
||||||
DTIRequests.post(
|
|
||||||
PET_DATA_URL,
|
|
||||||
[["Content-Type", "application/x-www-form-urlencoded"]],
|
|
||||||
params.to_query,
|
|
||||||
) do |response|
|
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{PET_DATA_URL})"
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
data = JSON.parse(response.read)
|
|
||||||
rescue JSON::ParserError
|
|
||||||
raise UnexpectedResponseFormat,
|
|
||||||
"failed to parse pet data response as JSON"
|
|
||||||
end
|
|
||||||
|
|
||||||
unless data["newsci"].is_a?(String) && data["newsci"].present?
|
|
||||||
raise UnexpectedResponseFormat,
|
|
||||||
"missing or invalid field newsci in pet data response"
|
|
||||||
end
|
|
||||||
|
|
||||||
{newsci: data["newsci"]}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
# Map load_type from menu JSON to the v2 API type parameter.
|
|
||||||
def self.map_load_type_to_api_type(load_type)
|
|
||||||
case load_type
|
|
||||||
when "new"
|
|
||||||
"new_items"
|
|
||||||
when "popular"
|
|
||||||
"popular_items"
|
|
||||||
else
|
|
||||||
"browse"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Flatten nested category structure (handles children arrays)
|
|
||||||
def self.flatten_categories(menu)
|
|
||||||
menu.flat_map do |cat|
|
|
||||||
children = cat["children"] || []
|
|
||||||
[cat] + flatten_categories(children)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
STYLING_STUDIO_URL = "https://www.neopets.com/np-templates/ajax/stylingstudio/studio.php"
|
|
||||||
def self.load_styles_tab(species_id:, neologin:, tab:)
|
|
||||||
Sync do
|
|
||||||
DTIRequests.post(
|
|
||||||
STYLING_STUDIO_URL,
|
|
||||||
[
|
|
||||||
["Content-Type", "application/x-www-form-urlencoded"],
|
|
||||||
["Cookie", "neologin=#{neologin}"],
|
|
||||||
["X-Requested-With", "XMLHttpRequest"],
|
|
||||||
],
|
|
||||||
{tab:, mode: "getAvailable", species: species_id}.to_query,
|
|
||||||
) do |response|
|
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{STYLING_STUDIO_URL})"
|
|
||||||
end
|
|
||||||
|
|
||||||
begin
|
|
||||||
data = JSON.parse(response.read).deep_symbolize_keys
|
|
||||||
|
|
||||||
# HACK: styles is a hash, unless it's empty, in which case it's an
|
|
||||||
# array? Weird. Normalize this by converting to hash.
|
|
||||||
data.fetch(:styles).to_h.values.
|
|
||||||
map { |s| s.slice(:oii, :name, :image, :limited) }
|
|
||||||
rescue JSON::ParserError, KeyError
|
|
||||||
raise UnexpectedResponseFormat
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
# Given a string of v2 NC page data, parse the useful data out of it!
|
|
||||||
def self.parse_nc_page(nc_page_str)
|
def self.parse_nc_page(nc_page_str)
|
||||||
begin
|
begin
|
||||||
nc_page = JSON.parse(nc_page_str)
|
nc_page = JSON.parse(nc_page_str)
|
||||||
|
|
@ -221,14 +72,24 @@ module Neopets::NCMall
|
||||||
"failed to parse NC page response as JSON"
|
"failed to parse NC page response as JSON"
|
||||||
end
|
end
|
||||||
|
|
||||||
# v2 API returns items in a "data" array
|
unless nc_page.has_key? "object_data"
|
||||||
unless nc_page.has_key? "data"
|
raise UnexpectedResponseFormat, "missing field object_data in NC page"
|
||||||
raise UnexpectedResponseFormat, "missing field data in v2 NC page"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
item_data = nc_page["data"] || []
|
object_data = nc_page["object_data"]
|
||||||
|
|
||||||
items = item_data.map do |item_info|
|
# NOTE: When there's no object data, it will be an empty array instead of
|
||||||
|
# an empty hash. Weird API thing to work around!
|
||||||
|
object_data = {} if object_data == []
|
||||||
|
|
||||||
|
# Only the items in the `render` list are actually listed as directly for
|
||||||
|
# sale in the shop. `object_data` might contain other items that provide
|
||||||
|
# supporting information about them, but aren't actually for sale.
|
||||||
|
visible_object_data = (nc_page["render"] || []).
|
||||||
|
map { |id| object_data[id.to_s] }.
|
||||||
|
filter(&:present?)
|
||||||
|
|
||||||
|
items = visible_object_data.map do |item_info|
|
||||||
{
|
{
|
||||||
id: item_info["id"],
|
id: item_info["id"],
|
||||||
name: item_info["name"],
|
name: item_info["name"],
|
||||||
|
|
@ -239,24 +100,18 @@ module Neopets::NCMall
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
{
|
{items:}
|
||||||
items:,
|
|
||||||
total_pages: nc_page["totalPages"].to_i,
|
|
||||||
page: nc_page["page"].to_i,
|
|
||||||
limit: nc_page["limit"].to_i,
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given item info, return a hash of discount-specific info, if any.
|
# Given item info, return a hash of discount-specific info, if any.
|
||||||
NST = Time.find_zone("Pacific Time (US & Canada)")
|
|
||||||
def self.parse_item_discount(item_info)
|
def self.parse_item_discount(item_info)
|
||||||
discount_price = item_info["discountPrice"]
|
discount_price = item_info["discountPrice"]
|
||||||
return nil unless discount_price.present? && discount_price > 0
|
return nil unless discount_price.present? && discount_price > 0
|
||||||
|
|
||||||
{
|
{
|
||||||
price: discount_price,
|
price: discount_price,
|
||||||
begins_at: NST.at(item_info["discountBegin"]),
|
begins_at: item_info["discountBegin"],
|
||||||
ends_at: NST.at(item_info["discountEnd"]),
|
ends_at: item_info["discountEnd"],
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
|
require "async/http/internet/instance"
|
||||||
|
|
||||||
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
|
# While most of our NeoPass logic is built into Devise -> OmniAuth -> OIDC
|
||||||
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
|
# OmniAuth plugin, NeoPass also offers some supplemental APIs that we use here.
|
||||||
module Neopets::NeoPass
|
module Neopets::NeoPass
|
||||||
|
# Share a pool of persistent connections, rather than reconnecting on
|
||||||
|
# each request. (This library does that automatically!)
|
||||||
|
INTERNET = Async::HTTP::Internet.instance
|
||||||
|
|
||||||
def self.load_main_neopets_username(access_token)
|
def self.load_main_neopets_username(access_token)
|
||||||
linkages = load_linkages(access_token)
|
linkages = load_linkages(access_token)
|
||||||
|
|
||||||
|
|
@ -26,10 +32,10 @@ module Neopets::NeoPass
|
||||||
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
|
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
|
||||||
def self.load_linkages(access_token)
|
def self.load_linkages(access_token)
|
||||||
linkages_str = Sync do
|
linkages_str = Sync do
|
||||||
DTIRequests.get(
|
INTERNET.get(LINKAGE_URL, [
|
||||||
LINKAGE_URL,
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
[["Authorization", "Bearer #{access_token}"]],
|
["Authorization", "Bearer #{access_token}"],
|
||||||
) do |response|
|
]) do |response|
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
raise ResponseNotOK.new(response.status),
|
raise ResponseNotOK.new(response.status),
|
||||||
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
|
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
require "addressable/uri"
|
require "addressable/uri"
|
||||||
|
require "async/http/internet/instance"
|
||||||
require "json"
|
require "json"
|
||||||
|
|
||||||
# The Neopets Media Archive is a service that mirrors images.neopets.com files
|
# The Neopets Media Archive is a service that mirrors images.neopets.com files
|
||||||
|
|
@ -10,6 +11,10 @@ require "json"
|
||||||
# long-term archive, not dependent on their services having 100% uptime in
|
# long-term archive, not dependent on their services having 100% uptime in
|
||||||
# order for us to operate. We never discard old files, we just keep going!
|
# order for us to operate. We never discard old files, we just keep going!
|
||||||
module NeopetsMediaArchive
|
module NeopetsMediaArchive
|
||||||
|
# Share a pool of persistent connections, rather than reconnecting on
|
||||||
|
# each request. (This library does that automatically!)
|
||||||
|
INTERNET = Async::HTTP::Internet.instance
|
||||||
|
|
||||||
ROOT_PATH = Pathname.new(Rails.configuration.neopets_media_archive_root)
|
ROOT_PATH = Pathname.new(Rails.configuration.neopets_media_archive_root)
|
||||||
|
|
||||||
# Load the file from the given `images.neopets.com` URI.
|
# Load the file from the given `images.neopets.com` URI.
|
||||||
|
|
@ -67,7 +72,9 @@ module NeopetsMediaArchive
|
||||||
# We use this in the `swf_assets:manifests:load` task to perform many
|
# We use this in the `swf_assets:manifests:load` task to perform many
|
||||||
# requests in parallel!
|
# requests in parallel!
|
||||||
Sync do
|
Sync do
|
||||||
DTIRequests.get(uri) do |response|
|
INTERNET.get(uri, [
|
||||||
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
|
]) do |response|
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
raise ResponseNotOK.new(response.status),
|
raise ResponseNotOK.new(response.status),
|
||||||
"expected status 200 but got #{response.status} (#{uri})"
|
"expected status 200 but got #{response.status} (#{uri})"
|
||||||
|
|
|
||||||
77
app/services/owls_value_guide.rb
Normal file
77
app/services/owls_value_guide.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
module OwlsValueGuide
|
||||||
|
include HTTParty
|
||||||
|
|
||||||
|
ITEMDATA_URL_TEMPLATE = Addressable::Template.new(
|
||||||
|
"https://neo-owls.net/itemdata/{item_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def self.find_by_name(item_name)
|
||||||
|
# Load the itemdata, pulling from the Rails cache if possible.
|
||||||
|
cache_key = "OwlsValueGuide/itemdata/#{item_name}"
|
||||||
|
data = Rails.cache.fetch(cache_key, expires_in: 1.day) do
|
||||||
|
load_itemdata(item_name)
|
||||||
|
end
|
||||||
|
|
||||||
|
if data == :not_found
|
||||||
|
raise NotFound
|
||||||
|
end
|
||||||
|
|
||||||
|
# Owls has records of some items that it explicitly marks as having no
|
||||||
|
# listed value. We don't care about that distinction, just return nil!
|
||||||
|
return nil if data['owls_value'].blank?
|
||||||
|
|
||||||
|
Value.new(data['owls_value'], parse_last_updated(data['last_updated']))
|
||||||
|
end
|
||||||
|
|
||||||
|
Value = Struct.new(:value_text, :updated_at)
|
||||||
|
|
||||||
|
class Error < StandardError;end
|
||||||
|
class NetworkError < Error;end
|
||||||
|
class NotFound < Error;end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def self.load_itemdata(item_name)
|
||||||
|
Rails.logger.info "[OwlsValueGuide] Loading value for #{item_name.inspect}"
|
||||||
|
|
||||||
|
url = ITEMDATA_URL_TEMPLATE.expand(item_name: item_name)
|
||||||
|
begin
|
||||||
|
res = get(url, headers: {
|
||||||
|
"User-Agent" => Rails.configuration.user_agent_for_neopets,
|
||||||
|
})
|
||||||
|
rescue StandardError => error
|
||||||
|
raise NetworkError, "Couldn't connect to Owls: #{error.message}"
|
||||||
|
end
|
||||||
|
|
||||||
|
if res.code == 404
|
||||||
|
# Instead of raising immediately, return `:not_found` to save this
|
||||||
|
# result in the cache, then raise *after* we exit the cache block. That
|
||||||
|
# way, we won't make repeat requests for items we have that Owls
|
||||||
|
# doesn't.
|
||||||
|
return :not_found
|
||||||
|
end
|
||||||
|
|
||||||
|
if res.code != 200
|
||||||
|
raise NetworkError, "Owls returned status code #{res.code} (expected 200)"
|
||||||
|
end
|
||||||
|
|
||||||
|
begin
|
||||||
|
res.parsed_response
|
||||||
|
rescue HTTParty::Error => error
|
||||||
|
raise NetworkError, "Owls returned unsupported data format: #{error.message}"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def self.parse_last_updated(date_str)
|
||||||
|
return nil if date_str.blank?
|
||||||
|
|
||||||
|
begin
|
||||||
|
Date.strptime(date_str, '%Y-%m-%d')
|
||||||
|
rescue Date::Error
|
||||||
|
Rails.logger.error(
|
||||||
|
"[OwlsValueGuide] unexpected last_updated format: #{date_str.inspect}"
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
%li
|
%li
|
||||||
= link_to view_or_edit_alt_style_url(alt_style) do
|
= link_to view_or_edit_alt_style_url(alt_style) do
|
||||||
= image_tag alt_style.preview_image_url, class: "preview", loading: "lazy"
|
= image_tag alt_style.preview_image_url, class: "preview", loading: "lazy"
|
||||||
.name= alt_style.full_name
|
.name
|
||||||
|
%span= alt_style.series_name
|
||||||
|
%span= alt_style.pet_name
|
||||||
.info
|
.info
|
||||||
%p
|
%p
|
||||||
Added
|
Added
|
||||||
= time_tag alt_style.created_at,
|
= time_tag alt_style.created_at,
|
||||||
title: alt_style.created_at.to_formatted_s(:long_nst) do
|
title: alt_style.created_at.to_formatted_s(:long_nst) do
|
||||||
= time_with_only_month_if_old alt_style.created_at
|
= time_with_only_month_if_old alt_style.created_at
|
||||||
- if support_staff? && !alt_style.real_series_name?
|
|
||||||
%p ⚠️ Needs series name
|
|
||||||
|
|
@ -13,29 +13,28 @@
|
||||||
|
|
||||||
= image_tag @alt_style.preview_image_url, class: "alt-style-preview"
|
= image_tag @alt_style.preview_image_url, class: "alt-style-preview"
|
||||||
|
|
||||||
= support_form_with model: @alt_style do |f|
|
= form_with model: @alt_style, class: "alt-style-form" do |f|
|
||||||
= f.errors
|
- if @alt_style.errors.any?
|
||||||
|
%p
|
||||||
= f.fields do
|
Could not save:
|
||||||
= f.field do
|
%ul.errors
|
||||||
= f.label :real_series_name, "Series"
|
- @alt_style.errors.each do |error|
|
||||||
= f.text_field :real_series_name, autofocus: !@alt_style.real_series_name?,
|
%li= error.full_message
|
||||||
placeholder: AltStyle.placeholder_name
|
%fieldset
|
||||||
|
= f.label :real_series_name, "Series"
|
||||||
= f.field do
|
= f.text_field :real_series_name
|
||||||
= f.label :real_series_name, "Full name"
|
= f.label :thumbnail_url, "Thumbnail"
|
||||||
= f.text_field :real_full_name, placeholder: @alt_style.fallback_full_name
|
.thumbnail-field
|
||||||
|
- if @alt_style.thumbnail_url?
|
||||||
= f.field do
|
= image_tag @alt_style.thumbnail_url
|
||||||
= f.label :thumbnail_url, "Thumbnail"
|
= f.url_field :thumbnail_url
|
||||||
= f.thumbnail_input :thumbnail_url
|
.actions
|
||||||
|
|
||||||
= f.actions do
|
|
||||||
= f.submit "Save changes"
|
= f.submit "Save changes"
|
||||||
= f.go_to_next_field title: "If checked, takes you to the next unlabeled pet style, if any. Useful for labeling in bulk!" do
|
%label{title: "If checked, takes you to the next unlabeled pet style, if any. Useful for labeling in bulk!"}
|
||||||
= f.go_to_next_check_box "unlabeled-style"
|
= check_box_tag "next", "unlabeled-style",
|
||||||
|
checked: params[:next] == "unlabeled-style"
|
||||||
Then: Go to unlabeled style
|
Then: Go to unlabeled style
|
||||||
|
|
||||||
- content_for :stylesheets do
|
- content_for :stylesheets do
|
||||||
= stylesheet_link_tag "application/breadcrumbs", "application/support-form"
|
= stylesheet_link_tag "application/breadcrumbs"
|
||||||
= page_stylesheet_link_tag "alt_styles/edit"
|
= page_stylesheet_link_tag "alt_styles/edit"
|
||||||
|
|
|
||||||
|
|
@ -22,16 +22,6 @@
|
||||||
|
|
||||||
[1]: https://www.neopets.com/mall/stylingstudio/
|
[1]: https://www.neopets.com/mall/stylingstudio/
|
||||||
|
|
||||||
- if support_staff?
|
|
||||||
%p
|
|
||||||
%strong 💡 Support summary:
|
|
||||||
✅ #{number_with_delimiter @counts[:labeled]} labeled
|
|
||||||
- if @unlabeled_style
|
|
||||||
+ ❓️
|
|
||||||
= link_to "#{number_with_delimiter @counts[:unlabeled]} unlabeled",
|
|
||||||
edit_alt_style_path(@unlabeled_style, next: "unlabeled-style")
|
|
||||||
\= #{number_with_delimiter @counts[:total]} total
|
|
||||||
|
|
||||||
= form_with url: alt_styles_path, method: :get,
|
= form_with url: alt_styles_path, method: :get,
|
||||||
class: "rainbow-pool-filters" do |f|
|
class: "rainbow-pool-filters" do |f|
|
||||||
%fieldset
|
%fieldset
|
||||||
|
|
@ -44,12 +34,11 @@
|
||||||
selected: @species&.human_name, include_blank: "Species…"
|
selected: @species&.human_name, include_blank: "Species…"
|
||||||
= f.submit "Go", name: nil
|
= f.submit "Go", name: nil
|
||||||
|
|
||||||
- if @alt_styles.present?
|
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
||||||
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
|
||||||
%ul.rainbow-pool-list= render @alt_styles
|
%ul.rainbow-pool-list= render @alt_styles
|
||||||
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
|
||||||
- else
|
= will_paginate @alt_styles, class: "rainbow-pool-pagination"
|
||||||
%p.rainbow-pool-no-results We don't have any styles matching that search.
|
|
||||||
|
|
||||||
- content_for :stylesheets do
|
- content_for :stylesheets do
|
||||||
= stylesheet_link_tag "application/breadcrumbs"
|
= stylesheet_link_tag "application/breadcrumbs"
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,7 @@
|
||||||
}
|
}
|
||||||
- if swf_asset.canvas_movie?
|
- if swf_asset.canvas_movie?
|
||||||
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
|
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
|
||||||
- elsif preferred_image_format == :svg && swf_asset.svg_url?
|
- elsif swf_asset.image_url.present?
|
||||||
= image_tag swf_asset.svg_url, alt: "", loading: "lazy"
|
|
||||||
- elsif swf_asset.image_url?
|
|
||||||
= image_tag swf_asset.image_url, alt: "", loading: "lazy"
|
= image_tag swf_asset.image_url, alt: "", loading: "lazy"
|
||||||
- else
|
- else
|
||||||
/ No movie or image available for SWF asset: #{swf_asset.url}
|
/ No movie or image available for SWF asset: #{swf_asset.url}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
- if form.object.errors.any?
|
|
||||||
%section.errors
|
|
||||||
Could not save:
|
|
||||||
|
|
||||||
%ul
|
|
||||||
- form.object.errors.each do |error|
|
|
||||||
%li= error.full_message
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
= form.field("data-type": "radio", **options) do
|
|
||||||
%fieldset
|
|
||||||
%legend= legend
|
|
||||||
%ul= content
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
- url = form.object.send(method)
|
|
||||||
.thumbnail-input
|
|
||||||
- if url.present?
|
|
||||||
= image_tag url, alt: "Thumbnail"
|
|
||||||
= form.url_field method
|
|
||||||
|
|
@ -31,14 +31,6 @@
|
||||||
= f.label :contact_neopets_connection_id
|
= f.label :contact_neopets_connection_id
|
||||||
= f.collection_select :contact_neopets_connection_id, @user.neopets_connections, :id, :neopets_username, {include_blank: true}, 'data-new-text' => t('.neopets_username.new'), 'data-new-prompt' => t('.neopets_username.prompt')
|
= f.collection_select :contact_neopets_connection_id, @user.neopets_connections, :id, :neopets_username, {include_blank: true}, 'data-new-text' => t('.neopets_username.new'), 'data-new-prompt' => t('.neopets_username.prompt')
|
||||||
= f.submit t('.neopets_username.submit')
|
= f.submit t('.neopets_username.submit')
|
||||||
- if support_staff?
|
|
||||||
= link_to "✏️ #{t('.support')}", edit_user_path(@user)
|
|
||||||
|
|
||||||
- if support_staff? && @user.shadowbanned?
|
|
||||||
%p.warning
|
|
||||||
%strong 🕶️ Shadowbanned:
|
|
||||||
For most users, this page is hidden, but you can still see them because
|
|
||||||
you're Support staff.
|
|
||||||
|
|
||||||
- unless public_perspective?
|
- unless public_perspective?
|
||||||
%noscript
|
%noscript
|
||||||
|
|
|
||||||
|
|
@ -17,17 +17,11 @@
|
||||||
%th= t(".table.headings.user.#{@type}")
|
%th= t(".table.headings.user.#{@type}")
|
||||||
%th= t(".table.headings.lists")
|
%th= t(".table.headings.lists")
|
||||||
%tbody
|
%tbody
|
||||||
- prev_trade = nil
|
|
||||||
- sorted_vaguely_by_trade_activity(@trades).each do |trade|
|
- sorted_vaguely_by_trade_activity(@trades).each do |trade|
|
||||||
%tr
|
%tr
|
||||||
%td{
|
|
||||||
'data-is-same-as-prev': same_vague_trade_timestamp?(trade, prev_trade)
|
|
||||||
}
|
|
||||||
= vague_trade_timestamp trade
|
|
||||||
%td
|
%td
|
||||||
= trade.user.name
|
= vague_trade_timestamp trade.user.last_trade_activity_at
|
||||||
- if support_staff? && trade.user.shadowbanned?
|
%td= trade.user.name
|
||||||
%abbr{title: "Shadowbanned (Hidden from most viewers; you can see because you're Support staff"} 🕶️ SBan
|
|
||||||
%td
|
%td
|
||||||
- if trade.lists.present?
|
- if trade.lists.present?
|
||||||
%ul.trade-list-names
|
%ul.trade-list-names
|
||||||
|
|
@ -38,7 +32,6 @@
|
||||||
= link_to t(".table.not_in_a_list.#{@type}"), user_closet_hangers_path(trade.user,
|
= link_to t(".table.not_in_a_list.#{@type}"), user_closet_hangers_path(trade.user,
|
||||||
anchor: "closet-hangers-group-#{@type == :offering}"),
|
anchor: "closet-hangers-group-#{@type == :offering}"),
|
||||||
class: "not-in-a-list"
|
class: "not-in-a-list"
|
||||||
- prev_trade = trade
|
|
||||||
- else
|
- else
|
||||||
%p= t(".no_trades_yet")
|
%p= t(".no_trades_yet")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,13 +33,9 @@
|
||||||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||||
- if item.nc_trade_value
|
- if item.nc_trade_value
|
||||||
= link_to lebron_url_for(item),
|
= link_to t('items.show.resources.owls', value: item.nc_trade_value.value_text),
|
||||||
title: nc_trade_value_updated_at_text(item.nc_trade_value) do
|
"https://www.neopets.com/~owls",
|
||||||
= t 'items.show.resources.lebron_value',
|
title: nc_trade_value_updated_at_text(item.nc_trade_value)
|
||||||
value: nc_trade_value_estimate_text(item.nc_trade_value)
|
|
||||||
- elsif item.nc?
|
|
||||||
= link_to lebron_url_for(item) do
|
|
||||||
= t 'items.show.resources.lebron'
|
|
||||||
- unless item.nc?
|
- unless item.nc?
|
||||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
||||||
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||||
|
|
@ -50,8 +46,6 @@
|
||||||
= link_to t('items.show.closet_hangers.button'),
|
= link_to t('items.show.closet_hangers.button'),
|
||||||
user_closet_hangers_path(current_user),
|
user_closet_hangers_path(current_user),
|
||||||
class: 'user-lists-form-opener'
|
class: 'user-lists-form-opener'
|
||||||
- if support_staff?
|
|
||||||
= link_to "Edit", edit_item_path(item)
|
|
||||||
|
|
||||||
- if user_signed_in?
|
- if user_signed_in?
|
||||||
= form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do
|
= form_tag update_quantities_user_item_closet_hangers_path(user_id: current_user, item_id: item), method: :put, class: 'user-lists-form', hidden: item_header_user_lists_form_state != "open" do
|
||||||
|
|
|
||||||
|
|
@ -1,58 +0,0 @@
|
||||||
- title "Editing \"#{@item.name}\""
|
|
||||||
- use_responsive_design
|
|
||||||
|
|
||||||
%h1#title Editing "#{@item.name}"
|
|
||||||
|
|
||||||
:markdown
|
|
||||||
Heads up: the modeling process controls some of these fields by default! If
|
|
||||||
you change something, but it doesn't match what we're seeing on Neopets.com,
|
|
||||||
it will probably be reverted automatically when someone models it.
|
|
||||||
|
|
||||||
= support_form_with model: @item do |f|
|
|
||||||
= f.errors
|
|
||||||
|
|
||||||
= f.fields do
|
|
||||||
= f.field do
|
|
||||||
= f.label :name
|
|
||||||
= f.text_field :name
|
|
||||||
|
|
||||||
= f.field do
|
|
||||||
= f.label :thumbnail_url, "Thumbnail"
|
|
||||||
= f.thumbnail_input :thumbnail_url
|
|
||||||
|
|
||||||
= f.field do
|
|
||||||
= f.label :description
|
|
||||||
= f.text_field :description
|
|
||||||
|
|
||||||
= f.radio_fieldset "Item kind" do
|
|
||||||
= f.radio_field title: "NC items generally have a rarity value of 500.\nPaintbrush items generally contain a special message in the description." do
|
|
||||||
= f.radio_button :is_manually_nc, false
|
|
||||||
Automatic: Based on rarity and description
|
|
||||||
= f.radio_field title: "Use this when Neopets releases an NC item, but labels the rarity as something other than 500, usually by mistake." do
|
|
||||||
= f.radio_button :is_manually_nc, true
|
|
||||||
Manually NC: From the NC Mall, but not r500
|
|
||||||
|
|
||||||
= f.radio_fieldset "Modeling status" do
|
|
||||||
= f.radio_field title: "If we fit two or more species of a standard color, assume we also fit the other standard-color pets that were released at the time.\nRepeat for special colors like Baby and Maraquan." do
|
|
||||||
= f.radio_button :modeling_status_hint, ""
|
|
||||||
Automatic: Fits 2+ species → Should fit all
|
|
||||||
= f.radio_field title: "Use this when e.g. there simply is no Acara version of the item." do
|
|
||||||
= f.radio_button :modeling_status_hint, "done"
|
|
||||||
Done: Neopets.com is missing some models
|
|
||||||
= f.radio_field title: "Use this when e.g. this fits the Blue Vandagyre even though it's a Maraquan item.\nBehaves identically to Done, but helps us remember why we did this!" do
|
|
||||||
= f.radio_button :modeling_status_hint, "glitchy"
|
|
||||||
Glitchy: Neopets.com has <em>too many</em> models
|
|
||||||
|
|
||||||
= f.radio_fieldset "Body fit" do
|
|
||||||
= f.radio_field title: "When an asset in a zone like Background is modeled, assume it fits all pets the same, and assign it body ID \#0.\nOtherwise, assume it fits only the kind of pet it was modeled on." do
|
|
||||||
= f.radio_button :explicitly_body_specific, false
|
|
||||||
Automatic: Some zones fit all species
|
|
||||||
= f.radio_field title: "Use this when an item uses a generally-universal zone like Static, but is body-specific regardless. \"Encased in Ice\" is one example.\nThis prevents these uncommon items from breaking every time they're modeled." do
|
|
||||||
= f.radio_button :explicitly_body_specific, true
|
|
||||||
Body-specific: Fits all species differently
|
|
||||||
|
|
||||||
= f.actions do
|
|
||||||
= f.submit "Save changes"
|
|
||||||
|
|
||||||
- content_for :stylesheets do
|
|
||||||
= page_stylesheet_link_tag "application/support-form"
|
|
||||||
|
|
@ -64,17 +64,17 @@
|
||||||
.item-zones-info
|
.item-zones-info
|
||||||
%section
|
%section
|
||||||
%h3 Occupies
|
%h3 Occupies
|
||||||
- if @appearances_by_occupied_zone_label.present?
|
- if @appearances_by_occupied_zone.present?
|
||||||
%ul
|
%ul
|
||||||
- @appearances_by_occupied_zone_label.each do |label, appearances|
|
- @appearances_by_occupied_zone.each do |zone, appearances_in_zone|
|
||||||
%li<
|
%li<
|
||||||
= label
|
= zone.label
|
||||||
- if item_zone_partial_fit? appearances, @all_appearances
|
- if item_zone_partial_fit? appearances_in_zone, @all_appearances
|
||||||
= " "
|
= " "
|
||||||
%span.zone-species-info{
|
%span.zone-species-info{
|
||||||
title: item_zone_species_list(appearances)
|
title: item_zone_species_list(appearances_in_zone)
|
||||||
}<
|
}<
|
||||||
(#{appearances.size} species)
|
(#{appearances_in_zone.size} species)
|
||||||
- else
|
- else
|
||||||
%span.no-zones (None)
|
%span.no-zones (None)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,7 @@
|
||||||
title: "This recipe is NOT currently scheduled to be removed " +
|
title: "This recipe is NOT currently scheduled to be removed " +
|
||||||
"from Dyeworks. It might not stay forever, but it's also " +
|
"from Dyeworks. It might not stay forever, but it's also " +
|
||||||
"not part of a known limited-time event, like most " +
|
"not part of a known limited-time event, like most " +
|
||||||
"Dyeworks items are. (Thanks Lebron team!)"
|
"Dyeworks items are. (Thanks Owls team!)"
|
||||||
}
|
}
|
||||||
(Always available)
|
(Always available)
|
||||||
- elsif item.dyeworks_limited_final_date.present?
|
- elsif item.dyeworks_limited_final_date.present?
|
||||||
|
|
@ -100,14 +100,14 @@
|
||||||
title: "This recipe is part of a limited-time Dyeworks " +
|
title: "This recipe is part of a limited-time Dyeworks " +
|
||||||
"event. The last day you can dye this is " +
|
"event. The last day you can dye this is " +
|
||||||
"#{item.dyeworks_limited_final_date.to_fs(:long)}. " +
|
"#{item.dyeworks_limited_final_date.to_fs(:long)}. " +
|
||||||
"(Thanks Lebron team!)"
|
"(Thanks Owls team!)"
|
||||||
}
|
}
|
||||||
(Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)})
|
(Limited-time: #{item.dyeworks_limited_final_date.to_fs(:month_and_day)})
|
||||||
- elsif item.dyeworks_limited?
|
- elsif item.dyeworks_limited?
|
||||||
%span.dyeworks-timeframe{
|
%span.dyeworks-timeframe{
|
||||||
title: "This recipe is part of a limited-time Dyeworks " +
|
title: "This recipe is part of a limited-time Dyeworks " +
|
||||||
"event, and is scheduled to be removed from the NC Mall " +
|
"event, and is scheduled to be removed from the NC Mall " +
|
||||||
"soon. (Thanks Lebron team!)"
|
"soon. (Thanks Owls team!)"
|
||||||
}
|
}
|
||||||
(Limited-time)
|
(Limited-time)
|
||||||
|
|
||||||
|
|
@ -169,7 +169,7 @@
|
||||||
alt: "Item thumbnail for #{color.pb_item_name}"
|
alt: "Item thumbnail for #{color.pb_item_name}"
|
||||||
- elsif color
|
- elsif color
|
||||||
= image_tag pet_type_image_url(@pb_color_pet_types[color], size: :face),
|
= image_tag pet_type_image_url(@pb_color_pet_types[color], size: :face),
|
||||||
srcset: ["#{pet_type_image_url(@pb_color_pet_types[color], size: :face_3x)} 2x"],
|
srcset: ["#{pet_type_image_url(@pb_color_pet_types[color], size: :face_2x)} 2x"],
|
||||||
alt: @pb_color_pet_types[color].human_name
|
alt: @pb_color_pet_types[color].human_name
|
||||||
- else
|
- else
|
||||||
= image_tag "https://images.neopets.com/items/starter_red_pb.gif",
|
= image_tag "https://images.neopets.com/items/starter_red_pb.gif",
|
||||||
|
|
@ -210,9 +210,11 @@
|
||||||
|
|
||||||
- if @items[:other_nc].any?(&:nc_trade_value)
|
- if @items[:other_nc].any?(&:nc_trade_value)
|
||||||
:markdown
|
:markdown
|
||||||
The "Lebron NC Values" team keep track of the details about how to get
|
The [Owls Value Guide][owls] often has more details about how to get
|
||||||
these items, and how much they're usually worth in the NC Trading
|
these items, and how much they're usually worth in the NC Trading
|
||||||
community. We've loaded their info here for you, too! Thanks, Lebron team!
|
community. We've loaded their info here for you, too! Thanks, Owls team!
|
||||||
|
|
||||||
|
[owls]: https://www.neopets.com/~owls
|
||||||
|
|
||||||
%table.item-list{"data-complexity": complexity_for(@items[:other_nc])}
|
%table.item-list{"data-complexity": complexity_for(@items[:other_nc])}
|
||||||
%thead
|
%thead
|
||||||
|
|
@ -225,17 +227,17 @@
|
||||||
- content_for :subtitle, flush: true do
|
- content_for :subtitle, flush: true do
|
||||||
- if item.nc_trade_value.present?
|
- if item.nc_trade_value.present?
|
||||||
- if nc_trade_value_is_estimate(item.nc_trade_value)
|
- if nc_trade_value_is_estimate(item.nc_trade_value)
|
||||||
= link_to 'https://www.neopets.com/~lebron', target: '_blank',
|
= link_to "https://www.neopets.com/~owls",
|
||||||
class: "nc-trade-guide-info-link",
|
class: "owls-info-link", target: "_blank",
|
||||||
title: 'Lebron keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do
|
title: 'Owls keeps track of approximate "capsule" values of NC items for trading. Items with similar values can often be traded for one another. This is an estimate, not a rule!' do
|
||||||
%span.nc-trade-guide-info-label [Lebron]
|
%span.owls-info-label [Owls]
|
||||||
Estimated value:
|
Estimated value:
|
||||||
= nc_trade_value_estimate_text(item.nc_trade_value)
|
= nc_trade_value_estimate_text(item.nc_trade_value)
|
||||||
- else
|
- else
|
||||||
= link_to 'https://www.neopets.com/~lebron', target: '_blank',
|
= link_to "https://www.neopets.com/~owls",
|
||||||
class: "nc-trade-guide-info-link",
|
class: "owls-info-link", target: "_blank",
|
||||||
title: "Lebron keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
|
title: "Owls keeps track of how to get certain special items, even when there isn't a clear NC trade estimate." do
|
||||||
%span.nc-trade-guide-info-label [Lebron]
|
%span.owls-info-label [Owls]
|
||||||
Trade info:
|
Trade info:
|
||||||
#{item.nc_trade_value.value_text}
|
#{item.nc_trade_value.value_text}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,19 +4,20 @@
|
||||||
|
|
||||||
%p#pet-not-found.alert= t 'pets.load.not_found'
|
%p#pet-not-found.alert= t 'pets.load.not_found'
|
||||||
|
|
||||||
- hide_after Date.new(2024, 12, 8) do
|
- if show_announcement?
|
||||||
%section.announcement
|
%section.announcement
|
||||||
= image_tag "about/announcement.png", width: 70, height: 70,
|
= image_tag "about/announcement.png", width: 70, height: 70,
|
||||||
srcset: {"about/announcement@2x.png": "2x"}
|
srcset: {"about/announcement@2x.png": "2x"}
|
||||||
.content
|
.content
|
||||||
%p
|
%p
|
||||||
%strong Oh wow, it's busy this time of year!
|
%strong
|
||||||
We've temporarily moved to a bigger server, to help us handle the extra
|
🎃
|
||||||
load. Hopefully this keeps us running smooth!
|
= link_to "New pet styles are out today!", alt_styles_path
|
||||||
|
If you've seen one we don't have yet, please model it by entering the
|
||||||
|
pet's name in the box below. Thank you!!
|
||||||
%p
|
%p
|
||||||
Happy holidays, everyone! Here's hoping you, and your families, and your
|
By the way, we had a bug where modeling new styles wasn't working for a
|
||||||
precious pets—both online and off—stay happy and healthy for the year
|
little while. Fixed now! 🤞
|
||||||
to come 💜
|
|
||||||
|
|
||||||
#outfit-forms
|
#outfit-forms
|
||||||
#pet-preview
|
#pet-preview
|
||||||
|
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
= content_tag "support-outfit-viewer", **html_options do
|
|
||||||
%div
|
|
||||||
.outfit-viewer-area
|
|
||||||
%magic-magnifier{"data-format": "svg"}
|
|
||||||
= outfit_viewer outfit, preferred_image_format: :svg
|
|
||||||
%magic-magnifier{"data-format": "png"}
|
|
||||||
= outfit_viewer outfit, preferred_image_format: :png
|
|
||||||
|
|
||||||
= form_with method: :get, class: "outfit-viewer-controls" do |f|
|
|
||||||
%fieldset
|
|
||||||
%legend Format
|
|
||||||
%label
|
|
||||||
= f.radio_button "preferred_image_format", "svg",
|
|
||||||
checked: true
|
|
||||||
SVG
|
|
||||||
%label
|
|
||||||
= f.radio_button "preferred_image_format", "png"
|
|
||||||
PNG
|
|
||||||
|
|
||||||
%table
|
|
||||||
%thead
|
|
||||||
%tr
|
|
||||||
%th{scope: "col"} DTI ID
|
|
||||||
%th{scope: "col"} Zone
|
|
||||||
%th{scope: "col"} Links
|
|
||||||
%tbody
|
|
||||||
- outfit.visible_layers.each do |swf_asset|
|
|
||||||
%tr
|
|
||||||
%th{scope: "row", "data-field": "id"}
|
|
||||||
= swf_asset.id
|
|
||||||
%td
|
|
||||||
= swf_asset.zone.label
|
|
||||||
(##{swf_asset.zone.id})
|
|
||||||
%td{"data-field": "links"}
|
|
||||||
%ul
|
|
||||||
- if swf_asset.image_url?
|
|
||||||
%li= link_to "PNG", swf_asset.image_url, target: "_blank"
|
|
||||||
- if swf_asset.svg_url?
|
|
||||||
%li= link_to "SVG", swf_asset.svg_url, target: "_blank"
|
|
||||||
%li= link_to "SWF", swf_asset.url, target: "_blank"
|
|
||||||
- if swf_asset.manifest_url?
|
|
||||||
%li= link_to "Manifest", swf_asset.manifest_url, target: "_blank"
|
|
||||||
|
|
@ -5,54 +5,44 @@
|
||||||
%li
|
%li
|
||||||
= link_to "Rainbow Pool", pet_types_path
|
= link_to "Rainbow Pool", pet_types_path
|
||||||
%li
|
%li
|
||||||
= link_to @pet_type.possibly_new_color.human_name,
|
= link_to @pet_type.color.human_name,
|
||||||
pet_types_path(color: @pet_type.possibly_new_color.human_name)
|
pet_types_path(color: @pet_type.color.human_name)
|
||||||
%li{"data-relation-to-prev": "sibling"}
|
%li{"data-relation-to-prev": "sibling"}
|
||||||
= link_to @pet_type.possibly_new_species.human_name,
|
= link_to @pet_type.species.human_name,
|
||||||
pet_types_path(species: @pet_type.possibly_new_species.human_name)
|
pet_types_path(species: @pet_type.species.human_name)
|
||||||
%li
|
%li
|
||||||
= link_to "Appearances", @pet_type
|
= link_to "Appearances", @pet_type
|
||||||
%li
|
%li
|
||||||
\##{@pet_state.id}
|
\##{@pet_state.id}
|
||||||
|
|
||||||
= support_outfit_viewer pet_state: @pet_state
|
= outfit_viewer pet_state: @pet_state
|
||||||
|
|
||||||
= support_form_with model: [@pet_type, @pet_state] do |f|
|
= form_with model: [@pet_type, @pet_state] do |f|
|
||||||
= f.errors
|
- if @pet_state.errors.any?
|
||||||
|
%p
|
||||||
= f.fields do
|
Could not save:
|
||||||
= f.radio_grid_fieldset "Pose" do
|
%ul.errors
|
||||||
- pose_options.each do |pose|
|
- @pet_state.errors.each do |error|
|
||||||
= f.radio_field do
|
%li= error.full_message
|
||||||
= f.radio_button :pose, pose
|
%dl
|
||||||
= pose_name(pose)
|
%dt Pose
|
||||||
- if @reference_pet_type
|
%dd
|
||||||
= link_to @reference_pet_type, target: "_blank", class: "reference-link" do
|
%ul.pose-options
|
||||||
= pet_type_image @reference_pet_type, :happy, :face
|
- pose_options.each do |pose|
|
||||||
%span Reference: #{@reference_pet_type.human_name}
|
%li
|
||||||
= external_link_icon
|
%label
|
||||||
|
= f.radio_button :pose, pose
|
||||||
= f.field do
|
= pose_name pose
|
||||||
= f.label :glitched, "Glitched?"
|
%dt Glitched?
|
||||||
|
%dd
|
||||||
= f.select :glitched, [["✅ Not marked as Glitched", false],
|
= f.select :glitched, [["✅ Not marked as Glitched", false],
|
||||||
["👾 Yes, it's bad news bonko'd", true]]
|
["👾 Yes, it's bad news bonko'd", true]]
|
||||||
|
= f.submit "Save"
|
||||||
= f.actions do
|
|
||||||
= f.submit "Save changes"
|
|
||||||
= f.go_to_next_field after: @pet_state.id,
|
|
||||||
title: "If checked, takes you to the first unlabeled appearance in the database, if any. Useful for labeling in bulk!" do
|
|
||||||
= f.go_to_next_check_box "unlabeled-appearance"
|
|
||||||
Then: Go to next unlabeled appearance
|
|
||||||
|
|
||||||
- content_for :stylesheets do
|
- content_for :stylesheets do
|
||||||
= stylesheet_link_tag "application/breadcrumbs"
|
= stylesheet_link_tag "application/breadcrumbs"
|
||||||
= stylesheet_link_tag "application/magic-magnifier"
|
|
||||||
= stylesheet_link_tag "application/outfit-viewer"
|
= stylesheet_link_tag "application/outfit-viewer"
|
||||||
= stylesheet_link_tag "application/support-form"
|
|
||||||
= stylesheet_link_tag "pet_states/support-outfit-viewer"
|
|
||||||
= page_stylesheet_link_tag "pet_states/edit"
|
= page_stylesheet_link_tag "pet_states/edit"
|
||||||
|
|
||||||
- content_for :javascripts do
|
- content_for :javascripts do
|
||||||
= javascript_include_tag "magic-magnifier"
|
|
||||||
= javascript_include_tag "outfit-viewer"
|
= javascript_include_tag "outfit-viewer"
|
||||||
= javascript_include_tag "pet_states/support-outfit-viewer"
|
|
||||||
|
|
|
||||||
|
|
@ -10,18 +10,6 @@
|
||||||
|
|
||||||
[1]: #{alt_styles_path}
|
[1]: #{alt_styles_path}
|
||||||
|
|
||||||
- if support_staff?
|
|
||||||
%p
|
|
||||||
%strong 💡 Support summary:
|
|
||||||
✅ #{number_with_delimiter @counts[:usable]} usable
|
|
||||||
+ 👾 #{number_with_delimiter @counts[:glitched]} glitched
|
|
||||||
- if @unlabeled_appearance
|
|
||||||
+ ❓️
|
|
||||||
= link_to "#{number_with_delimiter @counts[:needs_labeling]} unknown",
|
|
||||||
edit_pet_type_pet_state_path(@unlabeled_appearance.pet_type,
|
|
||||||
@unlabeled_appearance, next: "unlabeled-appearance")
|
|
||||||
\= #{number_with_delimiter @counts[:total]} total
|
|
||||||
|
|
||||||
= form_with method: :get, class: "rainbow-pool-filters" do |form|
|
= form_with method: :get, class: "rainbow-pool-filters" do |form|
|
||||||
%fieldset
|
%fieldset
|
||||||
%legend Filter by:
|
%legend Filter by:
|
||||||
|
|
@ -34,7 +22,7 @@
|
||||||
%ui.rainbow-pool-list= render @pet_types
|
%ui.rainbow-pool-list= render @pet_types
|
||||||
= will_paginate @pet_types, class: "rainbow-pool-pagination"
|
= will_paginate @pet_types, class: "rainbow-pool-pagination"
|
||||||
- else
|
- else
|
||||||
%p.rainbow-pool-no-results We don't have any pets matching that search.
|
%p.rainbow-pool-no-results No matching pets found!
|
||||||
|
|
||||||
- content_for :stylesheets do
|
- content_for :stylesheets do
|
||||||
= stylesheet_link_tag "application/rainbow-pool"
|
= stylesheet_link_tag "application/rainbow-pool"
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,11 @@
|
||||||
%li
|
%li
|
||||||
= link_to "Rainbow Pool", pet_types_path
|
= link_to "Rainbow Pool", pet_types_path
|
||||||
%li
|
%li
|
||||||
= link_to @pet_type.possibly_new_color.human_name,
|
= link_to @pet_type.color.human_name,
|
||||||
pet_types_path(color: @pet_type.possibly_new_color.human_name)
|
pet_types_path(color: @pet_type.color.human_name)
|
||||||
%li{"data-relation-to-prev": "sibling"}
|
%li{"data-relation-to-prev": "sibling"}
|
||||||
= link_to @pet_type.possibly_new_species.human_name,
|
= link_to @pet_type.species.human_name,
|
||||||
pet_types_path(species: @pet_type.possibly_new_species.human_name)
|
pet_types_path(species: @pet_type.species.human_name)
|
||||||
%li
|
%li
|
||||||
Appearances
|
Appearances
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,40 +0,0 @@
|
||||||
- title @user.name
|
|
||||||
- use_responsive_design
|
|
||||||
|
|
||||||
%ol.breadcrumbs
|
|
||||||
%li Users
|
|
||||||
%li= link_to @user.name, user_closet_hangers_path(@user)
|
|
||||||
|
|
||||||
= support_form_with model: @user do |f|
|
|
||||||
= f.errors
|
|
||||||
|
|
||||||
= f.fields do
|
|
||||||
= f.field do
|
|
||||||
= f.label :name
|
|
||||||
= f.text_field :name
|
|
||||||
|
|
||||||
= f.radio_fieldset "Item list visibility" do
|
|
||||||
= f.radio_field do
|
|
||||||
= f.radio_button :shadowbanned, false
|
|
||||||
%strong 👁️ Visible:
|
|
||||||
Everyone can see page and trades
|
|
||||||
= f.radio_field do
|
|
||||||
= f.radio_button :shadowbanned, true
|
|
||||||
%strong 🕶️ Shadowbanned:
|
|
||||||
Page and trades hidden from other users/IPs
|
|
||||||
|
|
||||||
= f.radio_fieldset "Account role" do
|
|
||||||
= f.radio_field do
|
|
||||||
= f.radio_button :support_staff, false
|
|
||||||
%strong 👤 User:
|
|
||||||
Can manage their own data
|
|
||||||
= f.radio_field do
|
|
||||||
= f.radio_button :support_staff, true
|
|
||||||
%strong 💖 Support:
|
|
||||||
Can manage other users' data and customization data
|
|
||||||
|
|
||||||
= f.actions do
|
|
||||||
= f.submit "Save changes"
|
|
||||||
|
|
||||||
- content_for :stylesheets do
|
|
||||||
= stylesheet_link_tag "application/breadcrumbs", "application/support-form"
|
|
||||||
6
bin/ci
6
bin/ci
|
|
@ -1,6 +0,0 @@
|
||||||
#!/usr/bin/env ruby
|
|
||||||
require_relative "../config/boot"
|
|
||||||
require "active_support/continuous_integration"
|
|
||||||
|
|
||||||
CI = ActiveSupport::ContinuousIntegration
|
|
||||||
require_relative "../config/ci.rb"
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
#!/usr/bin/env bash
|
|
||||||
# Rollback to a previous version in production.
|
|
||||||
cd $(dirname $0)/../deploy && ansible-playbook rollback.yml --extra-vars="new_app_version=$1"
|
|
||||||
|
|
@ -1,8 +0,0 @@
|
||||||
#!/usr/bin/env ruby
|
|
||||||
require "rubygems"
|
|
||||||
require "bundler/setup"
|
|
||||||
|
|
||||||
# Explicit RuboCop config increases performance slightly while avoiding config confusion.
|
|
||||||
ARGV.unshift("--config", File.expand_path("../.rubocop.yml", __dir__))
|
|
||||||
|
|
||||||
load Gem.bin_path("rubocop", "rubocop")
|
|
||||||
24
bin/setup
24
bin/setup
|
|
@ -1,6 +1,7 @@
|
||||||
#!/usr/bin/env ruby
|
#!/usr/bin/env ruby
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
|
||||||
|
# path to your application root.
|
||||||
APP_ROOT = File.expand_path("..", __dir__)
|
APP_ROOT = File.expand_path("..", __dir__)
|
||||||
|
|
||||||
def system!(*args)
|
def system!(*args)
|
||||||
|
|
@ -12,7 +13,8 @@ FileUtils.chdir APP_ROOT do
|
||||||
# This script is idempotent, so that you can run it at any time and get an expectable outcome.
|
# This script is idempotent, so that you can run it at any time and get an expectable outcome.
|
||||||
# Add necessary setup steps to this file.
|
# Add necessary setup steps to this file.
|
||||||
|
|
||||||
puts "== Installing Ruby dependencies =="
|
puts "== Installing dependencies =="
|
||||||
|
system! "gem install bundler --conservative"
|
||||||
system("bundle check") || system!("bundle install")
|
system("bundle check") || system!("bundle install")
|
||||||
|
|
||||||
# puts "\n== Copying sample files =="
|
# puts "\n== Copying sample files =="
|
||||||
|
|
@ -23,25 +25,9 @@ FileUtils.chdir APP_ROOT do
|
||||||
puts "\n== Preparing database =="
|
puts "\n== Preparing database =="
|
||||||
system! "bin/rails db:prepare"
|
system! "bin/rails db:prepare"
|
||||||
|
|
||||||
system! "bin/rails db:reset" if ARGV.include?("--reset")
|
|
||||||
|
|
||||||
puts "\n== Importing public modeling data =="
|
|
||||||
system! "bin/rails public_data:pull"
|
|
||||||
|
|
||||||
puts "\n== Installing Yarn dependencies =="
|
|
||||||
system! "corepack enable"
|
|
||||||
system! "corepack install"
|
|
||||||
system! "yarn install"
|
|
||||||
|
|
||||||
puts "\n== Building development JS files =="
|
|
||||||
system! "yarn build:dev"
|
|
||||||
|
|
||||||
puts "\n== Removing old logs and tempfiles =="
|
puts "\n== Removing old logs and tempfiles =="
|
||||||
system! "bin/rails log:clear tmp:clear"
|
system! "bin/rails log:clear tmp:clear"
|
||||||
|
|
||||||
unless ARGV.include?("--skip-server")
|
puts "\n== Restarting application server =="
|
||||||
puts "\n== Starting development server =="
|
system! "bin/rails restart"
|
||||||
STDOUT.flush # flush the output before exec(2) so that it displays
|
|
||||||
exec "bin/dev"
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,25 @@ require "rails"
|
||||||
# We disable some components we don't use, to: omit their routes, be confident
|
# We disable some components we don't use, to: omit their routes, be confident
|
||||||
# that there's not e.g. surprise storage happening on the machine, and keep the
|
# that there's not e.g. surprise storage happening on the machine, and keep the
|
||||||
# app footprint smaller.
|
# app footprint smaller.
|
||||||
|
#
|
||||||
# require "active_model/railtie"
|
# Disabled:
|
||||||
# require "active_job/railtie"
|
# - active_storage/engine
|
||||||
require "active_record/railtie"
|
# - active_job/railtie
|
||||||
# require "active_storage/engine"
|
# - action_cable/engine
|
||||||
require "action_controller/railtie"
|
# - action_mailbox/engine
|
||||||
require "action_mailer/railtie"
|
# - action_text/engine
|
||||||
# require "action_mailbox/engine"
|
%w(
|
||||||
# require "action_text/engine"
|
active_record/railtie
|
||||||
require "action_view/railtie"
|
action_controller/railtie
|
||||||
# require "action_cable/engine"
|
action_view/railtie
|
||||||
require "rails/test_unit/railtie"
|
action_mailer/railtie
|
||||||
|
rails/test_unit/railtie
|
||||||
|
).each do |railtie|
|
||||||
|
begin
|
||||||
|
require railtie
|
||||||
|
rescue LoadError
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Require the gems listed in Gemfile, including any gems
|
# Require the gems listed in Gemfile, including any gems
|
||||||
# you've limited to :test, :development, or :production.
|
# you've limited to :test, :development, or :production.
|
||||||
|
|
@ -25,12 +32,12 @@ Bundler.require(*Rails.groups)
|
||||||
module OpenneoImpressItems
|
module OpenneoImpressItems
|
||||||
class Application < Rails::Application
|
class Application < Rails::Application
|
||||||
# Initialize configuration defaults for originally generated Rails version.
|
# Initialize configuration defaults for originally generated Rails version.
|
||||||
config.load_defaults 8.1
|
config.load_defaults 7.1
|
||||||
|
|
||||||
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
# Please, add to the `ignore` list any other `lib` subdirectories that do
|
||||||
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
# not contain `.rb` files, or that should not be reloaded or eager loaded.
|
||||||
# Common ones are `templates`, `generators`, or `middleware`, for example.
|
# Common ones are `templates`, `generators`, or `middleware`, for example.
|
||||||
config.autoload_lib(ignore: %w[assets tasks])
|
config.autoload_lib(ignore: %w(assets tasks))
|
||||||
|
|
||||||
# Configuration for the application, engines, and railties goes here.
|
# Configuration for the application, engines, and railties goes here.
|
||||||
#
|
#
|
||||||
|
|
@ -64,10 +71,7 @@ module OpenneoImpressItems
|
||||||
# symbols? So I can't provide anything helpful like a URL, email address,
|
# symbols? So I can't provide anything helpful like a URL, email address,
|
||||||
# version number, etc. So let's only send this to Neopets systems, where it
|
# version number, etc. So let's only send this to Neopets systems, where it
|
||||||
# should hopefully be clear who we are from context!
|
# should hopefully be clear who we are from context!
|
||||||
#
|
config.user_agent_for_neopets = "Dress to Impress"
|
||||||
# NOTE: To be able to access Neopets.com, the User-Agent string must contain
|
|
||||||
# a slash character.
|
|
||||||
config.user_agent_for_neopets = "Dress to Impress (https://impress.openneo.net)"
|
|
||||||
|
|
||||||
# Use the usual Neopets.com, unless we have an override. (At times, we've
|
# Use the usual Neopets.com, unless we have an override. (At times, we've
|
||||||
# used this in collaboration with TNT to address the server directly,
|
# used this in collaboration with TNT to address the server directly,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue