Compare commits
No commits in common. "main" and "simpler-item-previews" have entirely different histories.
main
...
simpler-it
|
|
@ -1,3 +1,15 @@
|
|||
# Make sure RUBY_VERSION matches the Ruby version in .ruby-version
|
||||
ARG RUBY_VERSION=3.4.5
|
||||
FROM ghcr.io/rails/devcontainer/images/ruby:$RUBY_VERSION
|
||||
FROM mcr.microsoft.com/devcontainers/ruby:1-3.1-bullseye
|
||||
|
||||
# Default value to allow debug server to serve content over GitHub Codespace's port forwarding service
|
||||
# The value is a comma-separated list of allowed domains
|
||||
ENV RAILS_DEVELOPMENT_HOSTS=".githubpreview.dev,.preview.app.github.dev,.app.github.dev"
|
||||
|
||||
# [Optional] Uncomment this section to install additional OS packages.
|
||||
# RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
|
||||
# && apt-get -y install --no-install-recommends <your-package-list-here>
|
||||
|
||||
# [Optional] Uncomment this line to install additional gems.
|
||||
# RUN gem install <your-gem-names-here>
|
||||
|
||||
# [Optional] Uncomment this line to install global node packages.
|
||||
# RUN su vscode -c "source /usr/local/share/nvm/nvm.sh && npm install -g <your-package-here>" 2>&1
|
||||
|
|
|
|||
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 config options, see the README at: https://github.com/devcontainers/templates/tree/main/src/ruby
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/ruby-rails-postgres
|
||||
{
|
||||
"name": "openneo_impress_items",
|
||||
"dockerComposeFile": "compose.yaml",
|
||||
"service": "rails-app",
|
||||
"name": "Dress to Impress",
|
||||
"dockerComposeFile": "docker-compose.yml",
|
||||
"service": "app",
|
||||
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {
|
||||
"nodeGypDependencies": true,
|
||||
"version": "lts"
|
||||
}
|
||||
},
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/node:1": {},
|
||||
"ghcr.io/rails/devcontainer/features/mysql-client": {},
|
||||
"ghcr.io/devcontainers-extra/features/ansible:2": {}
|
||||
},
|
||||
|
||||
"containerEnv": {
|
||||
"DB_HOST": "mysql"
|
||||
},
|
||||
|
||||
"remoteEnv": {
|
||||
"IMPRESS_DEPLOY_USER": "${localEnv:USER}"
|
||||
},
|
||||
// "features": {},
|
||||
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// This can be used to network with other containers or the host.
|
||||
"forwardPorts": [3000],
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": ".devcontainer/post-create.sh",
|
||||
|
||||
"containerEnv": {
|
||||
// Because the database is hosted on the local network at the hostname `db`,
|
||||
// we partially override `config/database.yml` to connect to `db`!
|
||||
"DATABASE_URL_PRIMARY_DEV": "mysql2://db",
|
||||
"DATABASE_URL_OPENNEO_ID_DEV": "mysql2://db",
|
||||
"DATABASE_URL_PRIMARY_TEST": "mysql2://db",
|
||||
"DATABASE_URL_OPENNEO_ID_TEST": "mysql2://db",
|
||||
|
||||
// HACK: Out of the box, this dev container doesn't allow installation to
|
||||
// the default GEM_HOME, because of a weird thing going on with RVM.
|
||||
// Instead, we set a custom GEM_HOME and GEM_PATH in our home directory!
|
||||
// https://github.com/devcontainers/templates/issues/188
|
||||
"GEM_HOME": "~/.rubygems",
|
||||
"GEM_PATH": "~/.rubygems"
|
||||
}
|
||||
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
|
||||
// Uncomment to connect as root instead. More info: https://containers.dev/implementors/json_reference/#remoteUser.
|
||||
// "remoteUser": "root",
|
||||
|
||||
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
"postCreateCommand": "bash .devcontainer/setup-ssh-config.sh && bin/setup --skip-server"
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
name: "openneo_impress_items"
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
rails-app:
|
||||
app:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: .devcontainer/Dockerfile
|
||||
|
|
@ -12,26 +12,18 @@ services:
|
|||
# Overrides default command so things don't shut down after the process ends.
|
||||
command: sleep infinity
|
||||
|
||||
# Uncomment the next line to use a non-root user for all processes.
|
||||
# user: vscode
|
||||
# Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function.
|
||||
network_mode: service:db
|
||||
|
||||
# Use "forwardPorts" in **devcontainer.json** to forward an app port locally.
|
||||
# (Adding the "ports" property to this file will not forward from a Codespace.)
|
||||
depends_on:
|
||||
- mysql
|
||||
|
||||
environment:
|
||||
DB_USER: root
|
||||
|
||||
mysql:
|
||||
image: mariadb:10.6
|
||||
db:
|
||||
image: mysql:latest
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'true'
|
||||
volumes:
|
||||
- mysql-data:/var/lib/mysql
|
||||
networks:
|
||||
- default
|
||||
|
||||
volumes:
|
||||
mysql-data:
|
||||
- ./create-db.sql:/docker-entrypoint-initdb.d/create-db.sql
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: impress_dev
|
||||
MYSQL_USER: impress_dev
|
||||
MYSQL_PASSWORD: impress_dev
|
||||
19
.devcontainer/post-create.sh
Executable file
|
|
@ -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}"
|
||||
2
.gitignore
vendored
|
|
@ -4,8 +4,6 @@ log/*.log
|
|||
tmp/**/*
|
||||
.env
|
||||
.env.*
|
||||
/spec/examples.txt
|
||||
/.yardoc
|
||||
|
||||
/app/assets/builds/*
|
||||
!/app/assets/builds/.keep
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env sh
|
||||
. "$(dirname -- "$0")/_/husky.sh"
|
||||
|
||||
# Run the linter, and all our tests.
|
||||
yarn lint --max-warnings=0 --fix && bin/rake test spec
|
||||
yarn lint --max-warnings=0 --fix
|
||||
|
|
|
|||
1
.rspec
|
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
||||
|
|
@ -1 +1 @@
|
|||
3.4.5
|
||||
3.3.4
|
||||
|
|
|
|||
24
.solargraph.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
---
|
||||
include:
|
||||
- "app/**/*.rb"
|
||||
- "config/**/*.rb"
|
||||
exclude:
|
||||
- "spec/**/*"
|
||||
- "test/**/*"
|
||||
- "vendor/**/*"
|
||||
- ".bundle/**/*"
|
||||
require:
|
||||
- actioncable
|
||||
- actionmailer
|
||||
- actionpack
|
||||
- actionview
|
||||
- activemodel
|
||||
- activerecord
|
||||
- activesupport
|
||||
plugins:
|
||||
- solargraph-rails
|
||||
domains: []
|
||||
reporters:
|
||||
- require_not_found
|
||||
require_paths: []
|
||||
max_files: 5000
|
||||
50
Gemfile
|
|
@ -1,38 +1,39 @@
|
|||
source 'https://rubygems.org'
|
||||
ruby '3.4.5'
|
||||
ruby '3.3.4'
|
||||
|
||||
gem 'rails', '~> 8.0', '>= 8.0.1'
|
||||
gem 'rails', '~> 7.1', '>= 7.1.3.4'
|
||||
|
||||
# The HTTP server running the Rails instance.
|
||||
gem 'falcon', '~> 0.48.0'
|
||||
gem 'falcon', '~> 0.43.0'
|
||||
|
||||
# Our database is MySQL, in both development and production.
|
||||
gem 'mysql2', '~> 0.5.5'
|
||||
|
||||
# For reading the .env file, which you can use in development to more easily
|
||||
# set environment variables for secret data.
|
||||
gem 'dotenv', '~> 3.2'
|
||||
gem 'dotenv-rails', '~> 2.8', '>= 2.8.1'
|
||||
|
||||
# For the asset pipeline: templates, CSS, JS, etc.
|
||||
gem 'sprockets', '~> 4.2'
|
||||
gem 'haml', '~> 7.2'
|
||||
gem 'haml', '~> 6.1', '>= 6.1.1'
|
||||
gem 'sass-rails', '~> 6.0'
|
||||
gem 'terser', '~> 1.1', '>= 1.1.17'
|
||||
gem 'jsbundling-rails', '~> 1.3'
|
||||
gem 'react-rails', '~> 2.7', '>= 2.7.1'
|
||||
gem 'jsbundling-rails', '~> 1.1'
|
||||
gem 'turbo-rails', '~> 2.0'
|
||||
|
||||
# For authentication.
|
||||
gem 'devise', '~> 4.9', '>= 4.9.2'
|
||||
gem 'devise-encryptable', '~> 0.2.0'
|
||||
gem 'omniauth', '~> 2.1'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 2.0', '>= 2.0.1'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0'
|
||||
gem "omniauth_openid_connect", "~> 0.7.1"
|
||||
|
||||
# For pagination UI.
|
||||
gem 'will_paginate', '~> 4.0'
|
||||
|
||||
# For translation, both for the site UI and for Neopets data.
|
||||
gem 'rails-i18n', '~> 8.0', '>= 8.0.1'
|
||||
gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
|
||||
gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
|
||||
|
||||
# For reading and parsing HTML from Neopets.com, like importing Closet pages.
|
||||
|
|
@ -40,12 +41,11 @@ gem 'nokogiri', '~> 1.15', '>= 1.15.3'
|
|||
|
||||
# For safely rendering users' Markdown + HTML on item list pages.
|
||||
gem 'rdiscount', '~> 2.2', '>= 2.2.7.1'
|
||||
gem 'sanitize', '~> 7.0'
|
||||
gem 'sanitize', '~> 6.0', '>= 6.0.2'
|
||||
|
||||
# For working with Neopets APIs.
|
||||
# unstable version of RocketAMF interprets info registry as a hash instead of an array
|
||||
# Vendored version with Ruby 3.4 ARM compatibility fixes (see vendor/gems/README-RocketAMF.md)
|
||||
gem 'RocketAMF', path: 'vendor/gems/RocketAMF-1.0.0'
|
||||
gem 'RocketAMF', :git => 'https://github.com/rubyamf/rocketamf.git'
|
||||
|
||||
# For preventing too many modeling attempts.
|
||||
gem 'rack-attack', '~> 6.7'
|
||||
|
|
@ -53,28 +53,29 @@ gem 'rack-attack', '~> 6.7'
|
|||
# For testing emails in development.
|
||||
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
|
||||
|
||||
# For parallel API calls.
|
||||
gem 'parallel', '~> 1.23'
|
||||
|
||||
# For miscellaneous HTTP requests.
|
||||
gem "httparty", "~> 0.22.0"
|
||||
gem "addressable", "~> 2.8"
|
||||
|
||||
# For advanced batching of many HTTP requests.
|
||||
gem "async", "~> 2.17", require: false
|
||||
gem "async-http", "~> 0.89.0", require: false
|
||||
gem "async", "~> 2.6", require: false
|
||||
gem "async-http", "~> 0.61.0", require: false
|
||||
gem "thread-local", "~> 1.1", require: false
|
||||
|
||||
# For image processing (outfit PNG rendering).
|
||||
gem "ruby-vips", "~> 2.2"
|
||||
|
||||
# For debugging.
|
||||
group :development do
|
||||
gem 'debug', '~> 1.9.2'
|
||||
gem 'web-console', '~> 4.2'
|
||||
end
|
||||
gem 'web-console', '~> 4.2', group: :development
|
||||
|
||||
# TODO: Review our use of content_tag_for etc and uninstall this!
|
||||
gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
|
||||
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', '~> 1.16', require: false
|
||||
|
||||
# For investigating performance issues.
|
||||
gem 'rack-mini-profiler', '~> 4.0', '>= 4.0.1'
|
||||
gem "rack-mini-profiler", "~> 3.1"
|
||||
gem "memory_profiler", "~> 1.0"
|
||||
gem "stackprof", "~> 0.2.25"
|
||||
|
||||
|
|
@ -85,7 +86,6 @@ gem "sentry-rails", "~> 5.12"
|
|||
# For tasks that use shell commands.
|
||||
gem "shell", "~> 0.8.1"
|
||||
|
||||
# For automated tests.
|
||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||
gem 'rails-controller-testing', group: [:test]
|
||||
gem "webmock", "~> 3.24", group: [:test]
|
||||
# For workspace autocomplete.
|
||||
gem "solargraph", "~> 0.50.0", group: :development
|
||||
gem "solargraph-rails", "~> 1.1", group: :development
|
||||
|
|
|
|||
592
Gemfile.lock
|
|
@ -1,139 +1,136 @@
|
|||
PATH
|
||||
remote: vendor/gems/RocketAMF-1.0.0
|
||||
GIT
|
||||
remote: https://github.com/rubyamf/rocketamf.git
|
||||
revision: 796f591d002b5cf47df436dbcbd6f2ab00e869ed
|
||||
specs:
|
||||
RocketAMF (1.0.0.dti1)
|
||||
RocketAMF (1.0.0)
|
||||
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
action_text-trix (2.1.16)
|
||||
railties
|
||||
actioncable (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actioncable (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
zeitwerk (~> 2.6)
|
||||
actionmailbox (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailbox (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
actionmailer (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionmailer (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
mail (>= 2.8.0)
|
||||
rails-dom-testing (~> 2.2)
|
||||
actionpack (8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionpack (7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
rack (>= 2.2.4)
|
||||
racc
|
||||
rack (>= 2.2.4, < 3.2)
|
||||
rack-session (>= 1.0.1)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
useragent (~> 0.16)
|
||||
actiontext (8.1.2)
|
||||
action_text-trix (~> 2.1.15)
|
||||
actionpack (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actiontext (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
actionview (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.11)
|
||||
rails-dom-testing (~> 2.2)
|
||||
rails-html-sanitizer (~> 1.6)
|
||||
activejob (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activejob (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activerecord (8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activemodel (7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
activerecord (7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
timeout (>= 0.4.0)
|
||||
activestorage (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
activestorage (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
marcel (~> 1.0)
|
||||
activesupport (8.1.2)
|
||||
activesupport (7.2.1)
|
||||
base64
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.3.1)
|
||||
connection_pool (>= 2.2.5)
|
||||
drb
|
||||
i18n (>= 1.6, < 2)
|
||||
json
|
||||
logger (>= 1.4.2)
|
||||
minitest (>= 5.1)
|
||||
securerandom (>= 0.3)
|
||||
tzinfo (~> 2.0, >= 2.0.5)
|
||||
uri (>= 0.13.1)
|
||||
addressable (2.8.8)
|
||||
public_suffix (>= 2.0.2, < 8.0)
|
||||
addressable (2.8.7)
|
||||
public_suffix (>= 2.0.2, < 7.0)
|
||||
aes_key_wrap (1.1.0)
|
||||
async (2.35.3)
|
||||
console (~> 1.29)
|
||||
ast (2.4.2)
|
||||
async (2.16.1)
|
||||
console (~> 1.26)
|
||||
fiber-annotation
|
||||
io-event (~> 1.11)
|
||||
metrics (~> 0.12)
|
||||
traces (~> 0.18)
|
||||
async-container (0.29.0)
|
||||
async (~> 2.22)
|
||||
async-http (0.89.0)
|
||||
async (>= 2.10.2)
|
||||
async-pool (~> 0.9)
|
||||
io-endpoint (~> 0.14)
|
||||
io-stream (~> 0.6)
|
||||
metrics (~> 0.12)
|
||||
protocol-http (~> 0.49)
|
||||
protocol-http1 (~> 0.30)
|
||||
protocol-http2 (~> 0.22)
|
||||
traces (~> 0.10)
|
||||
async-http-cache (0.4.6)
|
||||
async-http (~> 0.56)
|
||||
async-pool (0.11.1)
|
||||
async (>= 2.0)
|
||||
async-service (0.17.0)
|
||||
io-event (~> 1.6, >= 1.6.5)
|
||||
async-container (0.16.13)
|
||||
async
|
||||
async-container (~> 0.28)
|
||||
string-format (~> 0.2)
|
||||
async-io
|
||||
async-http (0.61.0)
|
||||
async (>= 1.25)
|
||||
async-io (>= 1.28)
|
||||
async-pool (>= 0.2)
|
||||
protocol-http (~> 0.25.0)
|
||||
protocol-http1 (~> 0.16.0)
|
||||
protocol-http2 (~> 0.15.0)
|
||||
traces (>= 0.10.0)
|
||||
async-http-cache (0.4.4)
|
||||
async-http (~> 0.56)
|
||||
async-io (1.43.2)
|
||||
async
|
||||
async-pool (0.8.1)
|
||||
async (>= 1.25)
|
||||
metrics
|
||||
traces
|
||||
attr_required (1.0.2)
|
||||
base64 (0.3.0)
|
||||
bcrypt (3.1.21)
|
||||
bigdecimal (4.0.1)
|
||||
bindata (2.5.1)
|
||||
babel-source (5.8.35)
|
||||
babel-transpiler (0.7.0)
|
||||
babel-source (>= 4.0, < 6)
|
||||
execjs (~> 2.0)
|
||||
backport (1.2.0)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
benchmark (0.3.0)
|
||||
bigdecimal (3.1.8)
|
||||
bindata (2.5.0)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.21.1)
|
||||
bootsnap (1.18.4)
|
||||
msgpack (~> 1.2)
|
||||
build-environment (1.13.0)
|
||||
builder (3.3.0)
|
||||
childprocess (5.1.0)
|
||||
logger (~> 1.5)
|
||||
concurrent-ruby (1.3.6)
|
||||
connection_pool (3.0.2)
|
||||
console (1.34.2)
|
||||
concurrent-ruby (1.3.4)
|
||||
connection_pool (2.4.1)
|
||||
console (1.27.0)
|
||||
fiber-annotation
|
||||
fiber-local (~> 1.1)
|
||||
json
|
||||
crack (1.0.1)
|
||||
bigdecimal
|
||||
rexml
|
||||
crass (1.0.6)
|
||||
date (3.5.1)
|
||||
debug (1.9.2)
|
||||
irb (~> 1.10)
|
||||
reline (>= 0.3.8)
|
||||
csv (3.3.0)
|
||||
date (3.3.4)
|
||||
devise (4.9.4)
|
||||
bcrypt (~> 3.0)
|
||||
orm_adapter (~> 0.1)
|
||||
|
|
@ -142,130 +139,129 @@ GEM
|
|||
warden (~> 1.2.3)
|
||||
devise-encryptable (0.2.0)
|
||||
devise (>= 2.1.0)
|
||||
diff-lcs (1.6.2)
|
||||
dotenv (3.2.0)
|
||||
drb (2.2.3)
|
||||
diff-lcs (1.5.1)
|
||||
dotenv (2.8.1)
|
||||
dotenv-rails (2.8.1)
|
||||
dotenv (= 2.8.1)
|
||||
railties (>= 3.2)
|
||||
drb (2.2.1)
|
||||
e2mmap (0.1.0)
|
||||
email_validator (2.2.4)
|
||||
activemodel
|
||||
erb (6.0.1)
|
||||
erubi (1.13.1)
|
||||
execjs (2.10.0)
|
||||
falcon (0.48.6)
|
||||
erubi (1.13.0)
|
||||
execjs (2.9.1)
|
||||
falcon (0.43.0)
|
||||
async
|
||||
async-container (~> 0.18)
|
||||
async-http (~> 0.75)
|
||||
async-http-cache (~> 0.4)
|
||||
async-service (~> 0.10)
|
||||
async-container (~> 0.16.0)
|
||||
async-http (~> 0.57)
|
||||
async-http-cache (~> 0.4.0)
|
||||
async-io (~> 1.22)
|
||||
build-environment (~> 1.13)
|
||||
bundler
|
||||
localhost (~> 1.1)
|
||||
openssl (~> 3.0)
|
||||
process-metrics (~> 0.2)
|
||||
protocol-http (~> 0.31)
|
||||
protocol-rack (~> 0.7)
|
||||
samovar (~> 2.3)
|
||||
faraday (2.14.0)
|
||||
faraday-net_http (>= 2.0, < 3.5)
|
||||
json
|
||||
process-metrics (~> 0.2.0)
|
||||
protocol-rack (~> 0.1)
|
||||
samovar (~> 2.1)
|
||||
faraday (2.11.0)
|
||||
faraday-net_http (>= 2.0, < 3.4)
|
||||
logger
|
||||
faraday-follow_redirects (0.5.0)
|
||||
faraday-follow_redirects (0.3.0)
|
||||
faraday (>= 1, < 3)
|
||||
faraday-net_http (3.4.2)
|
||||
net-http (~> 0.5)
|
||||
ffi (1.17.3-aarch64-linux-gnu)
|
||||
ffi (1.17.3-arm64-darwin)
|
||||
ffi (1.17.3-x86_64-linux-gnu)
|
||||
faraday-net_http (3.3.0)
|
||||
net-http
|
||||
ffi (1.17.0)
|
||||
fiber-annotation (0.2.0)
|
||||
fiber-local (1.1.0)
|
||||
fiber-storage
|
||||
fiber-storage (1.0.1)
|
||||
globalid (1.3.0)
|
||||
fiber-storage (1.0.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
haml (7.2.0)
|
||||
haml (6.3.0)
|
||||
temple (>= 0.8.2)
|
||||
thor
|
||||
tilt
|
||||
hashdiff (1.2.1)
|
||||
hashie (5.1.0)
|
||||
logger
|
||||
hashie (5.0.0)
|
||||
http_accept_language (2.1.1)
|
||||
i18n (1.14.8)
|
||||
httparty (0.22.0)
|
||||
csv
|
||||
mini_mime (>= 1.0.0)
|
||||
multi_xml (>= 0.5.2)
|
||||
i18n (1.14.5)
|
||||
concurrent-ruby (~> 1.0)
|
||||
io-console (0.8.2)
|
||||
io-endpoint (0.16.0)
|
||||
io-event (1.14.2)
|
||||
io-stream (0.11.1)
|
||||
irb (1.16.0)
|
||||
pp (>= 0.6.0)
|
||||
io-console (0.7.2)
|
||||
io-event (1.6.5)
|
||||
irb (1.14.0)
|
||||
rdoc (>= 4.0.0)
|
||||
reline (>= 0.4.2)
|
||||
jaro_winkler (1.6.0)
|
||||
jsbundling-rails (1.3.1)
|
||||
railties (>= 6.0.0)
|
||||
json (2.18.0)
|
||||
json-jwt (1.17.0)
|
||||
json (2.7.2)
|
||||
json-jwt (1.16.6)
|
||||
activesupport (>= 4.2)
|
||||
aes_key_wrap
|
||||
base64
|
||||
bindata
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
launchy (3.1.1)
|
||||
kramdown (2.4.0)
|
||||
rexml
|
||||
kramdown-parser-gfm (1.1.0)
|
||||
kramdown (~> 2.0)
|
||||
language_server-protocol (3.17.0.3)
|
||||
launchy (3.0.1)
|
||||
addressable (~> 2.8)
|
||||
childprocess (~> 5.0)
|
||||
logger (~> 1.6)
|
||||
letter_opener (1.10.0)
|
||||
launchy (>= 2.2, < 4)
|
||||
localhost (1.7.0)
|
||||
logger (1.7.0)
|
||||
loofah (2.25.0)
|
||||
localhost (1.3.1)
|
||||
logger (1.6.0)
|
||||
loofah (2.22.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.12.0)
|
||||
mail (2.9.0)
|
||||
logger
|
||||
mail (2.8.1)
|
||||
mini_mime (>= 0.1.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
mapping (1.1.3)
|
||||
marcel (1.1.0)
|
||||
memory_profiler (1.1.0)
|
||||
metrics (0.15.0)
|
||||
mapping (1.1.1)
|
||||
marcel (1.0.4)
|
||||
memory_profiler (1.0.2)
|
||||
metrics (0.10.2)
|
||||
mini_mime (1.1.5)
|
||||
minitest (6.0.1)
|
||||
prism (~> 1.5)
|
||||
msgpack (1.8.0)
|
||||
mysql2 (0.5.7)
|
||||
bigdecimal
|
||||
net-http (0.9.1)
|
||||
uri (>= 0.11.1)
|
||||
net-imap (0.6.2)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.25.1)
|
||||
msgpack (1.7.2)
|
||||
multi_xml (0.7.1)
|
||||
bigdecimal (~> 3.1)
|
||||
mysql2 (0.5.6)
|
||||
net-http (0.4.1)
|
||||
uri
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
net-protocol
|
||||
net-protocol (0.2.2)
|
||||
timeout
|
||||
net-smtp (0.5.1)
|
||||
net-smtp (0.5.0)
|
||||
net-protocol
|
||||
nio4r (2.7.5)
|
||||
nokogiri (1.19.0-aarch64-linux-gnu)
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.7)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.19.0-x86_64-linux-gnu)
|
||||
racc (~> 1.4)
|
||||
omniauth (2.1.4)
|
||||
omniauth (2.1.2)
|
||||
hashie (>= 3.4.6)
|
||||
logger
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-rails_csrf_protection (2.0.1)
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
omniauth_openid_connect (0.7.1)
|
||||
omniauth (>= 1.9, < 3)
|
||||
openid_connect (~> 2.2)
|
||||
openid_connect (2.3.1)
|
||||
openid_connect (2.3.0)
|
||||
activemodel
|
||||
attr_required (>= 1.0.0)
|
||||
email_validator
|
||||
|
|
@ -278,130 +274,127 @@ GEM
|
|||
tzinfo
|
||||
validate_url
|
||||
webfinger (~> 2.0)
|
||||
openssl (3.3.2)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
pp (0.6.3)
|
||||
prettyprint
|
||||
prettyprint (0.2.0)
|
||||
prism (1.8.0)
|
||||
process-metrics (0.8.0)
|
||||
parallel (1.26.3)
|
||||
parser (3.3.4.2)
|
||||
ast (~> 2.4.1)
|
||||
racc
|
||||
process-metrics (0.2.1)
|
||||
console (~> 1.8)
|
||||
json (~> 2)
|
||||
samovar (~> 2.1)
|
||||
protocol-hpack (1.5.1)
|
||||
protocol-http (0.58.0)
|
||||
protocol-http1 (0.36.0)
|
||||
protocol-http (~> 0.58)
|
||||
protocol-http2 (0.24.0)
|
||||
protocol-hpack (1.5.0)
|
||||
protocol-http (0.25.0)
|
||||
protocol-http1 (0.16.1)
|
||||
protocol-http (~> 0.22)
|
||||
protocol-http2 (0.15.1)
|
||||
protocol-hpack (~> 1.4)
|
||||
protocol-http (~> 0.47)
|
||||
protocol-rack (0.21.0)
|
||||
io-stream (>= 0.10)
|
||||
protocol-http (~> 0.58)
|
||||
protocol-http (~> 0.18)
|
||||
protocol-rack (0.6.0)
|
||||
protocol-http (~> 0.23)
|
||||
rack (>= 1.0)
|
||||
psych (5.3.1)
|
||||
date
|
||||
psych (5.1.2)
|
||||
stringio
|
||||
public_suffix (7.0.2)
|
||||
public_suffix (6.0.1)
|
||||
racc (1.8.1)
|
||||
rack (3.2.4)
|
||||
rack-attack (6.8.0)
|
||||
rack (3.1.7)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-mini-profiler (4.0.1)
|
||||
rack-mini-profiler (3.3.1)
|
||||
rack (>= 1.2.0)
|
||||
rack-oauth2 (2.3.0)
|
||||
rack-oauth2 (2.2.1)
|
||||
activesupport
|
||||
attr_required
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
json-jwt (>= 1.11.0)
|
||||
rack (>= 2.1.0)
|
||||
rack-protection (4.2.1)
|
||||
rack-protection (4.0.0)
|
||||
base64 (>= 0.1.0)
|
||||
logger (>= 1.6.0)
|
||||
rack (>= 3.0.0, < 4)
|
||||
rack-session (2.1.1)
|
||||
base64 (>= 0.1.0)
|
||||
rack-session (2.0.0)
|
||||
rack (>= 3.0.0)
|
||||
rack-test (2.2.0)
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rackup (2.3.1)
|
||||
rackup (2.1.0)
|
||||
rack (>= 3)
|
||||
rails (8.1.2)
|
||||
actioncable (= 8.1.2)
|
||||
actionmailbox (= 8.1.2)
|
||||
actionmailer (= 8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
actiontext (= 8.1.2)
|
||||
actionview (= 8.1.2)
|
||||
activejob (= 8.1.2)
|
||||
activemodel (= 8.1.2)
|
||||
activerecord (= 8.1.2)
|
||||
activestorage (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
webrick (~> 1.8)
|
||||
rails (7.2.1)
|
||||
actioncable (= 7.2.1)
|
||||
actionmailbox (= 7.2.1)
|
||||
actionmailer (= 7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
actiontext (= 7.2.1)
|
||||
actionview (= 7.2.1)
|
||||
activejob (= 7.2.1)
|
||||
activemodel (= 7.2.1)
|
||||
activerecord (= 7.2.1)
|
||||
activestorage (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.3.0)
|
||||
railties (= 7.2.1)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.2)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
|
||||
rails-i18n (8.1.0)
|
||||
nokogiri (~> 1.14)
|
||||
rails-i18n (7.0.9)
|
||||
i18n (>= 0.7, < 2)
|
||||
railties (>= 8.0.0, < 9)
|
||||
railties (8.1.2)
|
||||
actionpack (= 8.1.2)
|
||||
activesupport (= 8.1.2)
|
||||
railties (>= 6.0.0, < 8)
|
||||
railties (7.2.1)
|
||||
actionpack (= 7.2.1)
|
||||
activesupport (= 7.2.1)
|
||||
irb (~> 1.13)
|
||||
rackup (>= 1.0.0)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0, >= 1.2.2)
|
||||
tsort (>= 0.2)
|
||||
zeitwerk (~> 2.6)
|
||||
rake (13.3.1)
|
||||
rainbow (3.1.1)
|
||||
rake (13.2.1)
|
||||
rbs (2.8.4)
|
||||
rdiscount (2.2.7.3)
|
||||
rdoc (7.1.0)
|
||||
erb
|
||||
rdoc (6.7.0)
|
||||
psych (>= 4.0.0)
|
||||
tsort
|
||||
reline (0.6.3)
|
||||
react-rails (2.7.1)
|
||||
babel-transpiler (>= 0.7.0)
|
||||
connection_pool
|
||||
execjs
|
||||
railties (>= 3.2)
|
||||
tilt
|
||||
record_tag_helper (1.0.1)
|
||||
actionview (>= 5)
|
||||
regexp_parser (2.9.2)
|
||||
reline (0.5.9)
|
||||
io-console (~> 0.5)
|
||||
responders (3.2.0)
|
||||
actionpack (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rexml (3.4.4)
|
||||
rspec-core (3.13.6)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.5)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.7)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (8.0.2)
|
||||
actionpack (>= 7.2)
|
||||
activesupport (>= 7.2)
|
||||
railties (>= 7.2)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.6)
|
||||
ruby-vips (2.3.0)
|
||||
ffi (~> 1.12)
|
||||
logger
|
||||
samovar (2.4.1)
|
||||
responders (3.1.1)
|
||||
actionpack (>= 5.2)
|
||||
railties (>= 5.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rubocop (1.65.1)
|
||||
json (~> 2.3)
|
||||
language_server-protocol (>= 3.17.0)
|
||||
parallel (~> 1.10)
|
||||
parser (>= 3.3.0.2)
|
||||
rainbow (>= 2.2.2, < 4.0)
|
||||
regexp_parser (>= 2.4, < 3.0)
|
||||
rexml (>= 3.2.5, < 4.0)
|
||||
rubocop-ast (>= 1.31.1, < 2.0)
|
||||
ruby-progressbar (~> 1.7)
|
||||
unicode-display_width (>= 2.4.0, < 3.0)
|
||||
rubocop-ast (1.32.1)
|
||||
parser (>= 3.3.1.0)
|
||||
ruby-progressbar (1.13.0)
|
||||
samovar (2.3.0)
|
||||
console (~> 1.0)
|
||||
mapping (~> 1.0)
|
||||
sanitize (7.0.0)
|
||||
sanitize (6.1.3)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.16.8)
|
||||
nokogiri (>= 1.12.0)
|
||||
sass-rails (6.0.0)
|
||||
sassc-rails (~> 2.1, >= 2.1.1)
|
||||
sassc (2.4.0)
|
||||
|
|
@ -412,49 +405,68 @@ GEM
|
|||
sprockets (> 3.0)
|
||||
sprockets-rails
|
||||
tilt
|
||||
securerandom (0.4.1)
|
||||
sentry-rails (5.28.1)
|
||||
securerandom (0.3.1)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.28.1)
|
||||
sentry-ruby (5.28.1)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sentry-ruby (5.19.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
shell (0.8.1)
|
||||
e2mmap
|
||||
sync
|
||||
sprockets (4.2.2)
|
||||
solargraph (0.50.0)
|
||||
backport (~> 1.2)
|
||||
benchmark
|
||||
bundler (~> 2.0)
|
||||
diff-lcs (~> 1.4)
|
||||
e2mmap
|
||||
jaro_winkler (~> 1.5)
|
||||
kramdown (~> 2.3)
|
||||
kramdown-parser-gfm (~> 1.1)
|
||||
parser (~> 3.0)
|
||||
rbs (~> 2.0)
|
||||
reverse_markdown (~> 2.0)
|
||||
rubocop (~> 1.38)
|
||||
thor (~> 1.0)
|
||||
tilt (~> 2.0)
|
||||
yard (~> 0.9, >= 0.9.24)
|
||||
solargraph-rails (1.1.0)
|
||||
activesupport
|
||||
solargraph
|
||||
sprockets (4.2.1)
|
||||
concurrent-ruby (~> 1.0)
|
||||
logger
|
||||
rack (>= 2.2.4, < 4)
|
||||
sprockets-rails (3.5.2)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
sprockets (>= 3.0.0)
|
||||
stackprof (0.2.27)
|
||||
string-format (0.2.0)
|
||||
stringio (3.2.0)
|
||||
stackprof (0.2.26)
|
||||
stringio (3.1.1)
|
||||
strscan (3.1.0)
|
||||
swd (2.0.3)
|
||||
activesupport (>= 3)
|
||||
attr_required (>= 0.0.5)
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
sync (0.5.0)
|
||||
temple (0.10.4)
|
||||
terser (1.2.6)
|
||||
temple (0.10.3)
|
||||
terser (1.2.3)
|
||||
execjs (>= 0.3.0, < 3)
|
||||
thor (1.5.0)
|
||||
thor (1.3.1)
|
||||
thread-local (1.1.0)
|
||||
tilt (2.7.0)
|
||||
timeout (0.6.0)
|
||||
traces (0.18.2)
|
||||
tsort (0.2.0)
|
||||
turbo-rails (2.0.21)
|
||||
actionpack (>= 7.1.0)
|
||||
railties (>= 7.1.0)
|
||||
tilt (2.4.0)
|
||||
timeout (0.4.1)
|
||||
traces (0.13.1)
|
||||
turbo-rails (2.0.6)
|
||||
actionpack (>= 6.0.0)
|
||||
activejob (>= 6.0.0)
|
||||
railties (>= 6.0.0)
|
||||
tzinfo (2.0.6)
|
||||
concurrent-ruby (~> 1.0)
|
||||
uri (1.1.1)
|
||||
useragent (0.16.11)
|
||||
unicode-display_width (2.5.0)
|
||||
uri (0.13.1)
|
||||
useragent (0.16.10)
|
||||
validate_url (1.0.15)
|
||||
activemodel (>= 3.0.0)
|
||||
public_suffix
|
||||
|
|
@ -469,67 +481,63 @@ GEM
|
|||
activesupport
|
||||
faraday (~> 2.0)
|
||||
faraday-follow_redirects
|
||||
webmock (3.26.1)
|
||||
addressable (>= 2.8.0)
|
||||
crack (>= 0.3.2)
|
||||
hashdiff (>= 0.4.0, < 2.0.0)
|
||||
websocket-driver (0.8.0)
|
||||
base64
|
||||
webrick (1.8.1)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
will_paginate (4.0.1)
|
||||
zeitwerk (2.7.4)
|
||||
yard (0.9.36)
|
||||
zeitwerk (2.6.17)
|
||||
|
||||
PLATFORMS
|
||||
aarch64-linux
|
||||
arm64-darwin
|
||||
x86_64-linux
|
||||
ruby
|
||||
|
||||
DEPENDENCIES
|
||||
RocketAMF!
|
||||
addressable (~> 2.8)
|
||||
async (~> 2.17)
|
||||
async-http (~> 0.89.0)
|
||||
async (~> 2.6)
|
||||
async-http (~> 0.61.0)
|
||||
bootsnap (~> 1.16)
|
||||
debug (~> 1.9.2)
|
||||
devise (~> 4.9, >= 4.9.2)
|
||||
devise-encryptable (~> 0.2.0)
|
||||
dotenv (~> 3.2)
|
||||
falcon (~> 0.48.0)
|
||||
haml (~> 7.2)
|
||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||
falcon (~> 0.43.0)
|
||||
haml (~> 6.1, >= 6.1.1)
|
||||
http_accept_language (~> 2.1, >= 2.1.1)
|
||||
jsbundling-rails (~> 1.3)
|
||||
httparty (~> 0.22.0)
|
||||
jsbundling-rails (~> 1.1)
|
||||
letter_opener (~> 1.8, >= 1.8.1)
|
||||
memory_profiler (~> 1.0)
|
||||
mysql2 (~> 0.5.5)
|
||||
nokogiri (~> 1.15, >= 1.15.3)
|
||||
omniauth (~> 2.1)
|
||||
omniauth-rails_csrf_protection (~> 2.0, >= 2.0.1)
|
||||
omniauth-rails_csrf_protection (~> 1.0)
|
||||
omniauth_openid_connect (~> 0.7.1)
|
||||
parallel (~> 1.23)
|
||||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||
rails (~> 8.0, >= 8.0.1)
|
||||
rails-controller-testing
|
||||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rack-mini-profiler (~> 3.1)
|
||||
rails (~> 7.1, >= 7.1.3.4)
|
||||
rails-i18n (~> 7.0, >= 7.0.7)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 8.0, >= 8.0.2)
|
||||
ruby-vips (~> 2.2)
|
||||
sanitize (~> 7.0)
|
||||
react-rails (~> 2.7, >= 2.7.1)
|
||||
record_tag_helper (~> 1.0, >= 1.0.1)
|
||||
sanitize (~> 6.0, >= 6.0.2)
|
||||
sass-rails (~> 6.0)
|
||||
sentry-rails (~> 5.12)
|
||||
sentry-ruby (~> 5.12)
|
||||
shell (~> 0.8.1)
|
||||
solargraph (~> 0.50.0)
|
||||
solargraph-rails (~> 1.1)
|
||||
sprockets (~> 4.2)
|
||||
stackprof (~> 0.2.25)
|
||||
terser (~> 1.1, >= 1.1.17)
|
||||
thread-local (~> 1.1)
|
||||
turbo-rails (~> 2.0)
|
||||
web-console (~> 4.2)
|
||||
webmock (~> 3.24)
|
||||
will_paginate (~> 4.0)
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.4.5p51
|
||||
ruby 3.3.4p94
|
||||
|
||||
BUNDLED WITH
|
||||
2.7.1
|
||||
2.5.18
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server -b 0.0.0.0
|
||||
web: unset PORT && env RUBY_DEBUG_OPEN=true bin/rails server
|
||||
js: yarn dev
|
||||
|
|
|
|||
161
README.md
|
|
@ -2,163 +2,6 @@
|
|||
|
||||
# Dress to Impress
|
||||
|
||||
Dress to Impress (DTI) is a tool for designing Neopets outfits. Load your pet, browse items, and see how they look together—all with a mobile-friendly interface!
|
||||
Oh! We've been revitalizing the Rails app! Fun!
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
DTI is a Rails application with a React-based outfit editor, backed by MySQL databases and a crowdsourced data collection system.
|
||||
|
||||
### Core Components
|
||||
|
||||
- **Rails backend** (Ruby 3.4, Rails 8.0): Serves web pages, API endpoints, and manages data
|
||||
- **MySQL databases**: Primary database (`openneo_impress`) + legacy auth database (`openneo_id`)
|
||||
- **React outfit editor**: Embedded in `app/javascript/wardrobe-2020/`, provides the main customization UI
|
||||
- **Modeling system**: Crowdsources pet/item appearance data by fetching from Neopets APIs when users load their pets
|
||||
|
||||
### The Impress 2020 Complication
|
||||
|
||||
In 2020, we started a NextJS rewrite ("Impress 2020") to modernize the frontend. We've since consolidated back into Rails, but **Impress 2020 still provides essential services**:
|
||||
|
||||
- **GraphQL API**: Some outfit appearance data still loads via GraphQL (being migrated to Rails REST APIs)
|
||||
- **Image generation**: Runs a headless browser to render outfit thumbnails and convert HTML5 assets to PNGs
|
||||
|
||||
See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for migration status.
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Customization Data Model
|
||||
|
||||
The core data model powers outfit rendering and item compatibility. See [docs/customization-architecture.md](./docs/customization-architecture.md) for details.
|
||||
|
||||
**Quick summary**:
|
||||
- `body_id` is the key compatibility constraint (not species or color directly)
|
||||
- Items have different `swf_assets` (visual layers) for different bodies
|
||||
- Restrictions are subtractive: start with all layers, hide some based on zone restrictions
|
||||
- Data is crowdsourced through "modeling" (users loading pets to contribute appearance data)
|
||||
|
||||
### Modeling (Crowdsourced Data)
|
||||
|
||||
DTI doesn't pre-populate item/pet data. Instead:
|
||||
|
||||
1. User loads a pet (via pet name lookup)
|
||||
2. DTI fetches appearance data from Neopets APIs (legacy Flash/AMF protocol)
|
||||
3. New `SwfAsset` records and relationships are created
|
||||
4. Over time, the database learns which items fit which pet bodies
|
||||
|
||||
This "self-sustaining" approach means the site stays up-to-date as Neopets releases new content, without manual data entry.
|
||||
|
||||
## Directory Map
|
||||
|
||||
### Key Application Files
|
||||
|
||||
```
|
||||
app/
|
||||
├── controllers/
|
||||
│ ├── outfits_controller.rb # Outfit editor + CRUD
|
||||
│ ├── items_controller.rb # Item search, pages, and JSON APIs
|
||||
│ ├── pets_controller.rb # Pet loading (triggers modeling)
|
||||
│ └── closet_hangers_controller.rb # User item lists ("closets")
|
||||
│
|
||||
├── models/
|
||||
│ ├── item.rb # Items + compatibility prediction logic
|
||||
│ ├── pet_type.rb # Species+Color combinations (has body_id)
|
||||
│ ├── pet_state.rb # Visual variants (pose/gender/mood)
|
||||
│ ├── swf_asset.rb # Visual layers (biology/object)
|
||||
│ ├── outfit.rb # Saved outfits + rendering logic (visible_layers)
|
||||
│ ├── alt_style.rb # Alternative pet appearances (Nostalgic, etc.)
|
||||
│ └── pet/
|
||||
│ └── modeling_snapshot.rb # Processes Neopets API data into models
|
||||
│
|
||||
├── services/
|
||||
│ ├── neopets/
|
||||
│ │ ├── custom_pets.rb # Neopets AMF/Flash API client (pet data)
|
||||
│ │ ├── nc_mall.rb # NC Mall item scraping
|
||||
│ │ └── neopass.rb # NeoPass OAuth integration
|
||||
│ ├── neopets_media_archive.rb # Local mirror of images.neopets.com
|
||||
│ └── lebron_nc_values.rb # NC item trading values (external API)
|
||||
│
|
||||
├── javascript/
|
||||
│ ├── wardrobe-2020/ # React outfit editor (extracted from Impress 2020)
|
||||
│ │ ├── loaders/ # REST API calls (migrated from GraphQL)
|
||||
│ │ ├── WardrobePage/ # Main editor UI
|
||||
│ │ └── components/ # Shared React components
|
||||
│ └── application.js # Rails asset pipeline entrypoint
|
||||
│
|
||||
└── views/
|
||||
├── outfits/
|
||||
│ └── edit.html.haml # Outfit editor page (loads React app)
|
||||
├── items/
|
||||
│ └── show.html.haml # Item detail page
|
||||
└── closet_hangers/
|
||||
└── index.html.haml # User closet/item lists
|
||||
```
|
||||
|
||||
### Configuration & Docs
|
||||
|
||||
```
|
||||
config/
|
||||
├── routes.rb # All Rails routes
|
||||
├── database.yml # Multi-database setup (main + openneo_id)
|
||||
└── environments/
|
||||
└── *.rb # Env-specific config (incl. impress_2020_origin)
|
||||
```
|
||||
|
||||
**Documentation:**
|
||||
- [docs/customization-architecture.md](./docs/customization-architecture.md) - Deep dive into data model & rendering
|
||||
- [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) - What still depends on Impress 2020 service
|
||||
|
||||
**Tests:**
|
||||
- `test/` - Test::Unit tests (privacy features)
|
||||
- `spec/` - RSpec tests (models, services, integrations)
|
||||
- Coverage is focused on key areas: modeling, prediction logic, external APIs
|
||||
- Not comprehensive, but thorough for critical behaviors
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Backend**: Ruby on Rails (Ruby 3.4, Rails 8.0)
|
||||
- **Frontend**: Mix of Rails views (Turbo/HAML) and React (for outfit editor)
|
||||
- **Database**: MySQL (two databases: `openneo_impress`, `openneo_id`)
|
||||
- **Styling**: CSS, Sass (moving toward modern Rails conventions)
|
||||
- **External Integrations**:
|
||||
- **Neopets.com**: Legacy Flash/AMF protocol for pet appearance data (modeling)
|
||||
- **Neopets NC Mall**: Web scraping for NC item availability/pricing
|
||||
- **NeoPass**: OAuth integration for Neopets account linking
|
||||
- **Neopets Media Archive**: Local filesystem mirror of `images.neopets.com` (never discards old files)
|
||||
- **Lebron's NC Values**: Third-party API for NC item trading values ([lebron-values.netlify.app](https://lebron-values.netlify.app))
|
||||
- **Impress 2020**: GraphQL for some outfit data, image generation service (being phased out)
|
||||
|
||||
## Development Notes
|
||||
|
||||
### OpenNeo ID Database
|
||||
|
||||
The `openneo_id` database is a legacy from when authentication was a separate service ("OpenNeo ID") meant to unify auth across multiple OpenNeo projects. DTI was the only project that succeeded, so the apps were merged—but the database split remains for now.
|
||||
|
||||
**Implications**:
|
||||
- Rails is configured for multi-database mode
|
||||
- User auth models live in `auth_user.rb` and connect to `openneo_id`
|
||||
- **⚠️ CRITICAL**: Impress 2020 also directly accesses both `openneo_impress` and `openneo_id` databases via SQL
|
||||
- **Database migrations affecting these schemas must consider Impress 2020's direct access**
|
||||
- See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for full details on this dependency
|
||||
|
||||
### Rails/React Hybrid
|
||||
|
||||
Most pages are traditional Rails views using Turbo for interactivity. The **outfit editor** (`/outfits/new`) is a full React app that:
|
||||
|
||||
- Loads into a `#wardrobe-2020-root` div
|
||||
- Uses React Query for data fetching
|
||||
- Calls both Rails REST APIs (in `loaders/`) and Impress 2020 GraphQL (being migrated)
|
||||
|
||||
The goal is to simplify this over time—either consolidate into Rails+Turbo, or commit fully to React. For now, we're in a hybrid state.
|
||||
|
||||
## Deployment
|
||||
|
||||
- **Main app**: VPS running Rails (Puma, MySQL)
|
||||
- **Impress 2020**: Separate VPS in same datacenter (NextJS, GraphQL, headless browser for images)
|
||||
- **Shared databases**: Both services directly access the same MySQL databases over the network
|
||||
- `openneo_impress` - Main application data
|
||||
- `openneo_id` - Authentication data
|
||||
- ⚠️ **Any database schema changes must be compatible with both services**
|
||||
|
||||
---
|
||||
|
||||
**Project maintained by [@matchu](https://github.com/matchu)** • **[OpenNeo.net](https://openneo.net)**
|
||||
There'll be more to say about it here soon :3
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
//= link_tree ../images
|
||||
//= link_tree ../javascripts .js
|
||||
//= link_tree ../../../vendor/javascript .js
|
||||
//= link_tree ../stylesheets .css
|
||||
//= link_directory ../fonts .otf
|
||||
//= link_tree ../builds
|
||||
|
|
|
|||
BIN
app/assets/fonts/Delicious-Heavy.otf
Normal file
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
BIN
app/assets/images/about/neopass-header.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
app/assets/images/about/neopass-survey.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
app/assets/images/about/neopass-survey@2x.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
app/assets/images/broken_item_thumbnail.gif
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
app/assets/images/emoticons/grin.gif
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
app/assets/images/emoticons/tongue.gif
Normal file
|
After Width: | Height: | Size: 601 B |
BIN
app/assets/images/grid.png
Normal file
|
After Width: | Height: | Size: 206 B |
BIN
app/assets/images/image_mode_icon.png
Normal file
|
After Width: | Height: | Size: 516 B |
BIN
app/assets/images/image_mode_preview.png
Normal file
|
After Width: | Height: | Size: 127 KiB |
BIN
app/assets/images/loading_current_outfit.gif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/loading_outfit_pane.gif
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
app/assets/images/outfits/default.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
app/assets/images/outfits/medium_default.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
app/assets/images/outfits/small_default.png
Normal file
|
After Width: | Height: | Size: 5.1 KiB |
BIN
app/assets/images/outfits/small_loading.gif
Normal file
|
After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
20
app/assets/javascripts/ajax_auth.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
(function () {
|
||||
var CSRFProtection;
|
||||
var token = $('meta[name="csrf-token"]').attr("content");
|
||||
if (token) {
|
||||
CSRFProtection = function (xhr, settings) {
|
||||
var sendToken =
|
||||
typeof settings.useCSRFProtection === "undefined" || // default to true
|
||||
settings.useCSRFProtection;
|
||||
if (sendToken) {
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
}
|
||||
};
|
||||
} else {
|
||||
CSRFProtection = $.noop;
|
||||
}
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: CSRFProtection,
|
||||
});
|
||||
})();
|
||||
8
app/assets/javascripts/closet_hangers/petpage.js
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
(function () {
|
||||
function setChecked() {
|
||||
var el = $(this);
|
||||
el.closest("li").toggleClass("checked", el.is(":checked"));
|
||||
}
|
||||
|
||||
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
|
||||
})();
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
document.addEventListener("change", ({ target }) => {
|
||||
if (target.matches('select[name="closet_list[visibility]"]')) {
|
||||
target.closest("form").setAttribute("data-list-visibility", target.value);
|
||||
target
|
||||
.closest("form")
|
||||
.setAttribute("data-list-visibility", target.value);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
(function () {
|
||||
$("span.choose-outfit select").change(function (e) {
|
||||
var select = $(this);
|
||||
select.closest("li").find("input[type=text]").val(select.val());
|
||||
});
|
||||
(function() {
|
||||
$('span.choose-outfit select').change(function(e) {
|
||||
var select = $(this);
|
||||
select.closest('li').find('input[type=text]').val(select.val());
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,115 +1,76 @@
|
|||
// When the species face picker changes, update and submit the main picker form.
|
||||
document.addEventListener("change", (e) => {
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
if (!e.target.matches("species-face-picker")) return;
|
||||
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form",
|
||||
);
|
||||
const mainSpeciesField = mainPickerForm.querySelector(
|
||||
"[name='preview[species_id]']",
|
||||
);
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
// If the preview frame fails to load, try a full pageload.
|
||||
document.addEventListener("turbo:frame-missing", (e) => {
|
||||
if (!e.target.matches("#item-preview")) return;
|
||||
|
||||
e.detail.visit(e.detail.response.url);
|
||||
e.preventDefault();
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form");
|
||||
const mainSpeciesField =
|
||||
mainPickerForm.querySelector("[name='preview[species_id]']");
|
||||
mainSpeciesField.value = e.target.value;
|
||||
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
|
||||
} catch (error) {
|
||||
console.error("Couldn't update species picker: ", error);
|
||||
}
|
||||
});
|
||||
|
||||
class SpeciesColorPicker extends HTMLElement {
|
||||
#internals;
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePicker extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
}
|
||||
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
get value() {
|
||||
return this.querySelector("input[type=radio]:checked")?.value;
|
||||
}
|
||||
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
}
|
||||
#handleClick(e) {
|
||||
if (e.target.matches("input[type=radio]")) {
|
||||
this.dispatchEvent(new Event("change", {bubbles: true}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePickerOptions extends HTMLElement {
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
static observedAttributes = ["inert", "aria-hidden"];
|
||||
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
connectedCallback() {
|
||||
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
attributeChangedCallback() {
|
||||
// If a Turbo Frame tries to morph us into being inert again, activate again!
|
||||
// (It's important that the server's HTML always return `inert`, for progressive
|
||||
// enhancement; and it's important to morph this element, so radio focus state
|
||||
// is preserved. To thread that needle, we have to monitor and remove!)
|
||||
this.#activate();
|
||||
}
|
||||
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
|
||||
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
|
||||
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
|
||||
class MeasuredContainer extends HTMLElement {
|
||||
static observedAttributes = ["style"];
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#measure(), 0);
|
||||
}
|
||||
|
||||
attributeChangedCallback() {
|
||||
// When `--natural-width` gets morphed away by Turbo, measure it again!
|
||||
if (this.style.getPropertyValue("--natural-width") === "") {
|
||||
this.#measure();
|
||||
}
|
||||
}
|
||||
|
||||
#measure() {
|
||||
// Find our `<measured-content>` child, and set our natural width as
|
||||
// `var(--natural-width)` in the context of our CSS styles.
|
||||
const content = this.querySelector("measured-content");
|
||||
if (content == null) {
|
||||
throw new Error(`<measured-container> must contain a <measured-content>`);
|
||||
}
|
||||
this.style.setProperty("--natural-width", content.offsetWidth + "px");
|
||||
}
|
||||
#activate() {
|
||||
this.removeAttribute("inert");
|
||||
this.removeAttribute("aria-hidden");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
||||
customElements.define("measured-container", MeasuredContainer);
|
||||
|
|
|
|||
850
app/assets/javascripts/lib/idiomorph.js
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/dist/idiomorph.js
|
||||
|
||||
// base IIFE to define idiomorph
|
||||
var Idiomorph = (function () {
|
||||
'use strict';
|
||||
|
||||
//=============================================================================
|
||||
// AND NOW IT BEGINS...
|
||||
//=============================================================================
|
||||
let EMPTY_SET = new Set();
|
||||
|
||||
// default configuration values, updatable by users now
|
||||
let defaults = {
|
||||
morphStyle: "outerHTML",
|
||||
callbacks : {
|
||||
beforeNodeAdded: noOp,
|
||||
afterNodeAdded: noOp,
|
||||
beforeNodeMorphed: noOp,
|
||||
afterNodeMorphed: noOp,
|
||||
beforeNodeRemoved: noOp,
|
||||
afterNodeRemoved: noOp,
|
||||
beforeAttributeUpdated: noOp,
|
||||
|
||||
},
|
||||
head: {
|
||||
style: 'merge',
|
||||
shouldPreserve: function (elt) {
|
||||
return elt.getAttribute("im-preserve") === "true";
|
||||
},
|
||||
shouldReAppend: function (elt) {
|
||||
return elt.getAttribute("im-re-append") === "true";
|
||||
},
|
||||
shouldRemove: noOp,
|
||||
afterHeadMorphed: noOp,
|
||||
}
|
||||
};
|
||||
|
||||
//=============================================================================
|
||||
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
|
||||
//=============================================================================
|
||||
function morph(oldNode, newContent, config = {}) {
|
||||
|
||||
if (oldNode instanceof Document) {
|
||||
oldNode = oldNode.documentElement;
|
||||
}
|
||||
|
||||
if (typeof newContent === 'string') {
|
||||
newContent = parseContent(newContent);
|
||||
}
|
||||
|
||||
let normalizedContent = normalizeContent(newContent);
|
||||
|
||||
let ctx = createMorphContext(oldNode, normalizedContent, config);
|
||||
|
||||
return morphNormalizedContent(oldNode, normalizedContent, ctx);
|
||||
}
|
||||
|
||||
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
|
||||
if (ctx.head.block) {
|
||||
let oldHead = oldNode.querySelector('head');
|
||||
let newHead = normalizedNewContent.querySelector('head');
|
||||
if (oldHead && newHead) {
|
||||
let promises = handleHeadElement(newHead, oldHead, ctx);
|
||||
// when head promises resolve, call morph again, ignoring the head tag
|
||||
Promise.all(promises).then(function () {
|
||||
morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
|
||||
head: {
|
||||
block: false,
|
||||
ignore: true
|
||||
}
|
||||
}));
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (ctx.morphStyle === "innerHTML") {
|
||||
|
||||
// innerHTML, so we are only updating the children
|
||||
morphChildren(normalizedNewContent, oldNode, ctx);
|
||||
return oldNode.children;
|
||||
|
||||
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
|
||||
// otherwise find the best element match in the new content, morph that, and merge its siblings
|
||||
// into either side of the best match
|
||||
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
|
||||
|
||||
// stash the siblings that will need to be inserted on either side of the best match
|
||||
let previousSibling = bestMatch?.previousSibling;
|
||||
let nextSibling = bestMatch?.nextSibling;
|
||||
|
||||
// morph it
|
||||
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
|
||||
|
||||
if (bestMatch) {
|
||||
// if there was a best match, merge the siblings in too and return the
|
||||
// whole bunch
|
||||
return insertSiblings(previousSibling, morphedNode, nextSibling);
|
||||
} else {
|
||||
// otherwise nothing was added to the DOM
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
throw "Do not understand how to morph style " + ctx.morphStyle;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param possibleActiveElement
|
||||
* @param ctx
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
|
||||
return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param oldNode root node to merge content into
|
||||
* @param newContent new content to merge
|
||||
* @param ctx the merge context
|
||||
* @returns {Element} the element that ended up in the DOM
|
||||
*/
|
||||
function morphOldNodeTo(oldNode, newContent, ctx) {
|
||||
if (ctx.ignoreActive && oldNode === document.activeElement) {
|
||||
// don't morph focused element
|
||||
} else if (newContent == null) {
|
||||
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
|
||||
|
||||
oldNode.remove();
|
||||
ctx.callbacks.afterNodeRemoved(oldNode);
|
||||
return null;
|
||||
} else if (!isSoftMatch(oldNode, newContent)) {
|
||||
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
|
||||
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
|
||||
|
||||
oldNode.parentElement.replaceChild(newContent, oldNode);
|
||||
ctx.callbacks.afterNodeAdded(newContent);
|
||||
ctx.callbacks.afterNodeRemoved(oldNode);
|
||||
return newContent;
|
||||
} else {
|
||||
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
|
||||
|
||||
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
|
||||
// ignore the head element
|
||||
} else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
|
||||
handleHeadElement(newContent, oldNode, ctx);
|
||||
} else {
|
||||
syncNodeFrom(newContent, oldNode, ctx);
|
||||
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
|
||||
morphChildren(newContent, oldNode, ctx);
|
||||
}
|
||||
}
|
||||
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
|
||||
return oldNode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
|
||||
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
|
||||
* by using id sets, we are able to better match up with content deeper in the DOM.
|
||||
*
|
||||
* Basic algorithm is, for each node in the new content:
|
||||
*
|
||||
* - if we have reached the end of the old parent, append the new content
|
||||
* - if the new content has an id set match with the current insertion point, morph
|
||||
* - search for an id set match
|
||||
* - if id set match found, morph
|
||||
* - otherwise search for a "soft" match
|
||||
* - if a soft match is found, morph
|
||||
* - otherwise, prepend the new node before the current insertion point
|
||||
*
|
||||
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
|
||||
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
|
||||
*
|
||||
* @param {Element} newParent the parent element of the new content
|
||||
* @param {Element } oldParent the old content that we are merging the new content into
|
||||
* @param ctx the merge context
|
||||
*/
|
||||
function morphChildren(newParent, oldParent, ctx) {
|
||||
|
||||
let nextNewChild = newParent.firstChild;
|
||||
let insertionPoint = oldParent.firstChild;
|
||||
let newChild;
|
||||
|
||||
// run through all the new content
|
||||
while (nextNewChild) {
|
||||
|
||||
newChild = nextNewChild;
|
||||
nextNewChild = newChild.nextSibling;
|
||||
|
||||
// if we are at the end of the exiting parent's children, just append
|
||||
if (insertionPoint == null) {
|
||||
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
|
||||
|
||||
oldParent.appendChild(newChild);
|
||||
ctx.callbacks.afterNodeAdded(newChild);
|
||||
removeIdsFromConsideration(ctx, newChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
// if the current node has an id set match then morph
|
||||
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
|
||||
morphOldNodeTo(insertionPoint, newChild, ctx);
|
||||
insertionPoint = insertionPoint.nextSibling;
|
||||
removeIdsFromConsideration(ctx, newChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
// otherwise search forward in the existing old children for an id set match
|
||||
let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
|
||||
|
||||
// if we found a potential match, remove the nodes until that point and morph
|
||||
if (idSetMatch) {
|
||||
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
|
||||
morphOldNodeTo(idSetMatch, newChild, ctx);
|
||||
removeIdsFromConsideration(ctx, newChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
// no id set match found, so scan forward for a soft match for the current node
|
||||
let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
|
||||
|
||||
// if we found a soft match for the current node, morph
|
||||
if (softMatch) {
|
||||
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
|
||||
morphOldNodeTo(softMatch, newChild, ctx);
|
||||
removeIdsFromConsideration(ctx, newChild);
|
||||
continue;
|
||||
}
|
||||
|
||||
// abandon all hope of morphing, just insert the new child before the insertion point
|
||||
// and move on
|
||||
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
|
||||
|
||||
oldParent.insertBefore(newChild, insertionPoint);
|
||||
ctx.callbacks.afterNodeAdded(newChild);
|
||||
removeIdsFromConsideration(ctx, newChild);
|
||||
}
|
||||
|
||||
// remove any remaining old nodes that didn't match up with new content
|
||||
while (insertionPoint !== null) {
|
||||
|
||||
let tempNode = insertionPoint;
|
||||
insertionPoint = insertionPoint.nextSibling;
|
||||
removeNode(tempNode, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// Attribute Syncing Code
|
||||
//=============================================================================
|
||||
|
||||
/**
|
||||
* @param attr {String} the attribute to be mutated
|
||||
* @param to {Element} the element that is going to be updated
|
||||
* @param updateType {("update"|"remove")}
|
||||
* @param ctx the merge context
|
||||
* @returns {boolean} true if the attribute should be ignored, false otherwise
|
||||
*/
|
||||
function ignoreAttribute(attr, to, updateType, ctx) {
|
||||
if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
|
||||
return true;
|
||||
}
|
||||
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
|
||||
}
|
||||
|
||||
/**
|
||||
* syncs a given node with another node, copying over all attributes and
|
||||
* inner element state from the 'from' node to the 'to' node
|
||||
*
|
||||
* @param {Element} from the element to copy attributes & state from
|
||||
* @param {Element} to the element to copy attributes & state to
|
||||
* @param ctx the merge context
|
||||
*/
|
||||
function syncNodeFrom(from, to, ctx) {
|
||||
let type = from.nodeType
|
||||
|
||||
// if is an element type, sync the attributes from the
|
||||
// new node into the new node
|
||||
if (type === 1 /* element type */) {
|
||||
const fromAttributes = from.attributes;
|
||||
const toAttributes = to.attributes;
|
||||
for (const fromAttribute of fromAttributes) {
|
||||
if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
|
||||
continue;
|
||||
}
|
||||
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
|
||||
to.setAttribute(fromAttribute.name, fromAttribute.value);
|
||||
}
|
||||
}
|
||||
// iterate backwards to avoid skipping over items when a delete occurs
|
||||
for (let i = toAttributes.length - 1; 0 <= i; i--) {
|
||||
const toAttribute = toAttributes[i];
|
||||
if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
|
||||
continue;
|
||||
}
|
||||
if (!from.hasAttribute(toAttribute.name)) {
|
||||
to.removeAttribute(toAttribute.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sync text nodes
|
||||
if (type === 8 /* comment */ || type === 3 /* text */) {
|
||||
if (to.nodeValue !== from.nodeValue) {
|
||||
to.nodeValue = from.nodeValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!ignoreValueOfActiveElement(to, ctx)) {
|
||||
// sync input values
|
||||
syncInputValue(from, to, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param from {Element} element to sync the value from
|
||||
* @param to {Element} element to sync the value to
|
||||
* @param attributeName {String} the attribute name
|
||||
* @param ctx the merge context
|
||||
*/
|
||||
function syncBooleanAttribute(from, to, attributeName, ctx) {
|
||||
if (from[attributeName] !== to[attributeName]) {
|
||||
let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
|
||||
if (!ignoreUpdate) {
|
||||
to[attributeName] = from[attributeName];
|
||||
}
|
||||
if (from[attributeName]) {
|
||||
if (!ignoreUpdate) {
|
||||
to.setAttribute(attributeName, from[attributeName]);
|
||||
}
|
||||
} else {
|
||||
if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
|
||||
to.removeAttribute(attributeName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NB: many bothans died to bring us information:
|
||||
*
|
||||
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
|
||||
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
|
||||
*
|
||||
* @param from {Element} the element to sync the input value from
|
||||
* @param to {Element} the element to sync the input value to
|
||||
* @param ctx the merge context
|
||||
*/
|
||||
function syncInputValue(from, to, ctx) {
|
||||
if (from instanceof HTMLInputElement &&
|
||||
to instanceof HTMLInputElement &&
|
||||
from.type !== 'file') {
|
||||
|
||||
let fromValue = from.value;
|
||||
let toValue = to.value;
|
||||
|
||||
// sync boolean attributes
|
||||
syncBooleanAttribute(from, to, 'checked', ctx);
|
||||
syncBooleanAttribute(from, to, 'disabled', ctx);
|
||||
|
||||
if (!from.hasAttribute('value')) {
|
||||
if (!ignoreAttribute('value', to, 'remove', ctx)) {
|
||||
to.value = '';
|
||||
to.removeAttribute('value');
|
||||
}
|
||||
} else if (fromValue !== toValue) {
|
||||
if (!ignoreAttribute('value', to, 'update', ctx)) {
|
||||
to.setAttribute('value', fromValue);
|
||||
to.value = fromValue;
|
||||
}
|
||||
}
|
||||
} else if (from instanceof HTMLOptionElement) {
|
||||
syncBooleanAttribute(from, to, 'selected', ctx)
|
||||
} else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
|
||||
let fromValue = from.value;
|
||||
let toValue = to.value;
|
||||
if (ignoreAttribute('value', to, 'update', ctx)) {
|
||||
return;
|
||||
}
|
||||
if (fromValue !== toValue) {
|
||||
to.value = fromValue;
|
||||
}
|
||||
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
|
||||
to.firstChild.nodeValue = fromValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
|
||||
//=============================================================================
|
||||
function handleHeadElement(newHeadTag, currentHead, ctx) {
|
||||
|
||||
let added = []
|
||||
let removed = []
|
||||
let preserved = []
|
||||
let nodesToAppend = []
|
||||
|
||||
let headMergeStyle = ctx.head.style;
|
||||
|
||||
// put all new head elements into a Map, by their outerHTML
|
||||
let srcToNewHeadNodes = new Map();
|
||||
for (const newHeadChild of newHeadTag.children) {
|
||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
||||
}
|
||||
|
||||
// for each elt in the current head
|
||||
for (const currentHeadElt of currentHead.children) {
|
||||
|
||||
// If the current head element is in the map
|
||||
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
||||
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
|
||||
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
|
||||
if (inNewContent || isPreserved) {
|
||||
if (isReAppended) {
|
||||
// remove the current version and let the new version replace it and re-execute
|
||||
removed.push(currentHeadElt);
|
||||
} else {
|
||||
// this element already exists and should not be re-appended, so remove it from
|
||||
// the new content map, preserving it in the DOM
|
||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
||||
preserved.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
if (headMergeStyle === "append") {
|
||||
// we are appending and this existing element is not new content
|
||||
// so if and only if it is marked for re-append do we do anything
|
||||
if (isReAppended) {
|
||||
removed.push(currentHeadElt);
|
||||
nodesToAppend.push(currentHeadElt);
|
||||
}
|
||||
} else {
|
||||
// if this is a merge, we remove this content since it is not in the new head
|
||||
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
|
||||
removed.push(currentHeadElt);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push the remaining new head elements in the Map into the
|
||||
// nodes to append to the head tag
|
||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
||||
log("to append: ", nodesToAppend);
|
||||
|
||||
let promises = [];
|
||||
for (const newNode of nodesToAppend) {
|
||||
log("adding: ", newNode);
|
||||
let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
|
||||
log(newElt);
|
||||
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
|
||||
if (newElt.href || newElt.src) {
|
||||
let resolve = null;
|
||||
let promise = new Promise(function (_resolve) {
|
||||
resolve = _resolve;
|
||||
});
|
||||
newElt.addEventListener('load', function () {
|
||||
resolve();
|
||||
});
|
||||
promises.push(promise);
|
||||
}
|
||||
currentHead.appendChild(newElt);
|
||||
ctx.callbacks.afterNodeAdded(newElt);
|
||||
added.push(newElt);
|
||||
}
|
||||
}
|
||||
|
||||
// remove all removed elements, after we have appended the new elements to avoid
|
||||
// additional network requests for things like style sheets
|
||||
for (const removedElement of removed) {
|
||||
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
|
||||
currentHead.removeChild(removedElement);
|
||||
ctx.callbacks.afterNodeRemoved(removedElement);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
|
||||
return promises;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// Misc
|
||||
//=============================================================================
|
||||
|
||||
function log() {
|
||||
//console.log(arguments);
|
||||
}
|
||||
|
||||
function noOp() {
|
||||
}
|
||||
|
||||
/*
|
||||
Deep merges the config object and the Idiomoroph.defaults object to
|
||||
produce a final configuration object
|
||||
*/
|
||||
function mergeDefaults(config) {
|
||||
let finalConfig = {};
|
||||
// copy top level stuff into final config
|
||||
Object.assign(finalConfig, defaults);
|
||||
Object.assign(finalConfig, config);
|
||||
|
||||
// copy callbacks into final config (do this to deep merge the callbacks)
|
||||
finalConfig.callbacks = {};
|
||||
Object.assign(finalConfig.callbacks, defaults.callbacks);
|
||||
Object.assign(finalConfig.callbacks, config.callbacks);
|
||||
|
||||
// copy head config into final config (do this to deep merge the head)
|
||||
finalConfig.head = {};
|
||||
Object.assign(finalConfig.head, defaults.head);
|
||||
Object.assign(finalConfig.head, config.head);
|
||||
return finalConfig;
|
||||
}
|
||||
|
||||
function createMorphContext(oldNode, newContent, config) {
|
||||
config = mergeDefaults(config);
|
||||
return {
|
||||
target: oldNode,
|
||||
newContent: newContent,
|
||||
config: config,
|
||||
morphStyle: config.morphStyle,
|
||||
ignoreActive: config.ignoreActive,
|
||||
ignoreActiveValue: config.ignoreActiveValue,
|
||||
idMap: createIdMap(oldNode, newContent),
|
||||
deadIds: new Set(),
|
||||
callbacks: config.callbacks,
|
||||
head: config.head
|
||||
}
|
||||
}
|
||||
|
||||
function isIdSetMatch(node1, node2, ctx) {
|
||||
if (node1 == null || node2 == null) {
|
||||
return false;
|
||||
}
|
||||
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
|
||||
if (node1.id !== "" && node1.id === node2.id) {
|
||||
return true;
|
||||
} else {
|
||||
return getIdIntersectionCount(ctx, node1, node2) > 0;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isSoftMatch(node1, node2) {
|
||||
if (node1 == null || node2 == null) {
|
||||
return false;
|
||||
}
|
||||
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
|
||||
}
|
||||
|
||||
function removeNodesBetween(startInclusive, endExclusive, ctx) {
|
||||
while (startInclusive !== endExclusive) {
|
||||
let tempNode = startInclusive;
|
||||
startInclusive = startInclusive.nextSibling;
|
||||
removeNode(tempNode, ctx);
|
||||
}
|
||||
removeIdsFromConsideration(ctx, endExclusive);
|
||||
return endExclusive.nextSibling;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// Scans forward from the insertionPoint in the old parent looking for a potential id match
|
||||
// for the newChild. We stop if we find a potential id match for the new child OR
|
||||
// if the number of potential id matches we are discarding is greater than the
|
||||
// potential id matches for the new child
|
||||
//=============================================================================
|
||||
function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
|
||||
|
||||
// max id matches we are willing to discard in our search
|
||||
let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
|
||||
|
||||
let potentialMatch = null;
|
||||
|
||||
// only search forward if there is a possibility of an id match
|
||||
if (newChildPotentialIdCount > 0) {
|
||||
let potentialMatch = insertionPoint;
|
||||
// if there is a possibility of an id match, scan forward
|
||||
// keep track of the potential id match count we are discarding (the
|
||||
// newChildPotentialIdCount must be greater than this to make it likely
|
||||
// worth it)
|
||||
let otherMatchCount = 0;
|
||||
while (potentialMatch != null) {
|
||||
|
||||
// If we have an id match, return the current potential match
|
||||
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
|
||||
return potentialMatch;
|
||||
}
|
||||
|
||||
// computer the other potential matches of this new content
|
||||
otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
|
||||
if (otherMatchCount > newChildPotentialIdCount) {
|
||||
// if we have more potential id matches in _other_ content, we
|
||||
// do not have a good candidate for an id match, so return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// advanced to the next old content child
|
||||
potentialMatch = potentialMatch.nextSibling;
|
||||
}
|
||||
}
|
||||
return potentialMatch;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// Scans forward from the insertionPoint in the old parent looking for a potential soft match
|
||||
// for the newChild. We stop if we find a potential soft match for the new child OR
|
||||
// if we find a potential id match in the old parents children OR if we find two
|
||||
// potential soft matches for the next two pieces of new content
|
||||
//=============================================================================
|
||||
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
|
||||
|
||||
let potentialSoftMatch = insertionPoint;
|
||||
let nextSibling = newChild.nextSibling;
|
||||
let siblingSoftMatchCount = 0;
|
||||
|
||||
while (potentialSoftMatch != null) {
|
||||
|
||||
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
|
||||
// the current potential soft match has a potential id set match with the remaining new
|
||||
// content so bail out of looking
|
||||
return null;
|
||||
}
|
||||
|
||||
// if we have a soft match with the current node, return it
|
||||
if (isSoftMatch(newChild, potentialSoftMatch)) {
|
||||
return potentialSoftMatch;
|
||||
}
|
||||
|
||||
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
|
||||
// the next new node has a soft match with this node, so
|
||||
// increment the count of future soft matches
|
||||
siblingSoftMatchCount++;
|
||||
nextSibling = nextSibling.nextSibling;
|
||||
|
||||
// If there are two future soft matches, bail to allow the siblings to soft match
|
||||
// so that we don't consume future soft matches for the sake of the current node
|
||||
if (siblingSoftMatchCount >= 2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// advanced to the next old content child
|
||||
potentialSoftMatch = potentialSoftMatch.nextSibling;
|
||||
}
|
||||
|
||||
return potentialSoftMatch;
|
||||
}
|
||||
|
||||
function parseContent(newContent) {
|
||||
let parser = new DOMParser();
|
||||
|
||||
// remove svgs to avoid false-positive matches on head, etc.
|
||||
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
||||
|
||||
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
|
||||
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
|
||||
let content = parser.parseFromString(newContent, "text/html");
|
||||
// if it is a full HTML document, return the document itself as the parent container
|
||||
if (contentWithSvgsRemoved.match(/<\/html>/)) {
|
||||
content.generatedByIdiomorph = true;
|
||||
return content;
|
||||
} else {
|
||||
// otherwise return the html element as the parent container
|
||||
let htmlElement = content.firstChild;
|
||||
if (htmlElement) {
|
||||
htmlElement.generatedByIdiomorph = true;
|
||||
return htmlElement;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
|
||||
// deal with touchy tags like tr, tbody, etc.
|
||||
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
|
||||
let content = responseDoc.body.querySelector('template').content;
|
||||
content.generatedByIdiomorph = true;
|
||||
return content
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContent(newContent) {
|
||||
if (newContent == null) {
|
||||
// noinspection UnnecessaryLocalVariableJS
|
||||
const dummyParent = document.createElement('div');
|
||||
return dummyParent;
|
||||
} else if (newContent.generatedByIdiomorph) {
|
||||
// the template tag created by idiomorph parsing can serve as a dummy parent
|
||||
return newContent;
|
||||
} else if (newContent instanceof Node) {
|
||||
// a single node is added as a child to a dummy parent
|
||||
const dummyParent = document.createElement('div');
|
||||
dummyParent.append(newContent);
|
||||
return dummyParent;
|
||||
} else {
|
||||
// all nodes in the array or HTMLElement collection are consolidated under
|
||||
// a single dummy parent element
|
||||
const dummyParent = document.createElement('div');
|
||||
for (const elt of [...newContent]) {
|
||||
dummyParent.append(elt);
|
||||
}
|
||||
return dummyParent;
|
||||
}
|
||||
}
|
||||
|
||||
function insertSiblings(previousSibling, morphedNode, nextSibling) {
|
||||
let stack = []
|
||||
let added = []
|
||||
while (previousSibling != null) {
|
||||
stack.push(previousSibling);
|
||||
previousSibling = previousSibling.previousSibling;
|
||||
}
|
||||
while (stack.length > 0) {
|
||||
let node = stack.pop();
|
||||
added.push(node); // push added preceding siblings on in order and insert
|
||||
morphedNode.parentElement.insertBefore(node, morphedNode);
|
||||
}
|
||||
added.push(morphedNode);
|
||||
while (nextSibling != null) {
|
||||
stack.push(nextSibling);
|
||||
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
|
||||
nextSibling = nextSibling.nextSibling;
|
||||
}
|
||||
while (stack.length > 0) {
|
||||
morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
function findBestNodeMatch(newContent, oldNode, ctx) {
|
||||
let currentElement;
|
||||
currentElement = newContent.firstChild;
|
||||
let bestElement = currentElement;
|
||||
let score = 0;
|
||||
while (currentElement) {
|
||||
let newScore = scoreElement(currentElement, oldNode, ctx);
|
||||
if (newScore > score) {
|
||||
bestElement = currentElement;
|
||||
score = newScore;
|
||||
}
|
||||
currentElement = currentElement.nextSibling;
|
||||
}
|
||||
return bestElement;
|
||||
}
|
||||
|
||||
function scoreElement(node1, node2, ctx) {
|
||||
if (isSoftMatch(node1, node2)) {
|
||||
return .5 + getIdIntersectionCount(ctx, node1, node2);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function removeNode(tempNode, ctx) {
|
||||
removeIdsFromConsideration(ctx, tempNode)
|
||||
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
|
||||
|
||||
tempNode.remove();
|
||||
ctx.callbacks.afterNodeRemoved(tempNode);
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// ID Set Functions
|
||||
//=============================================================================
|
||||
|
||||
function isIdInConsideration(ctx, id) {
|
||||
return !ctx.deadIds.has(id);
|
||||
}
|
||||
|
||||
function idIsWithinNode(ctx, id, targetNode) {
|
||||
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
|
||||
return idSet.has(id);
|
||||
}
|
||||
|
||||
function removeIdsFromConsideration(ctx, node) {
|
||||
let idSet = ctx.idMap.get(node) || EMPTY_SET;
|
||||
for (const id of idSet) {
|
||||
ctx.deadIds.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
function getIdIntersectionCount(ctx, node1, node2) {
|
||||
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
|
||||
let matchCount = 0;
|
||||
for (const id of sourceSet) {
|
||||
// a potential match is an id in the source and potentialIdsSet, but
|
||||
// that has not already been merged into the DOM
|
||||
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
|
||||
++matchCount;
|
||||
}
|
||||
}
|
||||
return matchCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* A bottom up algorithm that finds all elements with ids inside of the node
|
||||
* argument and populates id sets for those nodes and all their parents, generating
|
||||
* a set of ids contained within all nodes for the entire hierarchy in the DOM
|
||||
*
|
||||
* @param node {Element}
|
||||
* @param {Map<Node, Set<String>>} idMap
|
||||
*/
|
||||
function populateIdMapForNode(node, idMap) {
|
||||
let nodeParent = node.parentElement;
|
||||
// find all elements with an id property
|
||||
let idElements = node.querySelectorAll('[id]');
|
||||
for (const elt of idElements) {
|
||||
let current = elt;
|
||||
// walk up the parent hierarchy of that element, adding the id
|
||||
// of element to the parent's id set
|
||||
while (current !== nodeParent && current != null) {
|
||||
let idSet = idMap.get(current);
|
||||
// if the id set doesn't exist, create it and insert it in the map
|
||||
if (idSet == null) {
|
||||
idSet = new Set();
|
||||
idMap.set(current, idSet);
|
||||
}
|
||||
idSet.add(elt.id);
|
||||
current = current.parentElement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This function computes a map of nodes to all ids contained within that node (inclusive of the
|
||||
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
|
||||
* for a looser definition of "matching" than tradition id matching, and allows child nodes
|
||||
* to contribute to a parent nodes matching.
|
||||
*
|
||||
* @param {Element} oldContent the old content that will be morphed
|
||||
* @param {Element} newContent the new content to morph to
|
||||
* @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
|
||||
*/
|
||||
function createIdMap(oldContent, newContent) {
|
||||
let idMap = new Map();
|
||||
populateIdMapForNode(oldContent, idMap);
|
||||
populateIdMapForNode(newContent, idMap);
|
||||
return idMap;
|
||||
}
|
||||
|
||||
//=============================================================================
|
||||
// This is what ends up becoming the Idiomorph global object
|
||||
//=============================================================================
|
||||
return {
|
||||
morph,
|
||||
defaults
|
||||
}
|
||||
})();
|
||||
|
|
@ -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);
|
||||
|
|
@ -104,9 +104,13 @@ class OutfitLayer extends HTMLElement {
|
|||
this.#setStatus("loading");
|
||||
this.#sendMessageToIframe({ type: "requestStatus" });
|
||||
window.addEventListener("message", (m) => this.#onMessage(m));
|
||||
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
||||
this.iframe.addEventListener("error", () =>
|
||||
this.#setStatus("error"),
|
||||
);
|
||||
} else {
|
||||
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
||||
throw new Error(
|
||||
`<outfit-layer> must contain an <img> or <iframe> tag`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -131,7 +135,8 @@ class OutfitLayer extends HTMLElement {
|
|||
}
|
||||
} else {
|
||||
throw new Error(
|
||||
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
|
||||
`<outfit-layer> got unexpected message: ` +
|
||||
JSON.stringify(data),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,253 +1,272 @@
|
|||
(function () {
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
function petImage(id, size) {
|
||||
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
|
||||
}
|
||||
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
var PetQuery = {},
|
||||
query_string = document.location.hash || document.location.search;
|
||||
|
||||
for (const [key, value] of new URLSearchParams(query_string).entries()) {
|
||||
PetQuery[key] = value;
|
||||
}
|
||||
$.each(query_string.substr(1).split("&"), function () {
|
||||
var split_piece = this.split("=");
|
||||
if (split_piece.length == 2) {
|
||||
PetQuery[split_piece[0]] = split_piece[1];
|
||||
}
|
||||
});
|
||||
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
var image_url = petImage("cpn/" + PetQuery.name, 1);
|
||||
if (PetQuery.name.startsWith("@")) {
|
||||
image_url = petImage("cp/" + PetQuery.name.substr(1), 1);
|
||||
}
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: image_url,
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
if (PetQuery.name) {
|
||||
if (PetQuery.species && PetQuery.color) {
|
||||
$("#pet-query-notice-template")
|
||||
.tmpl({
|
||||
pet_name: PetQuery.name,
|
||||
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
|
||||
})
|
||||
.prependTo("#container");
|
||||
}
|
||||
}
|
||||
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
var preview_el = $("#pet-preview"),
|
||||
img_el = preview_el.find("img"),
|
||||
response_el = preview_el.find("span");
|
||||
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
var defaultPreviewUrl = img_el.attr("src");
|
||||
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
preview_el.click(function () {
|
||||
Preview.Job.current.visit();
|
||||
});
|
||||
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
var Preview = {
|
||||
clear: function () {
|
||||
if (typeof Preview.Job.fallback != "undefined")
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
},
|
||||
displayLoading: function () {
|
||||
preview_el.addClass("loading");
|
||||
response_el.text("Loading...");
|
||||
},
|
||||
failed: function () {
|
||||
preview_el.addClass("hidden");
|
||||
},
|
||||
notFound: function (key, options) {
|
||||
Preview.failed();
|
||||
response_el.empty();
|
||||
$("#preview-" + key + "-template")
|
||||
.tmpl(options)
|
||||
.appendTo(response_el);
|
||||
},
|
||||
updateWithName: function (name_el) {
|
||||
var name = name_el.val(),
|
||||
job;
|
||||
if (name) {
|
||||
currentName = name;
|
||||
if (!Preview.Job.current || name != Preview.Job.current.name) {
|
||||
job = new Preview.Job.Name(name);
|
||||
job.setAsCurrent();
|
||||
Preview.displayLoading();
|
||||
}
|
||||
} else {
|
||||
Preview.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
function loadNotable() {
|
||||
// TODO: add HTTPS to notables
|
||||
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
|
||||
// var notables = response.notables;
|
||||
// var i = Math.floor(Math.random() * notables.length);
|
||||
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
|
||||
// if(!Preview.Job.current) {
|
||||
// Preview.Job.fallback.setAsCurrent();
|
||||
// }
|
||||
// });
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
loadFeature();
|
||||
function loadFeature() {
|
||||
$.getJSON("/donations/features", function (features) {
|
||||
if (features.length > 0) {
|
||||
var feature = features[Math.floor(Math.random() * features.length)];
|
||||
Preview.Job.fallback = new Preview.Job.Feature(feature);
|
||||
if (!Preview.Job.current) {
|
||||
Preview.Job.fallback.setAsCurrent();
|
||||
}
|
||||
} else {
|
||||
loadNotable();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
loadFeature();
|
||||
|
||||
function getImageSrc() {
|
||||
if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
Preview.Job = function (key, base) {
|
||||
var job = this,
|
||||
quality = 2;
|
||||
job.loading = false;
|
||||
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
function getImageSrc() {
|
||||
if (key.substr(0, 3) === "a:-") {
|
||||
// lol lazy code for prank image :P
|
||||
// TODO: HTTPS?
|
||||
return (
|
||||
"https://swfimages.impress.openneo.net" +
|
||||
"/biology/000/000/0-2/" +
|
||||
key.substr(2) +
|
||||
"/300x300.png"
|
||||
);
|
||||
} else if (base === "cp" || base === "cpn") {
|
||||
return petImage(base + "/" + key, quality);
|
||||
} else if (base === "url") {
|
||||
return key;
|
||||
} else {
|
||||
throw new Error("unrecognized image base " + base);
|
||||
}
|
||||
}
|
||||
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
function load() {
|
||||
job.loading = true;
|
||||
img_el.attr("src", getImageSrc());
|
||||
}
|
||||
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
this.increaseQualityIfPossible = function () {
|
||||
if (quality == 2) {
|
||||
quality = 4;
|
||||
load();
|
||||
}
|
||||
};
|
||||
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
this.setAsCurrent = function () {
|
||||
Preview.Job.current = job;
|
||||
load();
|
||||
};
|
||||
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
if (name.startsWith("@")) {
|
||||
// This is an image hash "pet name".
|
||||
Preview.Job.apply(this, [name.substr(1), "cp"]);
|
||||
} else {
|
||||
// This is a normal pet name.
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
}
|
||||
this.notFound = function () {
|
||||
Preview.notFound("pet-not-found");
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
Preview.Job.Name = function (name) {
|
||||
this.name = name;
|
||||
Preview.Job.apply(this, [name, "cpn"]);
|
||||
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
this.visit = function () {
|
||||
$(".main-pet-name").val(this.name).closest("form").submit();
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
Preview.Job.Hash = function (hash, form) {
|
||||
Preview.Job.apply(this, [hash, "cp"]);
|
||||
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
this.visit = function () {
|
||||
window.location =
|
||||
"/wardrobe?color=" +
|
||||
form.find(".color").val() +
|
||||
"&species=" +
|
||||
form.find(".species").val();
|
||||
};
|
||||
};
|
||||
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
Preview.Job.Feature = function (feature) {
|
||||
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
|
||||
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
|
||||
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
this.visit = function () {
|
||||
window.location = "/donate";
|
||||
};
|
||||
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
this.notFound = function () {
|
||||
// The outfit thumbnail hasn't generated or is missing or something.
|
||||
// Let's fall back to a boring image for now.
|
||||
var boring = new Preview.Job.Feature({
|
||||
donor_name: feature.donor_name,
|
||||
outfit_image_url: defaultPreviewUrl,
|
||||
});
|
||||
boring.setAsCurrent();
|
||||
};
|
||||
};
|
||||
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
$(function () {
|
||||
var previewWithNameTimeout;
|
||||
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
var name_el = $(".main-pet-name");
|
||||
name_el.val(PetQuery.name);
|
||||
Preview.updateWithName(name_el);
|
||||
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
name_el.keyup(function () {
|
||||
if (previewWithNameTimeout && Preview.Job.current) {
|
||||
clearTimeout(previewWithNameTimeout);
|
||||
Preview.Job.current.loading = false;
|
||||
}
|
||||
var name_el = $(this);
|
||||
previewWithNameTimeout = setTimeout(function () {
|
||||
Preview.updateWithName(name_el);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
img_el
|
||||
.load(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.increaseQualityIfPossible();
|
||||
preview_el
|
||||
.removeClass("loading")
|
||||
.removeClass("hidden")
|
||||
.addClass("loaded");
|
||||
response_el.text(Preview.Job.current.name);
|
||||
}
|
||||
})
|
||||
.error(function () {
|
||||
if (Preview.Job.current.loading) {
|
||||
Preview.Job.loading = false;
|
||||
Preview.Job.current.notFound();
|
||||
}
|
||||
});
|
||||
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
$(".species, .color").change(function () {
|
||||
var type = {},
|
||||
nameComponents = {};
|
||||
var form = $(this).closest("form");
|
||||
form.find("select").each(function () {
|
||||
var el = $(this),
|
||||
selectedEl = el.children(":selected"),
|
||||
key = el.attr("name");
|
||||
type[key] = selectedEl.val();
|
||||
nameComponents[key] = selectedEl.text();
|
||||
});
|
||||
name = nameComponents.color + " " + nameComponents.species;
|
||||
Preview.displayLoading();
|
||||
$.ajax({
|
||||
url:
|
||||
"/species/" +
|
||||
type.species +
|
||||
"/colors/" +
|
||||
type.color +
|
||||
"/pet_type.json",
|
||||
dataType: "json",
|
||||
success: function (data) {
|
||||
var job;
|
||||
if (data) {
|
||||
job = new Preview.Job.Hash(data.image_hash, form);
|
||||
job.name = name;
|
||||
job.setAsCurrent();
|
||||
} else {
|
||||
Preview.notFound("pet-type-not-found", {
|
||||
color_name: nameComponents.color,
|
||||
species_name: nameComponents.species,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
$(".load-pet-to-wardrobe").submit(function (e) {
|
||||
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
|
||||
e.preventDefault();
|
||||
Preview.Job.current.visit();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
$("#latest-contribution-created-at").timeago();
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -1,46 +0,0 @@
|
|||
class SupportOutfitViewer extends HTMLElement {
|
||||
#internals = this.attachInternals();
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("mouseenter", this.#onMouseEnter, { capture: true });
|
||||
this.addEventListener("mouseleave", this.#onMouseLeave, { capture: true });
|
||||
this.addEventListener("click", this.#onClick);
|
||||
this.#internals.states.add("ready");
|
||||
}
|
||||
|
||||
// When a row is hovered, highlight its corresponding outfit viewer layer.
|
||||
#onMouseEnter(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.setAttribute("highlighted", "");
|
||||
}
|
||||
}
|
||||
|
||||
// When a row is unhovered, unhighlight its corresponding outfit viewer layer.
|
||||
#onMouseLeave(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.removeAttribute("highlighted");
|
||||
}
|
||||
}
|
||||
|
||||
// When clicking a row, redirect the click to the first link.
|
||||
#onClick(e) {
|
||||
const row = e.target.closest("tr");
|
||||
if (row == null) return;
|
||||
|
||||
row.querySelector("[data-field=links] a").click();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("support-outfit-viewer", SupportOutfitViewer);
|
||||
|
|
@ -1,110 +1,208 @@
|
|||
var DEBUG = document.location.search.substr(0, 6) == "?debug";
|
||||
|
||||
function petThumbnailUrl(pet_name) {
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
}
|
||||
|
||||
/* Needed items form */
|
||||
(function () {
|
||||
var UI = {};
|
||||
UI.form = $("#needed-items-form");
|
||||
UI.alert = $("#needed-items-alert");
|
||||
UI.pet_name_field = $("#needed-items-pet-name-field");
|
||||
UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
|
||||
UI.pet_header = $("#needed-items-pet-header");
|
||||
UI.reload = $("#needed-items-reload");
|
||||
UI.pet_items = $("#needed-items-pet-items");
|
||||
UI.item_template = $("#item-template");
|
||||
|
||||
var current_request = { abort: function () {} };
|
||||
function sendRequest(options) {
|
||||
current_request = $.ajax(options);
|
||||
}
|
||||
|
||||
function cancelRequest() {
|
||||
if (DEBUG) console.log("Canceling request", current_request);
|
||||
current_request.abort();
|
||||
}
|
||||
|
||||
/* Pet */
|
||||
|
||||
var last_successful_pet_name = null;
|
||||
|
||||
function loadPet(pet_name) {
|
||||
// If there is a request in progress, kill it. Our new pet request takes
|
||||
// priority, and, if I submit a name while the previous name is loading, I
|
||||
// don't want to process both responses.
|
||||
cancelRequest();
|
||||
|
||||
sendRequest({
|
||||
url: UI.form.attr("action") + ".json",
|
||||
dataType: "json",
|
||||
data: { name: pet_name },
|
||||
error: petError,
|
||||
success: function (data) {
|
||||
petSuccess(data, pet_name);
|
||||
},
|
||||
complete: petComplete,
|
||||
});
|
||||
|
||||
UI.form.removeClass("failed").addClass("loading-pet");
|
||||
}
|
||||
|
||||
function petComplete() {
|
||||
UI.form.removeClass("loading-pet");
|
||||
}
|
||||
|
||||
function petError(xhr) {
|
||||
UI.alert.text(xhr.responseText);
|
||||
UI.form.addClass("failed");
|
||||
}
|
||||
|
||||
function petSuccess(data, pet_name) {
|
||||
last_successful_pet_name = pet_name;
|
||||
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
|
||||
UI.pet_header.empty();
|
||||
$("#needed-items-pet-header-template")
|
||||
.tmpl({ pet_name: pet_name })
|
||||
.appendTo(UI.pet_header);
|
||||
loadItems(data.query);
|
||||
}
|
||||
|
||||
/* Items */
|
||||
|
||||
function loadItems(query) {
|
||||
UI.form.addClass("loading-items");
|
||||
sendRequest({
|
||||
url: "/items/needed.json",
|
||||
dataType: "json",
|
||||
data: query,
|
||||
success: itemsSuccess,
|
||||
});
|
||||
}
|
||||
|
||||
function itemsSuccess(items) {
|
||||
if (DEBUG) {
|
||||
// The dev server is missing lots of data, so sends me 2000+ needed
|
||||
// items. We don't need that many for styling, so limit it to 100 to make
|
||||
// my browser happier.
|
||||
items = items.slice(0, 100);
|
||||
}
|
||||
|
||||
UI.pet_items.empty();
|
||||
UI.item_template.tmpl(items).appendTo(UI.pet_items);
|
||||
|
||||
UI.form.removeClass("loading-items").addClass("loaded");
|
||||
}
|
||||
|
||||
UI.form.submit(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(UI.pet_name_field.val());
|
||||
});
|
||||
|
||||
UI.reload.click(function (e) {
|
||||
e.preventDefault();
|
||||
loadPet(last_successful_pet_name);
|
||||
});
|
||||
})();
|
||||
|
||||
/* Bulk pets form */
|
||||
(function () {
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
var form = $("#bulk-pets-form"),
|
||||
queue_el = form.find("ul"),
|
||||
names_el = form.find("textarea"),
|
||||
add_el = $("#bulk-pets-form-add"),
|
||||
clear_el = $("#bulk-pets-form-clear"),
|
||||
bulk_load_queue;
|
||||
|
||||
$(document.body).addClass("js");
|
||||
$(document.body).addClass("js");
|
||||
|
||||
function petThumbnailUrl(pet_name) {
|
||||
// if first character is "@", use the hash url
|
||||
if (pet_name[0] == "@") {
|
||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
||||
}
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
|
||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||
}
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
|
||||
bulk_load_queue = new (function BulkLoadQueue() {
|
||||
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
|
||||
var RECENTLY_SENT_MAX = 3;
|
||||
var pets = [],
|
||||
url = form.attr("action") + ".json",
|
||||
recently_sent_count = 0,
|
||||
loading = false;
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
|
||||
function Pet(name) {
|
||||
var el = $("#bulk-pets-submission-template")
|
||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
||||
.appendTo(queue_el);
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
|
||||
this.load = function () {
|
||||
el.removeClass("waiting").addClass("loading");
|
||||
var response_el = el.find("span.response");
|
||||
pets.shift();
|
||||
loading = true;
|
||||
$.ajax({
|
||||
beforeSend: (xhr) => {
|
||||
const token = document
|
||||
.querySelector('meta[name="csrf-token"]')
|
||||
?.getAttribute("content");
|
||||
xhr.setRequestHeader("X-CSRF-Token", token);
|
||||
},
|
||||
complete: function (data) {
|
||||
loading = false;
|
||||
loadNextIfReady();
|
||||
},
|
||||
data: { name: name },
|
||||
dataType: "json",
|
||||
error: function (xhr) {
|
||||
el.removeClass("loading").addClass("failed");
|
||||
response_el.text(xhr.responseText);
|
||||
},
|
||||
success: function (data) {
|
||||
var points = data.points;
|
||||
el.removeClass("loading").addClass("loaded");
|
||||
$("#bulk-pets-submission-success-template")
|
||||
.tmpl({ points: points })
|
||||
.appendTo(response_el);
|
||||
},
|
||||
type: "post",
|
||||
url: url,
|
||||
});
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
|
||||
recently_sent_count++;
|
||||
setTimeout(function () {
|
||||
recently_sent_count--;
|
||||
loadNextIfReady();
|
||||
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
|
||||
};
|
||||
}
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
this.add = function (name) {
|
||||
name = name.replace(/^\s+|\s+$/g, "");
|
||||
if (name.length) {
|
||||
var pet = new Pet(name);
|
||||
pets.push(pet);
|
||||
if (pets.length == 1) loadNextIfReady();
|
||||
}
|
||||
};
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
|
||||
function loadNextIfReady() {
|
||||
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
|
||||
pets[0].load();
|
||||
}
|
||||
}
|
||||
})();
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
|
||||
names_el.keyup(function () {
|
||||
var names = this.value.split("\n"),
|
||||
x = names.length - 1,
|
||||
i,
|
||||
name;
|
||||
for (i = 0; i < x; i++) {
|
||||
bulk_load_queue.add(names[i]);
|
||||
}
|
||||
this.value = x >= 0 ? names[x] : "";
|
||||
});
|
||||
|
||||
add_el.click(function () {
|
||||
bulk_load_queue.add(names_el.val());
|
||||
names_el.val("");
|
||||
});
|
||||
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
clear_el.click(function () {
|
||||
queue_el.children("li.loaded, li.failed").remove();
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -25,10 +25,6 @@ let numFramesSinceLastLog = 0;
|
|||
// State for error reporting.
|
||||
let hasLoggedRenderError = false;
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
//////// Loading the library and its assets ////////
|
||||
////////////////////////////////////////////////////
|
||||
|
||||
function loadImage(src) {
|
||||
const image = new Image();
|
||||
image.crossOrigin = "anonymous";
|
||||
|
|
@ -68,8 +64,8 @@ async function getLibrary() {
|
|||
// One more loading step as part of loading this library is loading the
|
||||
// images it uses for sprites.
|
||||
//
|
||||
// NOTE: We also read these from the manifest, and include them in the
|
||||
// document as preload meta tags, to get them moving faster.
|
||||
// TODO: I guess the manifest has these too, so we could put them in preload
|
||||
// meta tags to get them here faster?
|
||||
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
|
||||
const manifestImages = new Map(
|
||||
library.properties.manifest.map(({ id, src }) => [
|
||||
|
|
@ -100,10 +96,6 @@ async function getLibrary() {
|
|||
return library;
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
//////// Rendering the movie ////////
|
||||
/////////////////////////////////////
|
||||
|
||||
function buildMovieClip(library) {
|
||||
let constructorName;
|
||||
try {
|
||||
|
|
@ -150,54 +142,33 @@ function updateStage() {
|
|||
|
||||
function updateCanvasDimensions() {
|
||||
// Set the canvas's internal dimensions to be higher, if the device has high
|
||||
// DPI. Scale the stage to match, too.
|
||||
// DPI. Scale the movie clip to match, too.
|
||||
const internalWidth = canvas.offsetWidth * window.devicePixelRatio;
|
||||
const internalHeight = canvas.offsetHeight * window.devicePixelRatio;
|
||||
canvas.width = internalWidth;
|
||||
canvas.height = internalHeight;
|
||||
stage.scaleX = internalWidth / library.properties.width;
|
||||
stage.scaleY = internalHeight / library.properties.height;
|
||||
movieClip.scaleX = internalWidth / library.properties.width;
|
||||
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() {
|
||||
// Install the MotionGuidePlugin, which is needed for motion path animations.
|
||||
createjs.MotionGuidePlugin.install();
|
||||
|
||||
// Load the movie's library (from the JS file already run), and use it to
|
||||
// build a movie clip.
|
||||
library = await getLibrary();
|
||||
movieClip = buildMovieClip(library);
|
||||
|
||||
updateCanvasDimensions();
|
||||
|
||||
if (canvas.getContext("2d") == null) {
|
||||
console.warn(`Out of memory, can't use canvas for ${libraryUrl}.`);
|
||||
// TODO: "Too many animations!"
|
||||
return;
|
||||
}
|
||||
|
||||
stage = new library.Stage(canvas);
|
||||
stage = new window.createjs.Stage(canvas);
|
||||
stage.addChild(movieClip);
|
||||
updateCanvasDimensions();
|
||||
updateStage();
|
||||
|
||||
// Signal to the library that the composition is ready.
|
||||
AdobeAn.compositionLoaded(library.properties.id);
|
||||
|
||||
loadingStatus = "loaded";
|
||||
canvas.setAttribute("data-status", "loaded");
|
||||
|
||||
|
|
@ -303,10 +274,6 @@ function getInitialPlayingStatus() {
|
|||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////
|
||||
//// Syncing with the parent document ////
|
||||
//////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* Recursively scans the given MovieClip (or child createjs node), to see if
|
||||
* there are any animated areas.
|
||||
|
|
@ -345,6 +312,18 @@ function sendMessage(message) {
|
|||
parent.postMessage(message, document.location.origin);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
updateCanvasDimensions();
|
||||
|
||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
||||
// to `false`, so that we don't advance by a frame. This keeps us
|
||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
||||
// we're playing.
|
||||
stage.tickOnUpdate = false;
|
||||
updateStage();
|
||||
stage.tickOnUpdate = true;
|
||||
});
|
||||
|
||||
window.addEventListener("message", ({ data }) => {
|
||||
// NOTE: For more sensitive messages, it's important for security to also
|
||||
// check the `origin` property of the incoming event. But in this case, I'm
|
||||
|
|
@ -360,10 +339,6 @@ window.addEventListener("message", ({ data }) => {
|
|||
}
|
||||
});
|
||||
|
||||
/////////////////////////////////
|
||||
//// The actual entry point! ////
|
||||
/////////////////////////////////
|
||||
|
||||
startMovie()
|
||||
.then(() => {
|
||||
sendStatus();
|
||||
|
|
|
|||
30
app/assets/stylesheets/_items.sass
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
@import "partials/campaign-progress"
|
||||
|
||||
body.items-index, body.items-show, body.items-needed, body.item_trades
|
||||
+campaign-progress
|
||||
|
||||
text-align: center
|
||||
|
||||
.item-search-form
|
||||
display: flex
|
||||
gap: .5em
|
||||
justify-content: center
|
||||
|
||||
input[type=text]
|
||||
font-size: 125%
|
||||
width: 15em
|
||||
flex: 0 1 auto
|
||||
|
||||
h1
|
||||
margin-bottom: 1em
|
||||
img
|
||||
height: 80px
|
||||
margin-bottom: -0.5em
|
||||
width: 80px
|
||||
a
|
||||
text-decoration: none
|
||||
span
|
||||
text-decoration: underline
|
||||
&:hover span
|
||||
text-decoration: none
|
||||
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
@import "partials/icon"
|
||||
@import "partials/clean/constants"
|
||||
@import "partials/clean/mixins"
|
||||
@import fonts
|
||||
@import url("https://fonts.googleapis.com/css?family=Droid+Sans:400,700")
|
||||
@import url("https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic")
|
||||
@import url("https://fonts.googleapis.com/css?family=Calligraffitti")
|
||||
|
||||
/* Reset
|
||||
|
||||
|
|
@ -32,6 +36,9 @@ body
|
|||
a[href]
|
||||
color: $link-color
|
||||
|
||||
p
|
||||
font-family: $text-font
|
||||
|
||||
input, button, select
|
||||
font:
|
||||
family: inherit
|
||||
|
|
@ -74,7 +81,7 @@ $container_width: 800px
|
|||
input, button, select, label
|
||||
cursor: pointer
|
||||
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
|
||||
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
|
||||
border-radius: 3px
|
||||
background: #fff
|
||||
border: 1px solid $input-border-color
|
||||
|
|
@ -83,15 +90,6 @@ input[type=text], input[type=password], input[type=search], input[type=number],
|
|||
&:focus, &:active
|
||||
color: inherit
|
||||
|
||||
select:has(option[value='']:checked)
|
||||
color: #666
|
||||
|
||||
option[value='']
|
||||
color: #666
|
||||
|
||||
option:not([value=''])
|
||||
color: $text-color
|
||||
|
||||
textarea
|
||||
font: inherit
|
||||
|
||||
|
|
@ -252,3 +250,23 @@ dd
|
|||
margin: 0 .5em
|
||||
.current
|
||||
font-weight: bold
|
||||
|
||||
/* Fonts
|
||||
|
||||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
src: local("Delicious"), font-url("Delicious-Roman.otf")
|
||||
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-weight: bold
|
||||
src: local("Delicious"), font-url("Delicious-Bold.otf")
|
||||
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-style: italic
|
||||
src: local("Delicious"), font-url("Delicious-Italic.otf")
|
||||
|
||||
|
|
|
|||
|
|
@ -3,20 +3,10 @@ body.use-responsive-design
|
|||
max-width: 100%
|
||||
padding-inline: 1rem
|
||||
box-sizing: border-box
|
||||
padding-top: 0
|
||||
|
||||
#main-nav
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
|
||||
#home-link, #userbar
|
||||
position: static
|
||||
|
||||
#home-link
|
||||
padding-inline: .5rem
|
||||
margin-inline: -.5rem
|
||||
margin-right: auto
|
||||
margin-left: 1rem
|
||||
padding-inline: 0
|
||||
|
||||
#userbar
|
||||
margin-left: auto
|
||||
text-align: right
|
||||
margin-right: 1rem
|
||||
|
|
|
|||
18
app/assets/stylesheets/alt_styles/_index.sass
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
body.alt_styles-index
|
||||
.alt-styles-header
|
||||
margin-top: 1em
|
||||
margin-bottom: .5em
|
||||
|
||||
.alt-styles-list
|
||||
list-style: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: 1.5em
|
||||
|
||||
.alt-style
|
||||
text-align: center
|
||||
width: 80px
|
||||
|
||||
.alt-style-thumbnail
|
||||
width: 80px
|
||||
height: 80px
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
// Prefer to break the name at visually appealing points.
|
||||
.rainbow-pool-list
|
||||
.name
|
||||
text-wrap: balance
|
||||
|
||||
// De-emphasize Prismatic styles, in browsers that support it.
|
||||
.rainbow-pool-filters
|
||||
select[name="series"]
|
||||
option[value*=": "]
|
||||
color: $soft-text-color
|
||||
font-style: italic
|
||||
|
|
@ -8,10 +8,16 @@
|
|||
|
||||
@import partials/jquery.jgrowl
|
||||
|
||||
@import alt_styles/index
|
||||
@import closet_hangers/index
|
||||
@import closet_hangers/petpage
|
||||
@import closet_lists/form
|
||||
@import neopets_page_import_tasks/new
|
||||
@import contributions/index
|
||||
@import items
|
||||
@import items/index
|
||||
@import items/show
|
||||
@import item_trades/index
|
||||
@import outfits/index
|
||||
@import outfits/new
|
||||
@import pets/bulk
|
||||
|
|
|
|||
|
|
@ -1,23 +0,0 @@
|
|||
#title:has(+ .breadcrumbs)
|
||||
margin-bottom: .125em
|
||||
|
||||
.breadcrumbs
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: row
|
||||
margin-block: .5em
|
||||
font-size: .85em
|
||||
|
||||
li
|
||||
display: flex
|
||||
|
||||
li:not(:first-child)
|
||||
&::before
|
||||
margin-inline: .35em
|
||||
content: "→"
|
||||
|
||||
&[data-relation-to-prev=sibling]::before
|
||||
content: "+"
|
||||
|
||||
&[data-relation-to-prev=menu]::before
|
||||
content: "-"
|
||||
|
|
@ -1,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
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We only apply
|
||||
// the delay here, not on the base styles, because fading *out* on load should
|
||||
// be instant.
|
||||
//
|
||||
// This is implemented as a mixin, so that the item page can leverage the same
|
||||
// loading state when loading a new preview altogether. Once CSS container
|
||||
// style queries gain wider support, maybe use that instead.
|
||||
=outfit-viewer-loading
|
||||
cursor: wait
|
||||
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
|
||||
// If the outfit *starts* in loading state, still delay the fade-in.
|
||||
@starting-style
|
||||
opacity: 0
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
// These are default widths, expected to often be overridden.
|
||||
width: 300px
|
||||
height: 300px
|
||||
|
||||
// There's no useful text in here, but double-clicking the play/pause
|
||||
// button can cause a weird selection state. Disable text selection.
|
||||
user-select: none
|
||||
-webkit-user-select: none
|
||||
|
||||
outfit-layer
|
||||
display: block
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
// We disable pointer-events most importantly for the iframes, which
|
||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
||||
// inside of its own document. But also, the context menus for these
|
||||
// elements are kinda actively misleading, too!
|
||||
pointer-events: none
|
||||
|
||||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
bottom: 0px
|
||||
right: 4px
|
||||
padding: 8px
|
||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
||||
|
||||
opacity: 0
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: none
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
.play-pause-toggle
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
&:has(outfit-layer:state(loading))
|
||||
+outfit-viewer-loading
|
||||
|
||||
// If a layer has the `[highlighted]` attribute, it's brought to the front,
|
||||
// and other layers are grayed out and blurred. We use this in the support
|
||||
// outfit viewer, when you hover over a layer.
|
||||
&:has(outfit-layer[highlighted])
|
||||
outfit-layer[highlighted]
|
||||
z-index: 999
|
||||
|
||||
// Filter everything behind the bottom-most highlighted layer, using a
|
||||
// backdrop filter. This gives us the best visual consistency by applying
|
||||
// effects to the entire backdrop, instead of each layer and then
|
||||
// re-compositing them.
|
||||
backdrop-filter: grayscale(1) brightness(2) blur(1px)
|
||||
& ~ outfit-layer[highlighted]
|
||||
backdrop-filter: none
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-filters
|
||||
margin-block: .5em
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
legend
|
||||
display: contents
|
||||
font-weight: bold
|
||||
|
||||
select
|
||||
width: 16ch
|
||||
|
||||
.rainbow-pool-list
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
--preview-base-width: 150px
|
||||
|
||||
> li
|
||||
width: var(--preview-base-width)
|
||||
max-width: calc(50% - .25em)
|
||||
min-width: 150px
|
||||
box-sizing: border-box
|
||||
text-align: center
|
||||
|
||||
a
|
||||
display: block
|
||||
border-radius: 1em
|
||||
padding: .5em
|
||||
text-decoration: none
|
||||
background: white
|
||||
&:hover
|
||||
outline: 1px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
|
||||
.preview
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 1 / 1
|
||||
margin-bottom: -1em
|
||||
|
||||
.name
|
||||
background: inherit
|
||||
padding: .25em .5em
|
||||
border-radius: .5em
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.info
|
||||
font-size: .85em
|
||||
p
|
||||
margin-block: .25em
|
||||
|
||||
.rainbow-pool-pagination
|
||||
margin-block: .5em
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
|
||||
.rainbow-pool-no-results
|
||||
margin-block: 1em
|
||||
text-align: center
|
||||
font-style: italic
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.support-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1em
|
||||
align-items: flex-start
|
||||
|
||||
.fields
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
width: 100%
|
||||
|
||||
> li
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
max-width: 60ch
|
||||
|
||||
> label, > .field_with_errors label
|
||||
display: block
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
> label
|
||||
color: $error-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
&[data-type=radio]
|
||||
ul
|
||||
list-style-type: none
|
||||
|
||||
&[data-type=radio-grid] // Set the `--num-columns` property to configure!
|
||||
max-width: none
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: grid
|
||||
grid-template-columns: repeat(var(--num-columns, 1), 1fr)
|
||||
gap: .25em
|
||||
|
||||
li
|
||||
display: flex
|
||||
align-items: stretch // Give the bubbles equal heights!
|
||||
|
||||
label
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding: .5em 1em
|
||||
border: 1px solid $soft-border-color
|
||||
border-radius: 1em
|
||||
flex: 1 1 auto
|
||||
|
||||
input
|
||||
margin: 0
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-color: $module-border-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
width: 100%
|
||||
min-width: 10ch
|
||||
box-sizing: border-box
|
||||
|
||||
.thumbnail-input
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
|
||||
img
|
||||
width: 40px
|
||||
height: 40px
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
|
||||
legend
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
display: contents
|
||||
|
||||
.actions
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1em
|
||||
|
||||
.go-to-next
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
font-size: .85em
|
||||
font-style: italic
|
||||
|
|
@ -31,14 +31,11 @@ body.closet_hangers-index
|
|||
color: $soft-text-color
|
||||
margin-bottom: 1em
|
||||
margin-left: 2em
|
||||
min-height: $icon-height
|
||||
|
||||
display: flex
|
||||
gap: .5em
|
||||
align-items: center
|
||||
min-height: image-height("neomail.png")
|
||||
|
||||
a
|
||||
color: inherit
|
||||
margin-right: .5em
|
||||
text-decoration: none
|
||||
&:hover
|
||||
text-decoration: underline
|
||||
|
|
@ -47,14 +44,13 @@ body.closet_hangers-index
|
|||
background:
|
||||
position: left center
|
||||
repeat: no-repeat
|
||||
padding-left: image-width("neomail.png") + 4px
|
||||
|
||||
a.neomail, > form
|
||||
background-image: image-url("neomail.png")
|
||||
padding-left: $icon-width + 4px
|
||||
|
||||
a.lookup
|
||||
background-image: image-url("lookup.png")
|
||||
padding-left: $icon-width + 4px
|
||||
|
||||
select
|
||||
width: 10em
|
||||
|
|
|
|||
58
app/assets/stylesheets/closet_hangers/_petpage.sass
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
body.closet_hangers-petpage
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&.checked
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
font-weight: bold;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Bold.otf" %>");
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Delicious;
|
||||
font-style: italic;
|
||||
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
|
||||
}
|
||||
14
app/assets/stylesheets/fonts.css.sass
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
src: local("Delicious"), font-url("Delicious-Roman.otf")
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-weight: bold
|
||||
src: local("Delicious"), font-url("Delicious-Bold.otf")
|
||||
|
||||
@font-face
|
||||
font-family: Delicious
|
||||
font-style: italic
|
||||
src: local("Delicious"), font-url("Delicious-Italic.otf")
|
||||
29
app/assets/stylesheets/item_trades/_index.sass
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
@import "../partials/item_header"
|
||||
|
||||
body.item_trades-index
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
.item-subpage-title
|
||||
text-align: left
|
||||
margin-bottom: .5em
|
||||
|
||||
.trades-table
|
||||
text-align: left
|
||||
width: 100%
|
||||
table-layout: fixed
|
||||
|
||||
th, td
|
||||
&:nth-child(1), &:nth-child(2)
|
||||
width: 15ch
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
.trade-list-names
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&:not(:last-child)::after
|
||||
content: ", "
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
@import "../partials/item_header"
|
||||
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
.item-subpage-title
|
||||
text-align: left
|
||||
margin-bottom: .5em
|
||||
|
||||
.trades-table
|
||||
text-align: left
|
||||
width: 100%
|
||||
table-layout: fixed
|
||||
|
||||
th, td
|
||||
&:nth-child(1), &:nth-child(2)
|
||||
width: 15ch
|
||||
overflow: hidden
|
||||
text-overflow: ellipsis
|
||||
|
||||
td[data-is-same-as-prev]
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
.trade-list-names
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&:not(:last-child)::after
|
||||
content: ", "
|
||||
25
app/assets/stylesheets/items/_index.sass
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
=main_unit
|
||||
float: left
|
||||
width: 49%
|
||||
h2
|
||||
font-size: 125%
|
||||
|
||||
body.items-index
|
||||
form
|
||||
margin-bottom: 2em
|
||||
|
||||
#search-info
|
||||
+main_unit
|
||||
padding-right: 1%
|
||||
dl
|
||||
text-align: left
|
||||
dd
|
||||
margin-bottom: 1em
|
||||
|
||||
#species-search-links
|
||||
+main_unit
|
||||
padding-left: 1%
|
||||
img
|
||||
height: 80px
|
||||
width: 80px
|
||||
|
||||
350
app/assets/stylesheets/items/_show.sass
Normal file
|
|
@ -0,0 +1,350 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
body.items-show
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
#item-contributors
|
||||
+subtle-banner
|
||||
clear: both
|
||||
margin:
|
||||
bottom: 0
|
||||
top: 2em
|
||||
|
||||
header
|
||||
display: inline
|
||||
font-weight: bold
|
||||
margin-right: .25em
|
||||
|
||||
footer
|
||||
display: inline
|
||||
|
||||
ul
|
||||
display: inline
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&::after
|
||||
content: ", "
|
||||
|
||||
&:last-child::after
|
||||
content: "."
|
||||
|
||||
.nc-icon
|
||||
height: 16px
|
||||
width: 16px
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
overflow: hidden
|
||||
margin: 0 auto
|
||||
|
||||
// There's no useful text in here, but double-clicking the play/pause
|
||||
// button can cause a weird selection state. Disable text selection.
|
||||
user-select: none
|
||||
-webkit-user-select: none
|
||||
|
||||
outfit-layer
|
||||
display: block
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
// We disable pointer-events most importantly for the iframes, which
|
||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
||||
// inside of its own document. But also, the context menus for these
|
||||
// elements are kinda actively misleading, too!
|
||||
pointer-events: none
|
||||
|
||||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
bottom: 0px
|
||||
right: 4px
|
||||
padding: 8px
|
||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
||||
|
||||
opacity: 0
|
||||
transition: opacity .5s
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: none
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
.play-pause-toggle
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
.error-indicator
|
||||
font-size: 85%
|
||||
color: $error-color
|
||||
margin-top: .25em
|
||||
margin-bottom: .5em
|
||||
display: none
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. (We only
|
||||
// apply the delay here, because fading *out* on load should be instant.)
|
||||
// We are loading when the <turbo-frame> is busy, or when at least one layer
|
||||
// is loading.
|
||||
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
|
||||
cursor: wait
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
border: 2px solid red
|
||||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
||||
form[data-is-valid="false"]
|
||||
select
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
// If JS is enabled, but auto-loading isn't ready yet (script loading or
|
||||
// failed?), hide the submit button for .75sec, to give it time to load.
|
||||
@media (scripting: enabled)
|
||||
input[type=submit]
|
||||
position: absolute
|
||||
margin-left: .5em
|
||||
opacity: 0
|
||||
animation: fade-in .25s forwards
|
||||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
species-face-picker
|
||||
display: block
|
||||
position: relative
|
||||
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
|
||||
padding: 10px // leave enough room for the zoomed-in selected face
|
||||
margin-top: -10px
|
||||
overflow: auto
|
||||
|
||||
species-face-picker-options
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
|
||||
img
|
||||
width: 50px
|
||||
height: 50px
|
||||
transition: all 0.2s
|
||||
|
||||
// Calm down the default color, just a smidge! There's a lot of color
|
||||
// on this page already, y'know?
|
||||
opacity: .9
|
||||
filter: saturate(90%)
|
||||
|
||||
label
|
||||
display: flex
|
||||
overflow: hidden
|
||||
transition: all 0.2s
|
||||
position: relative
|
||||
line-height: 1
|
||||
|
||||
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
|
||||
// Chakra UI's styling system to generate them! (The colors are from their
|
||||
// color palette, too.)
|
||||
&:has(input:checked)
|
||||
border-radius: 6px
|
||||
z-index: 1
|
||||
background: #9AE6B4
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
|
||||
transform: scale(1.1)
|
||||
|
||||
&:has(input:focus)
|
||||
background: #BEE3F8
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
|
||||
transform: scale(1.2)
|
||||
|
||||
input[type=radio]
|
||||
position: absolute
|
||||
left: -10000px
|
||||
top: auto
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
&:checked + img
|
||||
opacity: 1
|
||||
filter: saturate(110%)
|
||||
|
||||
&:disabled + img
|
||||
opacity: .6
|
||||
filter: saturate(0%)
|
||||
|
||||
label:has(input[type=radio]:disabled)
|
||||
cursor: not-allowed
|
||||
|
||||
noscript
|
||||
position: absolute
|
||||
inset: 0
|
||||
background: rgba(white, .75)
|
||||
z-index: 1
|
||||
cursor: auto
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
text-align: center
|
||||
|
||||
&:has(species-face-picker-options[inert])
|
||||
cursor: wait
|
||||
|
||||
.item-preview-meta-info
|
||||
display: grid
|
||||
grid-template-columns: 1fr auto
|
||||
gap: .5em
|
||||
align-items: center
|
||||
|
||||
.item-zones-info
|
||||
h3
|
||||
display: inline
|
||||
font: inherit
|
||||
font-weight: bold
|
||||
&:after
|
||||
content: ": "
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: inline
|
||||
|
||||
li
|
||||
display: inline
|
||||
&:not(:last-of-type):after
|
||||
content: ", "
|
||||
|
||||
.no-zones
|
||||
font-style: italic
|
||||
opacity: .85
|
||||
|
||||
.zone-species-info
|
||||
font-style: italic
|
||||
text-decoration: underline dotted
|
||||
|
||||
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
|
||||
.item-html5-info
|
||||
display: flex
|
||||
align-items: center
|
||||
border: 1px solid
|
||||
border-radius: .375em
|
||||
padding: 4px 8px
|
||||
min-height: 30px
|
||||
box-sizing: border-box
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
|
||||
|
||||
&[data-status=converted]
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
svg:nth-of-type(2)
|
||||
margin-right: -4px // spacing hacks!
|
||||
|
||||
&[data-status=unconverted]
|
||||
background: $warning-bg-color
|
||||
color: #975A16
|
||||
gap: .25em // spacing hacks!
|
||||
|
||||
svg:first-of-type
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
svg:nth-of-type(2)
|
||||
width: 20px
|
||||
height: 20px
|
||||
|
||||
#item-preview
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
|
||||
@media (min-width: 600px)
|
||||
display: grid
|
||||
grid-template-areas: "viewer faces" "picker meta"
|
||||
gap: .5em
|
||||
|
||||
outfit-viewer
|
||||
grid-area: viewer
|
||||
width: 350px
|
||||
height: 350px
|
||||
|
||||
species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
grid-area: faces
|
||||
max-height: 350px
|
||||
margin: -10px
|
||||
|
||||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
=main_unit
|
||||
float: left
|
||||
width: 49%
|
||||
h2
|
||||
font-size: 125%
|
||||
|
||||
form
|
||||
margin-bottom: 2em
|
||||
|
||||
#search-info
|
||||
+main_unit
|
||||
padding-right: 1%
|
||||
dl
|
||||
text-align: left
|
||||
dd
|
||||
margin-bottom: 1em
|
||||
|
||||
#species-search-links
|
||||
+main_unit
|
||||
padding-left: 1%
|
||||
img
|
||||
height: 80px
|
||||
width: 80px
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
@import "../application/outfit-viewer"
|
||||
|
||||
#container
|
||||
width: 900px // A bit more generous to the preview area!
|
||||
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
#item-contributors
|
||||
+subtle-banner
|
||||
clear: both
|
||||
margin:
|
||||
bottom: 0
|
||||
top: 2em
|
||||
|
||||
header
|
||||
display: inline
|
||||
font-weight: bold
|
||||
margin-right: .25em
|
||||
|
||||
footer
|
||||
display: inline
|
||||
|
||||
ul
|
||||
display: inline
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&::after
|
||||
content: ", "
|
||||
|
||||
&:last-child::after
|
||||
content: "."
|
||||
|
||||
.nc-icon
|
||||
height: 16px
|
||||
width: 16px
|
||||
|
||||
.preview-area
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
|
||||
.customize-more
|
||||
position: absolute
|
||||
top: 1em
|
||||
right: 1em
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
text-decoration: none
|
||||
|
||||
background: #EDF2F7
|
||||
padding-inline: .75em
|
||||
border-radius: .375em
|
||||
min-height: 2rem
|
||||
min-width: 2rem
|
||||
box-sizing: border-box
|
||||
|
||||
.customize-more-label
|
||||
width: 0
|
||||
overflow: hidden
|
||||
transition: width .25s
|
||||
white-space: nowrap
|
||||
--natural-width: auto
|
||||
|
||||
measured-content
|
||||
padding-right: .5em
|
||||
|
||||
&:hover, &:focus
|
||||
// Expand the label to its natural width. If the JS ran to tell us
|
||||
// what it is in px, we can use that for a smooth transition. If not,
|
||||
// okay, we just pop out to `auto`, which CSS can't make smooth.
|
||||
.customize-more-label
|
||||
width: var(--natural-width)
|
||||
|
||||
outfit-viewer
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
|
||||
.error-indicator
|
||||
font-size: 85%
|
||||
color: $error-color
|
||||
margin-top: .25em
|
||||
margin-bottom: .5em
|
||||
display: none
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We are
|
||||
// loading when the <turbo-frame> is busy, or when at least one layer
|
||||
// is loading.
|
||||
//
|
||||
// We only apply the delay here, not on the base styles, because fading
|
||||
// *out* on load should be instant.
|
||||
#item-preview[busy] outfit-viewer
|
||||
+outfit-viewer-loading
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
border: 2px solid red
|
||||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
||||
form[data-is-valid="false"]
|
||||
select
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
// If JS is enabled, but auto-loading isn't ready yet (script loading or
|
||||
// failed?), hide the submit button for .75sec, to give it time to load.
|
||||
@media (scripting: enabled)
|
||||
input[type=submit]
|
||||
position: absolute
|
||||
margin-left: .5em
|
||||
opacity: 0
|
||||
animation: fade-in .25s forwards
|
||||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
species-face-picker
|
||||
display: block
|
||||
position: relative
|
||||
margin-top: -10px
|
||||
|
||||
species-face-picker-options
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
isolation: isolate // avoid z-index conflicts between pets and noscript
|
||||
overflow: auto
|
||||
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
|
||||
padding: 10px // leave enough room for the zoomed-in selected face
|
||||
|
||||
img
|
||||
width: 54px
|
||||
height: 54px
|
||||
transition: all 0.2s
|
||||
|
||||
// Calm down the default color, just a smidge! There's a lot of color
|
||||
// on this page already, y'know?
|
||||
opacity: .9
|
||||
filter: saturate(90%)
|
||||
|
||||
label
|
||||
display: flex
|
||||
overflow: hidden
|
||||
transition: all 0.2s
|
||||
position: relative
|
||||
line-height: 1
|
||||
|
||||
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
|
||||
// Chakra UI's styling system to generate them! (The colors are from their
|
||||
// color palette, too.)
|
||||
&:has(input:checked)
|
||||
border-radius: 6px
|
||||
z-index: 1
|
||||
background: #9AE6B4
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
|
||||
transform: scale(1.1)
|
||||
|
||||
&:has(input:focus)
|
||||
background: #BEE3F8
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
|
||||
transform: scale(1.2)
|
||||
|
||||
input[type=radio]
|
||||
position: absolute
|
||||
left: -10000px
|
||||
top: auto
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
&:checked + img
|
||||
opacity: 1
|
||||
filter: saturate(110%)
|
||||
|
||||
&:disabled + img
|
||||
opacity: .6
|
||||
filter: saturate(0%)
|
||||
|
||||
label:has(input[type=radio]:disabled)
|
||||
cursor: not-allowed
|
||||
|
||||
noscript
|
||||
position: absolute
|
||||
inset: 0
|
||||
padding: 1em
|
||||
background: rgba(white, .8)
|
||||
z-index: 1
|
||||
cursor: auto
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
text-align: center
|
||||
|
||||
&:has(species-face-picker-options[inert])
|
||||
cursor: wait
|
||||
|
||||
.item-preview-meta-info
|
||||
display: grid
|
||||
grid-template-columns: 1fr auto
|
||||
gap: .5em
|
||||
align-items: center
|
||||
|
||||
.item-zones-info
|
||||
h3
|
||||
display: inline
|
||||
font: inherit
|
||||
font-weight: bold
|
||||
&:after
|
||||
content: ": "
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: inline
|
||||
|
||||
li
|
||||
display: inline
|
||||
&:not(:last-of-type):after
|
||||
content: ", "
|
||||
|
||||
.no-zones
|
||||
font-style: italic
|
||||
opacity: .85
|
||||
|
||||
.zone-species-info
|
||||
font-style: italic
|
||||
text-decoration: underline dotted
|
||||
|
||||
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
|
||||
.item-html5-info
|
||||
display: flex
|
||||
align-items: center
|
||||
border: 1px solid
|
||||
border-radius: .375em
|
||||
padding: 4px 8px
|
||||
min-height: 30px
|
||||
box-sizing: border-box
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
|
||||
|
||||
&[data-status=converted]
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
svg:nth-of-type(2)
|
||||
margin-right: -4px // spacing hacks!
|
||||
|
||||
&[data-status=unconverted]
|
||||
background: $warning-bg-color
|
||||
color: #975A16
|
||||
gap: .25em // spacing hacks!
|
||||
|
||||
svg:first-of-type
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
svg:nth-of-type(2)
|
||||
width: 20px
|
||||
height: 20px
|
||||
|
||||
#item-preview
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
|
||||
@media (min-width: 700px)
|
||||
display: grid
|
||||
grid-template-areas: "viewer faces" "picker meta"
|
||||
gap: .5em
|
||||
|
||||
.preview-area
|
||||
grid-area: viewer
|
||||
outfit-viewer
|
||||
width: 380px
|
||||
height: 380px
|
||||
|
||||
species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
grid-area: faces
|
||||
species-face-picker-options
|
||||
max-height: 380px
|
||||
|
||||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
||||
|
|
@ -107,10 +107,10 @@
|
|||
a
|
||||
color: inherit
|
||||
|
||||
.nc-trade-guide-info-link
|
||||
.owls-info-link
|
||||
cursor: help
|
||||
|
||||
.nc-trade-guide-info-label
|
||||
.owls-info-label
|
||||
text-decoration-line: underline
|
||||
text-decoration-style: dotted
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
@import "partials/campaign-progress"
|
||||
|
||||
body
|
||||
+campaign-progress
|
||||
text-align: center
|
||||
|
||||
.item-search-form
|
||||
display: flex
|
||||
gap: .5em
|
||||
justify-content: center
|
||||
|
||||
input[type=text]
|
||||
font-size: 125%
|
||||
width: 15em
|
||||
flex: 0 1 auto
|
||||
|
||||
h1
|
||||
margin-bottom: 1em
|
||||
img
|
||||
height: 80px
|
||||
margin-bottom: -0.5em
|
||||
width: 80px
|
||||
a
|
||||
text-decoration: none
|
||||
span
|
||||
text-decoration: underline
|
||||
&:hover span
|
||||
text-decoration: none
|
||||
|
|
@ -7,8 +7,9 @@ body.outfits-new
|
|||
#pet-not-found
|
||||
display: none
|
||||
|
||||
.announcement
|
||||
border: 1px solid $module-border-color
|
||||
.neopass-announcement
|
||||
border: 1px solid #cd8400
|
||||
color: #764a00
|
||||
padding: .5em
|
||||
display: grid
|
||||
grid-template-areas: "thumbnail content"
|
||||
|
|
@ -23,6 +24,9 @@ body.outfits-new
|
|||
p:last-of-type
|
||||
margin-bottom: 0
|
||||
|
||||
a
|
||||
color: #be7a00
|
||||
|
||||
#outfit-forms
|
||||
+clearfix
|
||||
+module
|
||||
|
|
@ -78,57 +82,85 @@ body.outfits-new
|
|||
font-size: 175%
|
||||
select
|
||||
font-size: 120%
|
||||
#description, #top-contributors
|
||||
float: left
|
||||
#description
|
||||
margin-right: 2%
|
||||
width: 64%
|
||||
#top-contributors
|
||||
border: 1px solid $input-border-color
|
||||
margin-top: 1em
|
||||
padding: 1%
|
||||
width: 30%
|
||||
ol
|
||||
margin-left: 2em
|
||||
padding-left: 1em
|
||||
> a
|
||||
font-size: 80%
|
||||
display: block
|
||||
text-align: right
|
||||
#how-can-i-help, #i-found-something
|
||||
+module
|
||||
float: left
|
||||
padding: 1%
|
||||
width: 46%
|
||||
h2
|
||||
font-style: italic
|
||||
input, button
|
||||
font-size: 115%
|
||||
input[type=text]
|
||||
border-color: $module-border-color
|
||||
width: 12em
|
||||
#how-can-i-help
|
||||
margin-right: 1%
|
||||
#i-found-something
|
||||
margin-left: 1%
|
||||
a
|
||||
float: right
|
||||
font-size: 87.5%
|
||||
margin-top: 1em
|
||||
$section-count: 3
|
||||
$section-border-width: 1px
|
||||
$section-padding: 0.5em
|
||||
$section-width: 100% / $section-count
|
||||
// (A - (B-1)*C) / B
|
||||
#sections
|
||||
display: grid
|
||||
grid-template-columns: 1fr 1fr 1fr
|
||||
+clearfix
|
||||
display: table
|
||||
list-style: none
|
||||
margin-top: 1em
|
||||
li
|
||||
display: grid
|
||||
grid-template-areas: "header image" "info image" "form form"
|
||||
grid-template-rows: auto auto auto
|
||||
row-gap: .5em
|
||||
padding: 0.5em
|
||||
&:not(:first-child)
|
||||
border-left: 1px solid $module-border-color
|
||||
h3
|
||||
grid-area: header
|
||||
margin-bottom: 0
|
||||
margin-bottom: .25em
|
||||
li
|
||||
border-left:
|
||||
color: $module-border-color
|
||||
style: solid
|
||||
width: $section-border-width
|
||||
display: table-cell
|
||||
padding: $section-padding
|
||||
position: relative
|
||||
width: $section-width
|
||||
&:first-child
|
||||
border-left: 0
|
||||
div
|
||||
grid-area: info
|
||||
color: $soft-text-color
|
||||
font-size: 75%
|
||||
margin-left: 1em
|
||||
z-index: 2
|
||||
strong
|
||||
h4, input
|
||||
font-size: 116%
|
||||
a:has(img)
|
||||
grid-area: image
|
||||
h4, input[type=text]
|
||||
color: inherit
|
||||
h4 a
|
||||
background: #ffffc0
|
||||
img
|
||||
opacity: 0.75
|
||||
+opacity(0.75)
|
||||
float: right
|
||||
margin-left: .5em
|
||||
&:hover
|
||||
opacity: 1
|
||||
+opacity(1)
|
||||
p
|
||||
line-height: 1.5
|
||||
min-height: 4.5em
|
||||
margin-bottom: 0
|
||||
form
|
||||
grid-area: form
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
font-size: .85em
|
||||
margin-left: 1em
|
||||
margin-right: .5em
|
||||
|
||||
input[type=text], input[type=search]
|
||||
// TODO: It doesn't make sense to me that this is the right style? I
|
||||
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
|
||||
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
|
||||
// which I would have *thought* is the same as this, but isn't! Idk!
|
||||
width: 100%
|
||||
|
||||
#whats-new
|
||||
margin-bottom: 1em
|
||||
|
|
@ -297,3 +329,4 @@ body.outfits-new
|
|||
#latest-contribution-created-at
|
||||
color: $soft-text-color
|
||||
margin-left: .5em
|
||||
|
||||
|
|
|
|||
29
app/assets/stylesheets/partials/_blue.sass
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Used internally:
|
||||
|
||||
$background_color: #0b61a4
|
||||
$module_border_color: #033e6b
|
||||
$module_background_color: #66a3d2
|
||||
|
||||
$input_hover_border_color: #ff9200
|
||||
$input_focus_border_color: #fff
|
||||
|
||||
$loud_button_background_color: #ff9200
|
||||
$loud_button_border_color: #ffad40
|
||||
$loud_button_color: #a65f00
|
||||
$loud_button_focus_border_color: #000
|
||||
|
||||
// Used by Blueprint:
|
||||
|
||||
$font_color: #fff
|
||||
|
||||
$header_color: inherit
|
||||
|
||||
$link_color: inherit
|
||||
$link_hover_color: inherit
|
||||
$link_focus_color: inherit
|
||||
$link_active_color: inherit
|
||||
$link_visited_color: inherit
|
||||
|
||||
$error_color: inherit
|
||||
$error_bg_color: #e14f1c
|
||||
$error_border_color: #cd0a0a
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
@import "clean/mixins"
|
||||
|
||||
=context-button
|
||||
+awesome-button
|
||||
+awesome-button-color(#aaaaaa)
|
||||
+opacity(0.9)
|
||||
font-size: 80%
|
||||
|
||||
|
|
|
|||
|
|
@ -67,21 +67,14 @@
|
|||
background: #FEEBC8
|
||||
color: #7B341E
|
||||
|
||||
.support-form
|
||||
grid-area: support
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
.user-lists-info
|
||||
grid-area: lists
|
||||
font-size: 85%
|
||||
text-align: left
|
||||
|
||||
display: flex
|
||||
gap: 1em
|
||||
|
||||
a::after
|
||||
content: " ›"
|
||||
.user-lists-form-opener
|
||||
&::after
|
||||
content: " ›"
|
||||
|
||||
.user-lists-form
|
||||
background: $background-color
|
||||
|
|
|
|||
|
|
@ -18,8 +18,9 @@ $error-color: #8a1f11
|
|||
$error-bg-color: #fbe3e4
|
||||
$error-border-color: #fbc2c4
|
||||
|
||||
$header-font: Delicious, system-ui, sans-serif
|
||||
$main-font: system-ui, sans-serif
|
||||
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
|
||||
$main-font: "Droid Sans", Helvetica, Arial, Verdana, sans-serif
|
||||
$text-font: "Droid Serif", Georgia, "Times New Roman", Times, serif
|
||||
|
||||
$object-img-size: 80px
|
||||
$object-width: 100px
|
||||
|
|
|
|||
|
|
@ -1,15 +0,0 @@
|
|||
support-outfit-viewer
|
||||
margin-block: 1em
|
||||
|
||||
.fields li[data-type=radio-grid]
|
||||
--num-columns: 3
|
||||
|
||||
.reference-link
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding-inline: .5em
|
||||
|
||||
img
|
||||
height: 2em
|
||||
width: auto
|
||||
|
|
@ -1,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!
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-list
|
||||
--preview-base-width: 200px
|
||||
margin-bottom: 2em
|
||||
|
||||
.glitched
|
||||
cursor: help
|
||||
|
|
@ -2,8 +2,70 @@
|
|||
@import "../partials/clean/mixins"
|
||||
|
||||
body.pets-bulk
|
||||
#bulk-pets-form
|
||||
#needed-items-form, #bulk-pets-form
|
||||
text-align: center
|
||||
|
||||
#needed-items-form
|
||||
#needed-items-pet
|
||||
border-top: 1px solid $soft-border-color
|
||||
display: none
|
||||
margin-top: 1em
|
||||
padding-top: 1em
|
||||
|
||||
h4
|
||||
font-size: 150%
|
||||
margin-bottom: .5em
|
||||
|
||||
#needed-items-reload
|
||||
+inline-block
|
||||
font-size: 12px
|
||||
margin-left: 1em
|
||||
vertical-align: middle
|
||||
|
||||
#needed-items-alert
|
||||
display: none
|
||||
margin-top: .5em
|
||||
|
||||
#needed-items-pet-thumbnail
|
||||
height: 50px
|
||||
width: 50px
|
||||
|
||||
#needed-items-pet-items
|
||||
li.owned
|
||||
background: $module-bg-color
|
||||
border: 1px solid $module-border-color
|
||||
|
||||
.object-owned
|
||||
color: $soft-text-color
|
||||
display: block
|
||||
font-size: 75%
|
||||
font-style: italic
|
||||
padding-bottom: .25em
|
||||
|
||||
&.loading-pet, &.loading-items
|
||||
#needed-items-pet-name-field
|
||||
background:
|
||||
image: image-url("loading.gif")
|
||||
position: center right
|
||||
repeat: no-repeat
|
||||
|
||||
#needed-items-pet-items
|
||||
+opacity(.50)
|
||||
|
||||
&.loading-pet
|
||||
#needed-items-pet h4
|
||||
+opacity(.50)
|
||||
|
||||
&.loaded
|
||||
#needed-items-pet
|
||||
display: block
|
||||
|
||||
&.failed
|
||||
#needed-items-alert
|
||||
display: block
|
||||
|
||||
#bulk-pets-form
|
||||
border-top: 1px solid $module-border-color
|
||||
margin-top: 12px
|
||||
padding-top: 12px
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@
|
|||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: min(100vw, 100vh);
|
||||
height: min(100vw, 100vh);
|
||||
|
||||
/* HACK: `calc` isn't needed, but works around a bug in our asset pipeline,
|
||||
* where libsass is trying to preprocess it. (We're not SASS tho?) */
|
||||
width: calc(min(100vw, 100vh));
|
||||
height: calc(min(100vw, 100vh));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,15 +2,7 @@
|
|||
|
||||
body.users-top_contributors
|
||||
text-align: center
|
||||
|
||||
.timeframe-nav
|
||||
margin: 1em 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
list-style: none
|
||||
padding: 0
|
||||
|
||||
|
||||
#top-contributors
|
||||
border:
|
||||
spacing: 0
|
||||
|
|
|
|||
|
|
@ -1,45 +1,23 @@
|
|||
class AltStylesController < ApplicationController
|
||||
before_action :support_staff_only, except: [:index]
|
||||
|
||||
def index
|
||||
@all_series_names = AltStyle.all_series_names
|
||||
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
|
||||
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
|
||||
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
|
||||
order(:species_id, :color_id)
|
||||
|
||||
@series_name = params[:series]
|
||||
@color = find_color
|
||||
@species = find_species
|
||||
if params[:species_id]
|
||||
@species = Species.find(params[:species_id])
|
||||
@alt_styles = @alt_styles.merge(@species.alt_styles)
|
||||
end
|
||||
|
||||
@alt_styles = AltStyle.includes(:color, :species, :swf_assets)
|
||||
@alt_styles.where!(series_name: @series_name) if @series_name.present?
|
||||
@alt_styles.merge!(@color.alt_styles) if @color
|
||||
@alt_styles.merge!(@species.alt_styles) if @species
|
||||
# We're going to link to the HTML5 image URL, so make sure we have all the
|
||||
# manifests ready!
|
||||
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
|
||||
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@alt_styles = @alt_styles.
|
||||
by_creation_date.order(:color_id, :species_id, :series_name).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
|
||||
# We're using the HTML5 image for our preview, so make sure we have all the
|
||||
# manifests ready!
|
||||
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
|
||||
|
||||
if support_staff?
|
||||
@counts = {
|
||||
total: AltStyle.count,
|
||||
unlabeled: AltStyle.unlabeled.count,
|
||||
}
|
||||
@counts[:labeled] = @counts[:total] - @counts[:unlabeled]
|
||||
@unlabeled_style = AltStyle.unlabeled.newest.first
|
||||
end
|
||||
|
||||
render
|
||||
}
|
||||
format.html { render }
|
||||
format.json {
|
||||
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped
|
||||
render json: @alt_styles.as_json(
|
||||
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url],
|
||||
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
|
||||
only: [:id, :species_id, :color_id, :body_id, :series_name,
|
||||
:adjective_name, :thumbnail_url],
|
||||
include: {
|
||||
swf_assets: {
|
||||
only: [:id, :body_id],
|
||||
|
|
@ -47,62 +25,9 @@ class AltStylesController < ApplicationController
|
|||
methods: [:urls, :known_glitches],
|
||||
}
|
||||
},
|
||||
methods: [:series_main_name, :adjective_name],
|
||||
methods: [:series_name, :adjective_name, :thumbnail_url],
|
||||
)
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
end
|
||||
|
||||
def update
|
||||
@alt_style = AltStyle.find params[:id]
|
||||
|
||||
if @alt_style.update(alt_style_params)
|
||||
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def alt_style_params
|
||||
params.require(:alt_style).
|
||||
permit(:real_series_name, :real_full_name, :thumbnail_url)
|
||||
end
|
||||
|
||||
def find_color
|
||||
if params[:color]
|
||||
Color.find_by(name: params[:color])
|
||||
end
|
||||
end
|
||||
|
||||
def find_species
|
||||
if params[:species_id]
|
||||
Species.find_by(id: params[:species_id])
|
||||
elsif params[:species]
|
||||
Species.find_by(name: params[:species])
|
||||
end
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-style"
|
||||
next_unlabeled_style_path
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_style_path
|
||||
unlabeled_style = AltStyle.unlabeled.newest.first
|
||||
if unlabeled_style
|
||||
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
|
||||
else
|
||||
alt_styles_path
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,29 +2,30 @@ require 'async'
|
|||
require 'async/container'
|
||||
|
||||
class ApplicationController < ActionController::Base
|
||||
include FragmentLocalization
|
||||
|
||||
protect_from_forgery
|
||||
|
||||
helper_method :current_user, :support_staff?, :user_signed_in?
|
||||
|
||||
helper_method :current_user, :user_signed_in?
|
||||
|
||||
before_action :set_locale
|
||||
|
||||
before_action :configure_permitted_parameters, if: :devise_controller?
|
||||
before_action :save_return_to_path,
|
||||
if: ->(c) { c.controller_name == 'sessions' && c.action_name == 'new' }
|
||||
|
||||
# Enable profiling tools in development or when logged in as an admin.
|
||||
# Enable profiling tools if logged in as admin.
|
||||
before_action do
|
||||
Rack::MiniProfiler.authorize_request if Rails.env.development? || current_user&.admin?
|
||||
if current_user && current_user.admin?
|
||||
Rack::MiniProfiler.authorize_request
|
||||
end
|
||||
end
|
||||
|
||||
class AccessDenied < StandardError; end
|
||||
rescue_from AccessDenied, with: :on_access_denied
|
||||
|
||||
rescue_from Async::Stop, Async::Container::Terminate,
|
||||
with: :on_request_stopped
|
||||
|
||||
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
|
||||
|
||||
def authenticate_user!
|
||||
redirect_to(new_auth_user_session_path) unless user_signed_in?
|
||||
end
|
||||
|
|
@ -44,15 +45,15 @@ class ApplicationController < ActionController::Base
|
|||
def user_signed_in?
|
||||
auth_user_signed_in?
|
||||
end
|
||||
|
||||
|
||||
def infer_locale
|
||||
return params[:locale] if valid_locale?(params[:locale])
|
||||
return cookies[:locale] if valid_locale?(cookies[:locale])
|
||||
Rails.logger.debug "Preferred languages: #{http_accept_language.user_preferred_languages}"
|
||||
http_accept_language.language_region_compatible_from(I18n.available_locales.map(&:to_s)) ||
|
||||
http_accept_language.language_region_compatible_from(I18n.public_locales.map(&:to_s)) ||
|
||||
I18n.default_locale
|
||||
end
|
||||
|
||||
|
||||
def not_found(record_name='record')
|
||||
raise ActionController::RoutingError.new("#{record_name} not found")
|
||||
end
|
||||
|
|
@ -66,11 +67,6 @@ class ApplicationController < ActionController::Base
|
|||
status: :internal_server_error
|
||||
end
|
||||
|
||||
def on_db_timeout
|
||||
render file: 'public/503.html', layout: false,
|
||||
status: :service_unavailable
|
||||
end
|
||||
|
||||
def redirect_back!(default=:back)
|
||||
redirect_to(params[:return_to] || default)
|
||||
end
|
||||
|
|
@ -80,7 +76,7 @@ class ApplicationController < ActionController::Base
|
|||
end
|
||||
|
||||
def valid_locale?(locale)
|
||||
locale && I18n.available_locales.include?(locale.to_sym)
|
||||
locale && I18n.usable_locales.include?(locale.to_sym)
|
||||
end
|
||||
|
||||
def configure_permitted_parameters
|
||||
|
|
@ -108,13 +104,5 @@ class ApplicationController < ActionController::Base
|
|||
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
|
||||
return_to || root_path
|
||||
end
|
||||
|
||||
def support_staff?
|
||||
current_user&.support_staff?
|
||||
end
|
||||
|
||||
def support_staff_only
|
||||
raise AccessDenied, "Support staff only" unless support_staff?
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -218,7 +218,7 @@ class ClosetHangersController < ApplicationController
|
|||
def enforce_shadowban
|
||||
# If this user is shadowbanned, and this *doesn't* seem to be a request
|
||||
# from that user, render the 404 page.
|
||||
if !@user.visible_to?(current_user, request.remote_ip)
|
||||
if @user.shadowbanned? && !@user.likely_is?(current_user, request.remote_ip)
|
||||
render file: "public/404.html", layout: false, status: :not_found
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,12 +3,8 @@ class ItemTradesController < ApplicationController
|
|||
@item = Item.find params[:item_id]
|
||||
@type = type_from_params
|
||||
|
||||
@item_trades = @item.visible_trades(
|
||||
scope: ClosetHanger.includes(:user, :list).
|
||||
order('users.last_trade_activity_at DESC'),
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
@item_trades = @item.closet_hangers.trading.includes(:user, :list).
|
||||
user_is_active.order('users.last_trade_activity_at DESC').to_trades
|
||||
@trades = @item_trades[@type]
|
||||
|
||||
if user_signed_in?
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
class ItemsController < ApplicationController
|
||||
before_action :set_query
|
||||
before_action :support_staff_only, except: [:index, :show, :sources]
|
||||
rescue_from Item::Search::Error, :with => :search_error
|
||||
|
||||
def index
|
||||
|
|
@ -29,12 +28,6 @@ class ItemsController < ApplicationController
|
|||
render json: {
|
||||
items: @items.as_json(
|
||||
methods: [:nc?, :pb?, :owned?, :wanted?],
|
||||
include: {
|
||||
restricted_zones: {
|
||||
only: [:id, :depth, :label],
|
||||
methods: [:is_commonly_used_by_items],
|
||||
},
|
||||
},
|
||||
),
|
||||
appearances: load_appearances.as_json(
|
||||
include: {
|
||||
|
|
@ -80,10 +73,7 @@ class ItemsController < ApplicationController
|
|||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@trades = @item.visible_trades(
|
||||
user: current_user,
|
||||
remote_ip: request.remote_ip
|
||||
)
|
||||
@trades = @item.closet_hangers.trading.user_is_active.to_trades
|
||||
|
||||
@contributors_with_counts = @item.contributors_with_counts
|
||||
|
||||
|
|
@ -101,23 +91,14 @@ class ItemsController < ApplicationController
|
|||
@preview_error = validate_preview
|
||||
|
||||
@all_appearances = @item.appearances
|
||||
@appearances_by_occupied_zone_label =
|
||||
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l }
|
||||
@appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
|
||||
sort_by { |z, a| z.label }
|
||||
@selected_item_appearance = @preview_outfit.item_appearances.first
|
||||
|
||||
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
|
||||
includes(:species).merge(Species.alphabetical)
|
||||
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
|
||||
expires_in 1.month
|
||||
redirect_to @item.thumbnail_url, allow_other_host: true
|
||||
|
|
@ -125,18 +106,24 @@ class ItemsController < ApplicationController
|
|||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
@item = Item.find params[:id]
|
||||
render layout: "application"
|
||||
end
|
||||
|
||||
def update
|
||||
@item = Item.find params[:id]
|
||||
if @item.update(item_params)
|
||||
flash[:notice] = "\"#{@item.name}\" successfully saved!"
|
||||
redirect_to @item
|
||||
else
|
||||
render action: "edit", layout: "application", status: :bad_request
|
||||
def needed
|
||||
if params[:color] && params[:species]
|
||||
@pet_type = PetType.find_by_color_id_and_species_id(
|
||||
params[:color],
|
||||
params[:species]
|
||||
)
|
||||
end
|
||||
|
||||
unless @pet_type
|
||||
raise ActiveRecord::RecordNotFound, 'Pet type not found'
|
||||
end
|
||||
|
||||
@items = @pet_type.needed_items.order(:name)
|
||||
assign_closeted!(@items)
|
||||
|
||||
respond_to do |format|
|
||||
format.html { @pet_name = params[:name] ; render :layout => 'application' }
|
||||
format.json { render :json => @items }
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -156,7 +143,7 @@ class ItemsController < ApplicationController
|
|||
|
||||
# For Dyeworks items whose base is currently in the NC Mall, preload their
|
||||
# trade values. We'll use this to determine which ones are fully buyable rn
|
||||
# (because our NC values guide tracks this data and we don't).
|
||||
# (because Owls tracks this data and we don't).
|
||||
Item.preload_nc_trade_values(@items[:dyeworks])
|
||||
|
||||
# Start loading the NC trade values for the non-Mall NC items.
|
||||
|
|
@ -192,15 +179,6 @@ class ItemsController < ApplicationController
|
|||
|
||||
protected
|
||||
|
||||
def item_params
|
||||
params.require(:item).permit(
|
||||
:name, :thumbnail_url, :description, :modeling_status_hint,
|
||||
:is_manually_nc, :explicitly_body_specific,
|
||||
).tap do |p|
|
||||
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
|
||||
end
|
||||
end
|
||||
|
||||
def assign_closeted!(items)
|
||||
current_user.assign_closeted_to_items!(items) if user_signed_in?
|
||||
end
|
||||
|
|
@ -252,8 +230,7 @@ class ItemsController < ApplicationController
|
|||
@item.compatible_pet_types.
|
||||
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
|
||||
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
|
||||
preferring_simple.first ||
|
||||
PetType.matching_name("Blue", "Acara").first!
|
||||
preferring_simple.first
|
||||
end
|
||||
|
||||
def validate_preview
|
||||
|
|
|
|||
|
|
@ -5,10 +5,7 @@ class NeopetsConnectionsController < ApplicationController
|
|||
if connection.save
|
||||
render json: connection
|
||||
else
|
||||
render json: {
|
||||
errors: connection.errors,
|
||||
full_error_messages: connection.errors.map(&:full_message)
|
||||
}, status: :bad_request
|
||||
render json: {error: 'failure'}, status: :internal_server_error
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -13,26 +13,7 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def edit
|
||||
respond_to do |format|
|
||||
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
|
||||
render "outfits/edit", layout: false
|
||||
end
|
||||
|
||||
def index
|
||||
|
|
@ -66,24 +47,29 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
def new
|
||||
@colors = Color.alphabetical
|
||||
@colors = Color.funny.alphabetical
|
||||
@species = Species.alphabetical
|
||||
|
||||
newest_items = Item.newest.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
# HACK: Skip this in development, because it's slow!
|
||||
unless Rails.env.development?
|
||||
newest_items = Item.newest.
|
||||
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
|
||||
.limit(18)
|
||||
@newest_modeled_items, @newest_unmodeled_items =
|
||||
newest_items.partition(&:predicted_fully_modeled?)
|
||||
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color = {}
|
||||
@newest_unmodeled_items_predicted_modeled_ratio = {}
|
||||
@newest_unmodeled_items.each do |item|
|
||||
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
|
||||
standard_body_ids_by_species = item.
|
||||
predicted_missing_standard_body_ids_by_species
|
||||
if standard_body_ids_by_species.present?
|
||||
h[:standard] = standard_body_ids_by_species
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
|
||||
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
|
||||
end
|
||||
|
||||
@species_count = Species.count
|
||||
|
|
@ -136,40 +122,6 @@ class OutfitsController < ApplicationController
|
|||
biology: [:species_id, :color_id, :pose, :pet_state_id])
|
||||
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
|
||||
raise ActiveRecord::RecordNotFound unless user_signed_in?
|
||||
@outfit = current_user.outfits.find(params[:id])
|
||||
|
|
|
|||
|
|
@ -1,56 +0,0 @@
|
|||
class PetStatesController < ApplicationController
|
||||
before_action :support_staff_only
|
||||
before_action :find_pet_state
|
||||
before_action :preload_assets
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @pet_state.update(pet_state_params)
|
||||
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
|
||||
redirect_to destination_after_save
|
||||
else
|
||||
render action: :edit, status: :bad_request
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
def find_pet_state
|
||||
@pet_type = PetType.find_by_param!(params[:pet_type_name])
|
||||
@pet_state = @pet_type.pet_states.find(params[:id])
|
||||
@reference_pet_type = @pet_type.reference
|
||||
end
|
||||
|
||||
def preload_assets
|
||||
SwfAsset.preload_manifests @pet_state.swf_assets
|
||||
end
|
||||
|
||||
def pet_state_params
|
||||
params.require(:pet_state).permit(:pose, :glitched)
|
||||
end
|
||||
|
||||
def destination_after_save
|
||||
if params[:next] == "unlabeled-appearance"
|
||||
next_unlabeled_appearance_path
|
||||
else
|
||||
@pet_type
|
||||
end
|
||||
end
|
||||
|
||||
def next_unlabeled_appearance_path
|
||||
unlabeled_appearance =
|
||||
PetState.next_unlabeled_appearance(after_id: params[:after])
|
||||
|
||||
if unlabeled_appearance
|
||||
edit_pet_type_pet_state_path(
|
||||
unlabeled_appearance.pet_type,
|
||||
unlabeled_appearance,
|
||||
next: "unlabeled-appearance"
|
||||
)
|
||||
else
|
||||
@pet_type
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -1,111 +1,10 @@
|
|||
class PetTypesController < ApplicationController
|
||||
def index
|
||||
respond_to do |format|
|
||||
format.html {
|
||||
@species_names = Species.order(:name).map(&:human_name)
|
||||
@color_names = Color.order(:name).map(&:human_name)
|
||||
|
||||
if params[:species].present?
|
||||
@selected_species = Species.find_by!(name: params[:species])
|
||||
@selected_species_name = @selected_species.human_name
|
||||
end
|
||||
if params[:color].present?
|
||||
@selected_color = Color.find_by!(name: params[:color])
|
||||
@selected_color_name = @selected_color.human_name
|
||||
end
|
||||
@selected_order =
|
||||
if @selected_species.present? || @selected_color.present?
|
||||
:alphabetical
|
||||
else
|
||||
:newest
|
||||
end
|
||||
|
||||
@pet_types = PetType.
|
||||
includes(:color, :species, :pet_states).
|
||||
paginate(page: params[:page], per_page: 30)
|
||||
|
||||
@pet_types.where!(species_id: @selected_species) if @selected_species
|
||||
@pet_types.where!(color_id: @selected_color) if @selected_color
|
||||
if @selected_order == :newest
|
||||
@pet_types.order!(created_at: :desc)
|
||||
elsif @selected_order == :alphabetical
|
||||
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
|
||||
end
|
||||
|
||||
if @selected_species && @selected_color && @pet_types.size == 1
|
||||
redirect_to @pet_types.first
|
||||
end
|
||||
|
||||
if support_staff?
|
||||
@counts = {
|
||||
total: PetState.count,
|
||||
glitched: PetState.glitched.count,
|
||||
needs_labeling: PetState.needs_labeling.count,
|
||||
usable: PetState.usable.count,
|
||||
}
|
||||
@unlabeled_appearance = PetState.next_unlabeled_appearance
|
||||
end
|
||||
}
|
||||
|
||||
format.json {
|
||||
if stale?(etag: PetState.last_updated_key)
|
||||
render json: {
|
||||
species: Species.order(:name).all,
|
||||
colors: Color.order(:name).all,
|
||||
supported_poses: PetState.all_supported_poses,
|
||||
}
|
||||
end
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def show
|
||||
@pet_type = find_pet_type
|
||||
@pet_type = PetType.
|
||||
where(species_id: params[:species_id]).
|
||||
where(color_id: params[:color_id]).
|
||||
first
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
@pet_states = group_pet_states @pet_type.pet_states
|
||||
end
|
||||
format.json { render json: @pet_type }
|
||||
end
|
||||
end
|
||||
|
||||
protected
|
||||
|
||||
# The API-ish route uses IDs, but the human-facing route uses names.
|
||||
def find_pet_type
|
||||
if params[:species_id] && params[:color_id]
|
||||
PetType.find_by!(
|
||||
species_id: params[:species_id],
|
||||
color_id: params[:color_id],
|
||||
)
|
||||
elsif params[:name]
|
||||
PetType.find_by_param!(params[:name])
|
||||
else
|
||||
raise "expected params: species_id and color_id, or name"
|
||||
end
|
||||
end
|
||||
|
||||
# The `canonical` pet states are the main ones we want to show: the most
|
||||
# canonical state for each pose. The `other` pet states are, the others!
|
||||
#
|
||||
# If no main poses are available, then we just make all the poses
|
||||
# "canonical", and show the whole mish-mash!
|
||||
def group_pet_states(pet_states)
|
||||
pose_groups = pet_states.emotion_order.group_by(&:pose)
|
||||
main_groups =
|
||||
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
other_groups =
|
||||
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
|
||||
|
||||
if main_groups.empty?
|
||||
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
|
||||
end
|
||||
|
||||
canonical = main_groups.map(&:first).sort_by(&:pose)
|
||||
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
|
||||
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
|
||||
|
||||
{canonical:, other:}
|
||||
render json: @pet_type
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,17 +1,20 @@
|
|||
class PetsController < ApplicationController
|
||||
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
|
||||
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
|
||||
rescue_from Pet::PetNotFound, with: :pet_not_found
|
||||
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
|
||||
rescue_from Pet::DownloadError, with: :pet_download_error
|
||||
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
|
||||
|
||||
def load
|
||||
raise Neopets::CustomPets::PetNotFound unless params[:name]
|
||||
# Uncomment this to temporarily disable modeling for most users.
|
||||
# return modeling_disabled unless user_signed_in? && current_user.admin?
|
||||
|
||||
raise Pet::PetNotFound unless params[:name]
|
||||
@pet = Pet.load(params[:name])
|
||||
points = contribute(current_user, @pet)
|
||||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
path = destination + "?" + @pet.wardrobe_query
|
||||
path = destination + @pet.wardrobe_query
|
||||
redirect_to path
|
||||
end
|
||||
|
||||
|
|
@ -34,10 +37,10 @@ class PetsController < ApplicationController
|
|||
end
|
||||
|
||||
def destination
|
||||
if request.get?
|
||||
wardrobe_path
|
||||
else
|
||||
root_path
|
||||
case (params[:destination] || params[:origin])
|
||||
when 'wardrobe' then wardrobe_path + '?'
|
||||
when 'needed_items' then needed_items_path + '?'
|
||||
else root_path + '#'
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -46,6 +49,12 @@ class PetsController < ApplicationController
|
|||
:status => :not_found
|
||||
end
|
||||
|
||||
def asset_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
pet_load_error :long_message => t('pets.load.asset_download_error'),
|
||||
:status => :gateway_timeout
|
||||
end
|
||||
|
||||
def pet_download_error(e)
|
||||
Rails.logger.warn e.message
|
||||
Rails.logger.warn e.backtrace.join("\n")
|
||||
|
|
@ -60,7 +69,7 @@ class PetsController < ApplicationController
|
|||
path += "?name=#{params[:name]}"
|
||||
redirect_to path, :alert => options[:long_message]
|
||||
end
|
||||
|
||||
|
||||
format.json do
|
||||
render :json => options[:long_message], :status => options[:status]
|
||||
end
|
||||
|
|
|
|||