Compare commits

...

No commits in common. "main" and "use-head-request-for-hash" have entirely different histories.

1030 changed files with 46633 additions and 53800 deletions

View file

@ -1,3 +0,0 @@
# 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

View file

@ -1,37 +0,0 @@
name: "openneo_impress_items"
services:
rails-app:
build:
context: ..
dockerfile: .devcontainer/Dockerfile
volumes:
- ../..:/workspaces:cached
# 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
# 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
restart: unless-stopped
environment:
MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: 'true'
volumes:
- mysql-data:/var/lib/mysql
networks:
- default
volumes:
mysql-data:

View file

@ -1,36 +0,0 @@
// 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
{
"name": "openneo_impress_items",
"dockerComposeFile": "compose.yaml",
"service": "rails-app",
"workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}",
// 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}"
},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [3000],
// 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"
}

View file

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

View file

@ -1,20 +1,7 @@
{
"extends": [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
"plugin:react/recommended",
"plugin:react-hooks/recommended",
"plugin:jsx-a11y/recommended"
],
"extends": ["next/core-web-vitals"],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "react", "react-hooks", "jsx-a11y"],
"env": {
"browser": true,
"es2021": true
},
"globals": {
"process": true // For process.env["NODE_ENV"]
},
"plugins": ["@typescript-eslint"],
"rules": {
"no-console": [
"warn",
@ -34,12 +21,6 @@
],
"react/no-unescaped-entities": ["error", { "forbid": [">", "}"] }],
// We have some React.forwardRefs that trigger this, not sure how to improve
"react/display-name": "off",
"react/prop-types": "off"
},
"settings": {
"react": {
"version": "detect"
}
"react/display-name": "off"
}
}

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
*.sql.gz filter=lfs diff=lfs merge=lfs -text

21
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,21 @@
name: Release
on:
push:
branches:
- main
jobs:
sentry:
name: Checkout latest, and create Sentry release
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v2
- name: Create Sentry release
uses: getsentry/action-release@v1.1.5
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
environment: production

51
.gitignore vendored
View file

@ -1,25 +1,34 @@
.bundle
db/*.sqlite3
log/*.log
tmp/**/*
.env
.env.*
/spec/examples.txt
/.yardoc
/app/assets/builds/*
!/app/assets/builds/.keep
/public/public-data
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# testing
/coverage
# production
/build
# misc
.DS_Store
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.eslintcache
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.vercel
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# next.js
/.next/
/out/
*.pem
# debug
# local env files
# vercel

1
.husky/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
_

3
.husky/post-checkout Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-checkout.\n"; exit 2; }
git lfs post-checkout "$@"

3
.husky/post-commit Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-commit.\n"; exit 2; }
git lfs post-commit "$@"

3
.husky/post-merge Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/post-merge.\n"; exit 2; }
git lfs post-merge "$@"

View file

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

3
.husky/pre-push Executable file
View file

@ -0,0 +1,3 @@
#!/bin/sh
command -v git-lfs >/dev/null 2>&1 || { echo >&2 "\nThis repository is configured for Git LFS but 'git-lfs' was not found on your path. If you no longer wish to use Git LFS, remove this hook by deleting .git/hooks/pre-push.\n"; exit 2; }
git lfs pre-push "$@"

1
.rspec
View file

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

View file

@ -1 +0,0 @@
3.4.5

View file

@ -1,24 +0,0 @@
---
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

41
.vscode/impress-2020.code-snippets vendored Normal file
View file

@ -0,0 +1,41 @@
{
// Place your impress-2020 workspace snippets here. Each snippet is defined under a snippet name and has a scope, prefix, body and
// description. Add comma separated ids of the languages where the snippet is applicable in the scope field. If scope
// is left empty or omitted, the snippet gets applied to all languages. The prefix is what is
// used to trigger the snippet and the body will be expanded and inserted. Possible variables are:
// $1, $2 for tab stops, $0 for the final cursor position, and ${1:label}, ${2:another} for placeholders.
// Placeholders with the same ids are connected.
// Example:
// "Print to console": {
// "scope": "javascript,typescript",
// "prefix": "log",
// "body": [
// "console.log('$1');",
// "$2"
// ],
// "description": "Log output to console"
// }
"Component file": {
"scope": "javascript",
"prefix": "componentfile",
"body": [
"import React from \"react\";",
"import { Box } from \"@chakra-ui/react\";",
"",
"function $TM_FILENAME_BASE() {",
" return <Box>$1</Box>;",
"}",
"",
"export default $TM_FILENAME_BASE;"
]
},
"Function component": {
"scope": "javascript",
"prefix": "fncomponent",
"body": [
"function ${1:Component}({${2:children}}) {",
" return ${3:<Box>$4</Box>};",
"}"
]
}
}

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"editor.formatOnSave": true,
"editor.tabSize": 2,
"jest.pathToJest": "yarn test",
"jest.autoEnable": false,
"javascript.suggest.completeJSDocs": false,
"typescript.suggest.completeJSDocs": false,
"editor.rulers": [80]
}

100
Gemfile
View file

@ -1,100 +0,0 @@
source 'https://rubygems.org'
ruby '3.4.5'
gem 'rails', '~> 8.0', '>= 8.0.1'
# The HTTP server running the Rails instance.
gem 'falcon', '~> 0.48.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-rails', '~> 2.8', '>= 2.8.1'
# For the asset pipeline: templates, CSS, JS, etc.
gem 'sprockets', '~> 4.2'
gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'jsbundling-rails', '~> 1.3'
gem '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', '~> 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 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages.
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', '~> 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'
# For preventing too many modeling attempts.
gem 'rack-attack', '~> 6.7'
# For testing emails in development.
gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
# For miscellaneous HTTP requests.
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 "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
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false
# For investigating performance issues.
gem "rack-mini-profiler", "~> 3.1"
gem "memory_profiler", "~> 1.0"
gem "stackprof", "~> 0.2.25"
# For monitoring errors in production.
gem "sentry-ruby", "~> 5.12"
gem "sentry-rails", "~> 5.12"
# For tasks that use shell commands.
gem "shell", "~> 0.8.1"
# For workspace autocomplete.
group :development do
gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

@ -1,593 +0,0 @@
PATH
remote: vendor/gems/RocketAMF-1.0.0
specs:
RocketAMF (1.0.0.dti1)
GEM
remote: https://rubygems.org/
specs:
action_text-trix (2.1.15)
railties
actioncable (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
actionmailer (8.1.1)
actionpack (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activesupport (= 8.1.1)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (8.1.1)
actionview (= 8.1.1)
activesupport (= 8.1.1)
nokogiri (>= 1.8.5)
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
useragent (~> 0.16)
actiontext (8.1.1)
action_text-trix (~> 2.1.15)
actionpack (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (8.1.1)
activesupport (= 8.1.1)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (8.1.1)
activesupport (= 8.1.1)
globalid (>= 0.3.6)
activemodel (8.1.1)
activesupport (= 8.1.1)
activerecord (8.1.1)
activemodel (= 8.1.1)
activesupport (= 8.1.1)
timeout (>= 0.4.0)
activestorage (8.1.1)
actionpack (= 8.1.1)
activejob (= 8.1.1)
activerecord (= 8.1.1)
activesupport (= 8.1.1)
marcel (~> 1.0)
activesupport (8.1.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)
aes_key_wrap (1.1.0)
ast (2.4.3)
async (2.35.0)
console (~> 1.29)
fiber-annotation
io-event (~> 1.11)
metrics (~> 0.12)
traces (~> 0.18)
async-container (0.27.7)
async (~> 2.22)
async-http (0.89.0)
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.16.0)
async
async-container (~> 0.16)
string-format (~> 0.2)
attr_required (1.0.2)
backport (1.2.0)
base64 (0.3.0)
bcrypt (3.1.20)
benchmark (0.5.0)
bigdecimal (4.0.1)
bindata (2.5.1)
bindex (0.8.1)
bootsnap (1.20.1)
msgpack (~> 1.2)
builder (3.3.0)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.6)
connection_pool (3.0.2)
console (1.34.2)
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)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
responders
warden (~> 1.2.3)
devise-encryptable (0.2.0)
devise (>= 2.1.0)
diff-lcs (1.6.2)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
drb (2.2.3)
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)
async
async-container (~> 0.18)
async-http (~> 0.75)
async-http-cache (~> 0.4)
async-service (~> 0.10)
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
logger
faraday-follow_redirects (0.4.0)
faraday (>= 1, < 3)
faraday-net_http (3.4.2)
net-http (~> 0.5)
ffi (1.17.2)
ffi (1.17.2-aarch64-linux-gnu)
ffi (1.17.2-arm64-darwin)
ffi (1.17.2-x86_64-linux-gnu)
fiber-annotation (0.2.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.1)
globalid (1.3.0)
activesupport (>= 6.1)
haml (6.4.0)
temple (>= 0.8.2)
thor
tilt
hashdiff (1.2.1)
hashie (5.1.0)
logger
http_accept_language (2.1.1)
i18n (1.14.8)
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)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jaro_winkler (1.6.1)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.18.0)
json-jwt (1.17.0)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.5)
launchy (3.1.1)
addressable (~> 2.8)
childprocess (~> 5.0)
logger (~> 1.6)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
lint_roller (1.1.0)
localhost (1.6.0)
logger (1.7.0)
loofah (2.25.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.9.0)
logger
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)
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)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.5.1)
net-protocol
nio4r (2.7.5)
nokogiri (1.18.10-aarch64-linux-gnu)
racc (~> 1.4)
nokogiri (1.18.10-arm64-darwin)
racc (~> 1.4)
nokogiri (1.18.10-x86_64-linux-gnu)
racc (~> 1.4)
omniauth (2.1.4)
hashie (>= 3.4.6)
logger
rack (>= 2.2.3)
rack-protection
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)
activemodel
attr_required (>= 1.0.0)
email_validator
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.16)
mail
rack-oauth2 (~> 2.2)
swd (~> 2.0)
tzinfo
validate_url
webfinger (~> 2.0)
openssl (3.3.2)
orm_adapter (0.5.0)
parallel (1.27.0)
parser (3.3.10.0)
ast (~> 2.4.1)
racc
pp (0.6.3)
prettyprint
prettyprint (0.2.0)
prism (1.7.0)
process-metrics (0.8.0)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.5.1)
protocol-http (0.56.1)
protocol-http1 (0.35.2)
protocol-http (~> 0.22)
protocol-http2 (0.23.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.47)
protocol-rack (0.19.0)
io-stream (>= 0.10)
protocol-http (~> 0.43)
rack (>= 1.0)
psych (5.3.1)
date
stringio
public_suffix (7.0.0)
racc (1.8.1)
rack (3.2.4)
rack-attack (6.8.0)
rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1)
rack (>= 1.2.0)
rack-oauth2 (2.3.0)
activesupport
attr_required
faraday (~> 2.0)
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.2.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.1.1)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.3.1)
rack (>= 3)
rails (8.1.1)
actioncable (= 8.1.1)
actionmailbox (= 8.1.1)
actionmailer (= 8.1.1)
actionpack (= 8.1.1)
actiontext (= 8.1.1)
actionview (= 8.1.1)
activejob (= 8.1.1)
activemodel (= 8.1.1)
activerecord (= 8.1.1)
activestorage (= 8.1.1)
activesupport (= 8.1.1)
bundler (>= 1.15.0)
railties (= 8.1.1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.2)
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)
i18n (>= 0.7, < 2)
railties (>= 8.0.0, < 9)
railties (8.1.1)
actionpack (= 8.1.1)
activesupport (= 8.1.1)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
tsort (>= 0.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.3.1)
rbs (2.8.4)
rdiscount (2.2.7.3)
rdoc (7.0.3)
erb
psych (>= 4.0.0)
tsort
regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
responders (3.2.0)
actionpack (>= 7.0)
railties (>= 7.0)
reverse_markdown (2.1.1)
nokogiri
rexml (3.4.4)
rspec-core (3.13.6)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.7)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.1)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.6)
rubocop (1.82.1)
json (~> 2.3)
language_server-protocol (~> 3.17.0.2)
lint_roller (~> 1.1.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.48.0, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.48.0)
parser (>= 3.3.7.2)
prism (~> 1.4)
ruby-progressbar (1.13.0)
ruby-vips (2.3.0)
ffi (~> 1.12)
logger
samovar (2.4.1)
console (~> 1.0)
mapping (~> 1.0)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
sass-rails (6.0.0)
sassc-rails (~> 2.1, >= 2.1.1)
sassc (2.4.0)
ffi (~> 1.9)
sassc-rails (2.1.2)
railties (>= 4.0.0)
sassc (>= 2.0)
sprockets (> 3.0)
sprockets-rails
tilt
securerandom (0.4.1)
sentry-rails (5.28.1)
railties (>= 5.0)
sentry-ruby (~> 5.28.1)
sentry-ruby (5.28.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shell (0.8.1)
e2mmap
sync
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.2.4)
activesupport
solargraph (>= 0.48.0, <= 0.57)
sprockets (4.2.2)
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)
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)
execjs (>= 0.3.0, < 3)
thor (1.4.0)
thread-local (1.1.0)
tilt (2.6.1)
timeout (0.6.0)
traces (0.18.2)
tsort (0.2.0)
turbo-rails (2.0.20)
actionpack (>= 7.1.0)
railties (>= 7.1.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (3.2.0)
unicode-emoji (~> 4.1)
unicode-emoji (4.2.0)
uri (1.1.1)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
warden (1.2.9)
rack (>= 2.0.9)
web-console (4.2.1)
actionview (>= 6.0.0)
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
webfinger (2.1.3)
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
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.1)
yard (0.9.38)
zeitwerk (2.7.4)
PLATFORMS
aarch64-linux
arm64-darwin
x86_64-linux
DEPENDENCIES
RocketAMF!
addressable (~> 2.8)
async (~> 2.17)
async-http (~> 0.89.0)
bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.48.0)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
jsbundling-rails (~> 1.3)
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 (~> 1.0)
omniauth_openid_connect (~> 0.7.1)
rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1)
rails (~> 8.0, >= 8.0.1)
rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 7.0)
ruby-vips (~> 2.2)
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
BUNDLED WITH
2.7.1

View file

@ -2,7 +2,7 @@
Contributor: Matchu
Source Code: https://code.openneo.net/OpenNeo/impress
Source Code: https://github.com/matchu/impress-2020
## Modification

View file

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

204
README.md
View file

@ -1,164 +1,108 @@
<img src="https://i.imgur.com/mZ2FCfX.png" width="200" height="200" alt="Dress to Impress beach logo" />
# Dress to Impress
# Dress to Impress 2020
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!
This is a rewrite of the Neopets customization app, Dress to Impress!
## Architecture Overview
It's a React app, using the Next.js framework. (But kinda awkwardly, because it
used to be a `create-react-app`, and we never fully rearchitected from that!)
DTI is a Rails application with a React-based outfit editor, backed by MySQL databases and a crowdsourced data collection system.
The motivating goals of the rewrite are:
### Core Components
- Mobile friendly, to match Neopets's move to mobile.
- Simple modern tech, to be more maintainable over time and decrease hosting costs.
- **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
## Installation guide
### The Impress 2020 Complication
### Getting everything set up
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**:
We'll assume you already have your basic development environment ready! Be sure
to install the following:
- **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
- Git
- Node v16
- The Yarn package manager
- A MySQL database server
See [docs/impress-2020-dependencies.md](./docs/impress-2020-dependencies.md) for migration status.
**Before you clone the repository**, install Git LFS, a tool for managing large
files in Git. (We use this for the big batch of public data that we'll import
into your dev database.)
## Key Concepts
Next, clone this repository, and ensure that
`scripts/db/public-data-from-modeling.sql.gz` is around ~30MB large. (If it's
much smaller, like 4KB, that probably means Git LFS didn't run correctly, so
the next step would be to debug that, delete the repository, and try again!)
### Customization Data Model
Next, run `yarn install`. This should install the app's NPM dependencies. (You
may need to install some additional libraries to your machine for certain
dependencies to install correctly. See the instructions for
[canvas][npm-canvas] in particular!)
The core data model powers outfit rendering and item compatibility. See [docs/customization-architecture.md](./docs/customization-architecture.md) for details.
### Create your development database
**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)
Next, create two MySQL databases: `openneo_impress` and `openneo_id`. Then,
create a MySQL user named `impress_dev` with password `impress_dev`,
with full permissions for both databases.
### Modeling (Crowdsourced Data)
(We're assuming that, on your local machine, your MySQL server isn't connected
to the outside internet, and that there probably won't be sensitive information
stored in your DTI database anyway, so it should be okay for this username and
password to be hardcoded.)
DTI doesn't pre-populate item/pet data. Instead:
Finally, run `yarn db:setup-dev:full` to fill the databases
with the necessary schema, plus some real public data exported from DTI—like
items, species, and colors!
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
### See it work!
This "self-sustaining" approach means the site stays up-to-date as Neopets releases new content, without manual data entry.
Okay, let's run `yarn dev`! This should start a DTI server on port 3000. Open
it in your browser and hopefully it works!! 🤞
## Directory Map
### Optional: You might need some environment variables
### Key Application Files
In Next.js, you can set environment variables in a `.env` file, in the root of
the app. (This will be ignored by Git, thanks to our `.gitignore` file.)
Note that some the features of the site won't work without special environment
variables set, because they depend on production services we can't reproduce
locally. But they generally fail gracefully and show a helpful error message,
so you mostly won't have to worry about it until you run into it!
You mostly won't need to use this! But one early case you'll run into: for
account creation and login to work, you'll need to create a `.env` file with a
value for `DTI_AUTH_TOKEN_SECRET`: a secret string we use to cryptographically
validates the user's login cookie. In production this is a closely-guarded
secret, but for development, just open a random password generator and
copy-paste the result into `.env`!
```
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
DTI_AUTH_TOKEN_SECRET=jl2DFjkewkrufsIDKwhatever
```
### Configuration & Docs
[npm-canvas]: https://www.npmjs.com/package/canvas
```
config/
├── routes.rb # All Rails routes
├── database.yml # Multi-database setup (main + openneo_id)
└── environments/
└── *.rb # Env-specific config (incl. impress_2020_origin)
```
## Architecture sketch
**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
First, there's the core app, in this repository.
**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
- **React app:** Runs mainly on the client's machine. Code in `src/app`.
- **API functions:** Run on our VPS server. Code in `api` and `src/server`.
## Tech Stack
Then, there's our various data storage components.
- **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)
- **MySQL database:** Runs on our Linode VPS, colocated with the old app.
- **Amazon S3:** Stores PNGs of pet/item appearance layers, converted from the Neopets SWFs. _(Once Neopets releases HTML5-compatible assets for all their items, we can hopefully remove this!)_
## Development Notes
Finally, there's our third-party integrations.
### OpenNeo ID Database
- **Honeycomb:** For observability & performance insights on the backend.
- **Discord:** For logging Support users' actions to a private Discord server.
- **Neopets:** We load pet data from them! And plenty of assets!
- **Fastly:** A CDN cache that sits in front of our server to help cache common requests and expensive operations. We also use them to proxy for `images.neopets.com` in some cases, so we can add crossdomain headers.
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.
Notable old components _not_ currently included in Impress 2020:
**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)**
- **Elasticsearch:** Used for lightning-fast item search queries. So far, we're finding the MySQL queries to be fast enough in practice. Might consider using some kind of fulltext query engine if that doesn't scale with more users!
- **Resque:** Used to schedule background tasks for modeling and outfit thumbnails. (We now perform these tasks on-demand, and use Fastly to cache things like thumbnails!)
- **Memcache:** Used to cache common HTML and JSON snippets. Not yet needing anything similar in Impress 2020!
- **The entire old Rails app!** No references to it in here, aside from some temporary URL links to features that aren't implemented here yet.

View file

@ -1,11 +0,0 @@
# Add your own tasks in files placed in lib/tasks ending in .rake,
# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake.
require File.expand_path('../config/application', __FILE__)
require 'rake'
require 'rake/testtask'
require 'rdoc/task'
OpenneoImpressItems::Application.load_tasks

View file

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 801 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 655 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 732 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 537 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 670 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 596 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 671 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

View file

@ -1,861 +0,0 @@
(function () {
function addCSRFToken(xhr) {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
}
var hangersInitCallbacks = [];
function onHangersInit(callback) {
hangersInitCallbacks[hangersInitCallbacks.length] = callback;
}
function hangersInit() {
for (var i = 0; i < hangersInitCallbacks.length; i++) {
try {
hangersInitCallbacks[i]();
} catch (error) {
console.error(error);
}
}
}
/*
Hanger groups
*/
var hangerGroups = [];
$(".closet-hangers-group").each(function () {
var el = $(this);
var lists = [];
el.find("div.closet-list").each(function () {
var el = $(this);
var id = el.attr("data-id");
if (id) {
lists[lists.length] = {
id: parseInt(id, 10),
label: el.find("h4").text(),
};
}
});
hangerGroups[hangerGroups.length] = {
label: el.find("h3").text(),
lists: lists,
owned: el.attr("data-owned") == "true",
};
});
$(".closet-hangers-group span.toggle").live("click", function () {
$(this).closest(".closet-hangers-group").toggleClass("hidden");
});
var hangersElQuery = "#closet-hangers";
var hangersEl = $(hangersElQuery);
/*
Compare with Your Items
*/
$("#toggle-compare").click(function () {
hangersEl.toggleClass("comparing");
});
// Read the item IDs of trade matches from the meta tags.
const ownedIds =
document
.querySelector("meta[name=trade-matches-owns]")
?.getAttribute("value")
?.split(",") ?? [];
const wantedIds =
document
.querySelector("meta[name=trade-matches-wants]")
?.getAttribute("value")
?.split(",") ?? [];
// Apply the `user-owns` and `user-wants` classes to the relevant entries.
// This both provides immediate visual feedback, and sets up "Compare with
// Your Items" to toggle to just them!
//
// NOTE: The motivation here is caching: this allows us to share a cache of
// the closet list contents across all users, without `user-owns` or
// `user-wants` classes for one specific user getting cached and reused.
const hangerEls = document.querySelectorAll("#closet-hangers .object");
for (const hangerEl of hangerEls) {
const itemId = hangerEl.getAttribute("data-item-id");
if (ownedIds.includes(itemId)) {
hangerEl.classList.add("user-owns");
}
if (wantedIds.includes(itemId)) {
hangerEl.classList.add("user-wants");
}
}
/*
Hanger forms
*/
var body = $(document.body).addClass("js");
if (!body.hasClass("current-user")) return false;
// When we get hangers HTML, add the controls. We do this in JS rather than
// in the HTML for caching, since otherwise the requests can take forever.
// If there were another way to add hangers, then we'd have to worry about
// that, but, right now, the only way to create a new hanger from this page
// is through the autocompleter, which reinitializes anyway. Geez, this thing
// is begging for a rewrite, but today we're here for performance.
$("#closet-hanger-update-tmpl").template("updateFormTmpl");
$("#closet-hanger-destroy-tmpl").template("destroyFormTmpl");
onHangersInit(function () {
// Super-lame hack to get the user ID from where it already is :/
var currentUserId = itemsSearchForm.data("current-user-id");
$("#closet-hangers .closet-hangers-group").each(function () {
var groupEl = $(this);
var owned = groupEl.data("owned");
groupEl.find("div.closet-list").each(function () {
var listEl = $(this);
var listId = listEl.data("id");
listEl.find("div.object").each(function () {
var hangerEl = $(this);
var hangerId = hangerEl.data("id");
var quantityEl = hangerEl.find("div.quantity");
var quantity = hangerEl.data("quantity");
// Ooh, this part is weird. We only want the name to be linked, so
// lift everything else out.
var checkboxId = "hanger-selected-" + hangerId;
var label = $("<label />", { for: checkboxId });
var link = hangerEl.children("a");
link.children(":not(.name)").detach().appendTo(label);
link.detach().appendTo(label);
var checkbox = $("<input />", {
type: "checkbox",
id: checkboxId,
}).appendTo(hangerEl);
label.appendTo(hangerEl);
// I don't usually like to _blank things, but it's too easy to click
// the text when you didn't mean to and lose your selection work.
link.attr("target", "_blank");
$.tmpl("updateFormTmpl", {
user_id: currentUserId,
closet_hanger_id: hangerId,
quantity: quantity,
list_id: listId,
owned: owned,
}).appendTo(quantityEl);
$.tmpl("destroyFormTmpl", {
user_id: currentUserId,
closet_hanger_id: hangerId,
}).appendTo(hangerEl);
});
});
});
});
$.fn.liveDraggable = function (opts) {
this.live("mouseover", function () {
if (!$(this).data("init")) {
$(this).data("init", true).draggable(opts);
}
});
};
$.fn.disableForms = function () {
return this.data("formsDisabled", true)
.find("input")
.attr("disabled", "disabled")
.end();
};
$.fn.enableForms = function () {
return this.data("formsDisabled", false)
.find("input")
.removeAttr("disabled")
.end();
};
$.fn.hasChanged = function () {
return this.attr("data-previous-value") != this.val();
};
$.fn.revertValue = function () {
return this.each(function () {
var el = $(this);
el.val(el.attr("data-previous-value"));
});
};
$.fn.storeValue = function () {
return this.each(function () {
var el = $(this);
el.attr("data-previous-value", el.val());
});
};
$.fn.insertIntoSortedList = function (list, compare) {
var newChild = this,
inserted = false;
list.children().each(function () {
if (compare(newChild, $(this)) < 1) {
newChild.insertBefore(this);
inserted = true;
return false;
}
});
if (!inserted) newChild.appendTo(list);
return this;
};
function handleSaveError(xhr, action) {
try {
var data = $.parseJSON(xhr.responseText);
} catch (e) {
var data = {};
}
if (typeof data.errors != "undefined") {
$.jGrowl("Error " + action + ": " + data.errors.join(", "));
} else {
$.jGrowl("We had trouble " + action + " just now. Try again?");
}
}
function objectRemoved(objectWrapper) {
objectWrapper.hide(250, function () {
objectWrapper.remove();
updateBulkActions();
});
}
function compareItemsByName(a, b) {
return a.find("span.name").text().localeCompare(b.find("span.name").text());
}
function findList(owned, id, item) {
if (id) {
return $("#closet-list-" + id);
} else {
return $(
".closet-hangers-group[data-owned=" +
owned +
"] div.closet-list.unlisted",
);
}
}
function updateListHangersCount(el) {
el.attr("data-hangers-count", el.find("div.object").length);
}
function moveItemToList(item, owned, listId) {
var newList = findList(owned, listId, item);
var oldList = item.closest("div.closet-list");
var hangersWrapper = newList.find("div.closet-list-hangers");
item.insertIntoSortedList(hangersWrapper, compareItemsByName);
updateListHangersCount(oldList);
updateListHangersCount(newList);
}
function submitUpdateForm(form) {
if (form.data("loading")) return false;
var quantityEl = form.children("input[name=closet_hanger[quantity]]");
var ownedEl = form.children("input[name=closet_hanger[owned]]");
var listEl = form.children("input[name=closet_hanger[list_id]]");
var listChanged = ownedEl.hasChanged() || listEl.hasChanged();
if (listChanged || quantityEl.hasChanged()) {
var objectWrapper = form.closest(".object").addClass("loading");
var newQuantity = quantityEl.val();
var quantitySpan = objectWrapper.find(".quantity span").text(newQuantity);
objectWrapper.attr("data-quantity", newQuantity);
var data = form.serialize(); // get data before disabling inputs
objectWrapper.disableForms();
form.data("loading", true);
if (listChanged)
moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
$.ajax({
url: form.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function (data) {
if (quantityEl.val() == 0) {
objectRemoved(objectWrapper);
} else {
objectWrapper.removeClass("loading").enableForms();
}
form.data("loading", false);
},
success: function () {
// Now that the move was successful, let's merge it with any
// conflicting hangers
var id = objectWrapper.attr("data-item-id");
var conflictingHanger = findList(
ownedEl.val(),
listEl.val(),
objectWrapper,
)
.find("div[data-item-id=" + id + "]")
.not(objectWrapper);
if (conflictingHanger.length) {
var conflictingQuantity = parseInt(
conflictingHanger.attr("data-quantity"),
10,
);
var currentQuantity = parseInt(newQuantity, 10);
var mergedQuantity = conflictingQuantity + currentQuantity;
quantitySpan.text(mergedQuantity);
quantityEl.val(mergedQuantity);
objectWrapper.attr("data-quantity", mergedQuantity);
conflictingHanger.remove();
}
quantityEl.storeValue();
ownedEl.storeValue();
listEl.storeValue();
updateBulkActions();
},
error: function (xhr) {
quantityEl.revertValue();
ownedEl.revertValue();
listEl.revertValue();
if (listChanged)
moveItemToList(objectWrapper, ownedEl.val(), listEl.val());
quantitySpan.text(quantityEl.val());
handleSaveError(xhr, "updating the quantity");
},
});
}
}
$(hangersElQuery + " form.closet-hanger-update").live("submit", function (e) {
e.preventDefault();
submitUpdateForm($(this));
});
function editableInputs() {
return $(hangersElQuery).find(
"input[name=closet_hanger[quantity]], " +
"input[name=closet_hanger[owned]], " +
"input[name=closet_hanger[list_id]]",
);
}
$(hangersElQuery + "input[name=closet_hanger[quantity]]")
.live("change", function () {
submitUpdateForm($(this).parent());
})
.storeValue();
onHangersInit(function () {
editableInputs().storeValue();
});
$(hangersElQuery + " div.object")
.live("mouseleave", function () {
submitUpdateForm($(this).find("form.closet-hanger-update"));
})
.liveDraggable({
appendTo: "#closet-hangers",
distance: 20,
helper: "clone",
revert: "invalid",
});
$(hangersElQuery + " form.closet-hanger-destroy").live(
"submit",
function (e) {
e.preventDefault();
var form = $(this);
var button = form.children("input[type=submit]").val("Removing…");
var objectWrapper = form.closest(".object").addClass("loading");
var data = form.serialize(); // get data before disabling inputs
objectWrapper.addClass("loading").disableForms();
$.ajax({
url: form.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function () {
button.val("Remove");
},
success: function () {
objectRemoved(objectWrapper);
},
error: function () {
objectWrapper.removeClass("loading").enableForms();
$.jGrowl("Error removing item. Try again?");
},
});
},
);
$(hangersElQuery + " .select-all").live("click", function (e) {
var checkboxes = $(this)
.closest(".closet-list")
.find(".object input[type=checkbox]");
var allChecked = true;
checkboxes.each(function () {
if (!this.checked) {
allChecked = false;
return false;
}
});
checkboxes.attr("checked", !allChecked);
updateBulkActions(); // setting the checked prop doesn't fire change events
});
function getCheckboxes() {
return $(hangersElQuery + " input[type=checkbox]");
}
function getCheckedIds() {
var checkedIds = [];
getCheckboxes()
.filter(":checked")
.each(function () {
if (this.checked) checkedIds.push(this.id);
});
return checkedIds;
}
getCheckboxes().live("change", updateBulkActions);
function updateBulkActions() {
var checkedCount = getCheckboxes().filter(":checked").length;
$(".bulk-actions").attr("data-target-count", checkedCount);
$(".bulk-actions-target-count").text(checkedCount);
}
$(".bulk-actions-move-all").bind("submit", function (e) {
// TODO: DRY
e.preventDefault();
var form = $(this);
var data = form.serializeArray();
data.push({
name: "return_to",
value: window.location.pathname + window.location.search,
});
var checkedBoxes = getCheckboxes().filter(":checked");
checkedBoxes.each(function () {
data.push({
name: "ids[]",
value: $(this).closest(".object").attr("data-id"),
});
});
$.ajax({
url: form.attr("action"),
type: form.attr("method"),
data: data,
beforeSend: addCSRFToken,
success: function (html) {
var doc = $(html);
hangersEl.html(doc.find("#closet-hangers").html());
hangersInit();
updateBulkActions(); // don't want to maintain checked; deselect em all
doc
.find(".flash")
.hide()
.insertBefore(hangersEl)
.show(500)
.delay(5000)
.hide(250);
itemsSearchField.val("");
},
error: function (xhr) {
handleSaveError(xhr, "moving these items");
},
});
});
$(".bulk-actions-remove-all").bind("submit", function (e) {
e.preventDefault();
var form = $(this);
var hangerIds = [];
var checkedBoxes = getCheckboxes().filter(":checked");
var hangerEls = $();
checkedBoxes.each(function () {
hangerEls = hangerEls.add($(this).closest(".object"));
});
hangerEls.each(function () {
hangerIds.push($(this).attr("data-id"));
});
$.ajax({
url: form.attr("action") + ".json?" + $.param({ ids: hangerIds }),
type: "delete",
dataType: "json",
beforeSend: addCSRFToken,
success: function () {
objectRemoved(hangerEls);
},
error: function () {
$.jGrowl("Error removing items. Try again?");
},
});
});
$(".bulk-actions-deselect-all").bind("click", function (e) {
getCheckboxes().filter(":checked").attr("checked", false);
updateBulkActions();
});
function maintainCheckboxes(fn) {
var checkedIds = getCheckedIds();
fn();
checkedIds.forEach(function (id) {
document.getElementById(id).checked = true;
});
updateBulkActions();
}
/*
Search, autocomplete
*/
var itemsSearchForm = $("#closet-hangers-items-search[data-current-user-id]");
var itemsSearchField = itemsSearchForm.children("input[name=q]");
itemsSearchField.autocomplete({
select: function (e, ui) {
if (ui.item.is_item) {
// Let the autocompleter finish up this search before starting a new one
setTimeout(function () {
itemsSearchField.autocomplete("search", ui.item);
}, 0);
} else {
var item = ui.item.item;
var group = ui.item.group;
itemsSearchField.addClass("loading");
var closetHanger = {
owned: group.owned,
list_id: ui.item.list ? ui.item.list.id : "",
};
if (!item.hasHanger) closetHanger.quantity = 1;
$.ajax({
url:
"/user/" +
itemsSearchForm.data("current-user-id") +
"/items/" +
item.id +
"/closet_hangers",
type: "post",
data: {
closet_hanger: closetHanger,
return_to: window.location.pathname + window.location.search,
},
beforeSend: addCSRFToken,
complete: function () {
itemsSearchField.removeClass("loading");
},
success: function (html) {
var doc = $(html);
maintainCheckboxes(function () {
hangersEl.html(doc.find("#closet-hangers").html());
hangersInit();
});
doc
.find(".flash")
.hide()
.insertBefore(hangersEl)
.show(500)
.delay(5000)
.hide(250);
itemsSearchField.val("");
},
error: function (xhr) {
handleSaveError(xhr, "adding the item");
},
});
}
},
source: function (input, callback) {
if (typeof input.term == "string") {
// user-typed query
$.getJSON("/items.json?q=" + input.term, function (data) {
var output = [];
var items = data.items;
for (var i in items) {
items[i].label = items[i].name;
items[i].is_item = true;
output[output.length] = items[i];
}
callback(output);
});
} else {
// item was chosen, now choose a group to insert
var groupInserts = [],
group;
var item = input.term,
itemEl,
occupiedGroups,
hasHanger;
for (var i in hangerGroups) {
group = hangerGroups[i];
itemEl = $(
".closet-hangers-group[data-owned=" +
group.owned +
"] div.object[data-item-id=" +
item.id +
"]",
);
occupiedGroups = itemEl.closest(".closet-list");
hasHanger = occupiedGroups.filter(".unlisted").length > 0;
groupInserts[groupInserts.length] = {
group: group,
item: item,
label: item.label,
hasHanger: hasHanger,
};
for (var i = 0; i < group.lists.length; i++) {
hasHanger =
occupiedGroups.filter("[data-id=" + group.lists[i].id + "]")
.length > 0;
groupInserts[groupInserts.length] = {
group: group,
item: item,
label: item.label,
list: group.lists[i],
hasHanger: hasHanger,
};
}
}
callback(groupInserts);
}
},
});
var autocompleter = itemsSearchField.data("autocomplete");
autocompleter._renderItem = function (ul, item) {
var li = $("<li></li>").data("item.autocomplete", item);
if (item.is_item) {
// these are items from the server
$("#autocomplete-item-tmpl").tmpl({ item_name: item.label }).appendTo(li);
} else if (item.list) {
// these are list inserts
var listName = item.list.label;
if (item.hasHanger) {
$("#autocomplete-already-in-collection-tmpl")
.tmpl({ collection_name: listName })
.appendTo(li);
} else {
$("#autocomplete-add-to-list-tmpl")
.tmpl({ list_name: listName })
.appendTo(li);
}
li.addClass("closet-list-autocomplete-item");
} else {
// these are group inserts
var groupName = item.group.label;
if (!item.hasHanger) {
$("#autocomplete-add-to-group-tmpl")
.tmpl({ group_name: groupName.replace(/\s+$/, "") })
.appendTo(li);
} else {
$("#autocomplete-already-in-collection-tmpl")
.tmpl({ collection_name: groupName })
.appendTo(li);
}
li.addClass("closet-hangers-group-autocomplete-item");
}
return li.appendTo(ul);
};
/*
Contact Neopets username form
*/
var contactEl = $("#closet-hangers-contact");
var contactForm = contactEl.children("form");
var contactField = contactForm.children("select");
var contactAddOption = $("<option/>", {
text: contactField.attr("data-new-text"),
value: -1,
});
contactAddOption.appendTo(contactField);
var currentUserId = $("meta[name=current-user-id]").attr("content");
function submitContactForm() {
var data = contactForm.serialize();
contactForm.disableForms();
$.ajax({
url: contactForm.attr("action") + ".json",
type: "post",
data: data,
dataType: "json",
beforeSend: addCSRFToken,
complete: function () {
contactForm.enableForms();
},
error: function (xhr) {
handleSaveError(xhr, "saving Neopets username");
},
});
}
contactField.change(function (e) {
if (contactField.val() < 0) {
var newUsername = $.trim(
prompt(contactField.attr("data-new-prompt"), ""),
);
if (newUsername) {
$.ajax({
url: "/user/" + currentUserId + "/neopets-connections",
type: "POST",
data: { neopets_connection: { neopets_username: newUsername } },
dataType: "json",
beforeSend: addCSRFToken,
success: function (connection) {
var newOption = $("<option/>", {
text: newUsername,
value: connection.id,
});
newOption.insertBefore(contactAddOption);
contactField.val(connection.id);
submitContactForm();
},
error: function (xhr) {
var data = JSON.parse(xhr.responseText);
var fullMessage = data.full_error_messages.join("\n");
alert("Oops, we couldn't save this username!\n\n" + fullMessage);
},
});
}
} else {
submitContactForm();
}
});
/*
Closet list droppable
*/
onHangersInit(function () {
$("div.closet-list").droppable({
accept: "div.object",
activate: function () {
$(this)
.find(".closet-list-content")
.animate({ opacity: 0, height: 100 }, 250);
},
activeClass: "droppable-active",
deactivate: function () {
$(this)
.find(".closet-list-content")
.css("height", "auto")
.animate({ opacity: 1 }, 250);
},
drop: function (e, ui) {
var form = ui.draggable.find("form.closet-hanger-update");
form
.find("input[name=closet_hanger[list_id]]")
.val(this.getAttribute("data-id"));
form
.find("input[name=closet_hanger[owned]]")
.val($(this).closest(".closet-hangers-group").attr("data-owned"));
submitUpdateForm(form);
},
});
});
/*
Visibility Descriptions
*/
function updateVisibilityDescription() {
var descriptions = $(this)
.closest(".visibility-form")
.find("ul.visibility-descriptions");
descriptions.children("li.current").removeClass("current");
descriptions
.children("li[data-id=" + $(this).val() + "]")
.addClass("current");
}
function visibilitySelects() {
return $("form.visibility-form select");
}
visibilitySelects().live("change", updateVisibilityDescription);
onHangersInit(function () {
visibilitySelects().each(updateVisibilityDescription);
});
/*
Help
*/
$("#toggle-help").click(function () {
$("#closet-hangers-help").toggleClass("hidden");
});
/*
Share URL
*/
$("#closet-hangers-share-box")
.mouseover(function () {
$(this).focus();
})
.mouseout(function () {
$(this).blur();
});
/*
Initialize
*/
hangersInit();
})();

View file

@ -1,5 +0,0 @@
document.addEventListener("change", ({ target }) => {
if (target.matches('select[name="closet_list[visibility]"]')) {
target.closest("form").setAttribute("data-list-visibility", target.value);
}
});

View file

@ -1,6 +0,0 @@
(function () {
$("span.choose-outfit select").change(function (e) {
var select = $(this);
select.closest("li").find("input[type=text]").val(select.val());
});
})();

View file

@ -1,19 +0,0 @@
function setFormStateCookie(value) {
const thirtyDays = 60 * 60 * 24 * 30;
document.cookie = `DTIItemPageUserListsFormState=${value};max-age=${thirtyDays}`;
}
document.addEventListener("click", (event) => {
if (event.target.matches(".item-header .user-lists-form-opener")) {
const header = event.target.closest(".item-header");
const form = header.querySelector(".user-lists-form");
if (form.hasAttribute("hidden")) {
form.removeAttribute("hidden");
setFormStateCookie("open");
} else {
form.setAttribute("hidden", "");
setFormStateCookie("closed");
}
event.preventDefault();
}
});

View file

@ -1,115 +0,0 @@
// 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;
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();
});
class SpeciesColorPicker extends HTMLElement {
#internals;
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");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
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 }));
}
}
}
class SpeciesFacePickerOptions extends HTMLElement {
static observedAttributes = ["inert", "aria-hidden"];
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();
}
#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");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

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

View file

@ -1,214 +0,0 @@
class OutfitViewer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals(); // for CSS `:state()`
}
connectedCallback() {
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0);
}
#connectToChildren() {
const playPauseToggle = document.querySelector(".play-pause-toggle");
// Read our initial playing state from the toggle, and subscribe to changes.
this.#setIsPlaying(playPauseToggle.checked);
playPauseToggle.addEventListener("change", () => {
this.#setIsPlaying(playPauseToggle.checked);
this.#setIsPlayingCookie(playPauseToggle.checked);
});
}
#setIsPlaying(isPlaying) {
// TODO: Listen for changes to the child list, and add `playing` when new
// nodes arrive, if playing.
const thirtyDays = 60 * 60 * 24 * 30;
if (isPlaying) {
this.#internals.states.add("playing");
for (const layer of this.querySelectorAll("outfit-layer")) {
layer.play();
}
} else {
this.#internals.states.delete("playing");
for (const layer of this.querySelectorAll("outfit-layer")) {
layer.pause();
}
}
}
#setIsPlayingCookie(isPlaying) {
const thirtyDays = 60 * 60 * 24 * 30;
const value = isPlaying ? "true" : "false";
document.cookie = `DTIOutfitViewerIsPlaying=${value};max-age=${thirtyDays}`;
}
}
class OutfitLayer extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
// An <outfit-layer> starts in the loading state, and then might very
// quickly decide it's not after `#connectToChildren`. This is to prevent a
// flash of *non*-loading state, when a new layer loads in. (e.g. In the
// time between our parent <turbo-frame> loading, which shows the loading
// spinner; and us being marked `:state(loading)`, which shows the loading
// spinner; we don't want the loading spinner to do its usual *immediate*
// total fade-out; then have to fade back in again, on the usual delay.)
this.#setStatus("loading");
}
connectedCallback() {
setTimeout(() => this.#connectToChildren(), 0);
}
disconnectedCallback() {
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
// messages, if we were.
window.removeEventListener("message", this.#onMessage);
}
play() {
this.#sendMessageToIframe({ type: "play" });
}
pause() {
this.#sendMessageToIframe({ type: "pause" });
}
#connectToChildren() {
const image = this.querySelector("img");
const iframe = this.querySelector("iframe");
if (image) {
// If this is an image layer, track its loading state by listening
// to the load/error events, and initialize based on whether it's
// already `complete` (which it can be if it loaded from cache).
this.#setStatus(image.complete ? "loaded" : "loading");
image.addEventListener("load", () => this.#setStatus("loaded"));
image.addEventListener("error", () => this.#setStatus("error"));
} else if (iframe) {
this.iframe = iframe;
// Initialize status to `loading`, and asynchronously request a
// status message from the iframe if it managed to load before this
// triggers (impressive, but I think I've seen it happen!). Then,
// wait for messages or error events from the iframe to update
// status further if needed.
this.#setStatus("loading");
this.#sendMessageToIframe({ type: "requestStatus" });
window.addEventListener("message", (m) => this.#onMessage(m));
this.iframe.addEventListener("error", () => this.#setStatus("error"));
} else {
console.warn(`<outfit-layer> contained no image or iframe: `, this);
}
}
#onMessage({ source, data }) {
// Ignore messages that aren't from *our* frame.
if (source !== this.iframe.contentWindow) {
return;
}
// Validate the incoming status message, then set our status to match.
if (data.type === "status") {
if (data.status === "loaded") {
this.#setStatus("loaded");
this.#setHasAnimations(data.hasAnimations);
} else if (data.status === "error") {
this.#setStatus("error");
} else {
throw new Error(
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
);
}
} else {
throw new Error(
`<outfit-layer> got unexpected message: ` + JSON.stringify(data),
);
}
}
/**
* Set the status value that the CSS `:state()` selector will match.
* For example, when loading, `:state(loading)` matches this element.
*/
#setStatus(newStatus) {
this.#internals.states.delete("loading");
this.#internals.states.delete("loaded");
this.#internals.states.delete("error");
this.#internals.states.add(newStatus);
}
/**
* Set whether CSS selector `:state(has-animations)` matches this element.
*/
#setHasAnimations(hasAnimations) {
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
this.#internals.states.delete("has-animations");
}
}
#sendMessageToIframe(message) {
// If we have no frame or it hasn't loaded, ignore this message.
if (this.iframe == null) {
return;
}
if (this.iframe.contentWindow == null) {
console.debug(
`Ignoring message, frame not loaded yet: `,
this.iframe,
message,
);
return;
}
// The frame is sandboxed (origin == null), so send to Any origin.
this.iframe.contentWindow.postMessage(message, "*");
}
}
customElements.define("outfit-viewer", OutfitViewer);
customElements.define("outfit-layer", OutfitLayer);
// Morph turbo-frames on this page, to reuse asset nodes when we want to—very
// important for movies!—but ensure that it *doesn't* do its usual behavior of
// aggressively reusing existing <outfit-layer> nodes for entirely different
// assets. (It's a lot clearer for managing the loading state, and not showing
// old incorrect layers!) (We also tried using `id` to enforce this… no luck.)
function morphWithOutfitLayers(currentElement, newElement) {
Idiomorph.morph(currentElement, newElement.innerHTML, {
morphStyle: "innerHTML",
callbacks: {
beforeNodeMorphed: (currentNode, newNode) => {
// If Idiomorph wants to transform an <outfit-layer> to
// have a different data-asset-id attribute, we replace
// the node ourselves and abort the morph.
if (
newNode.tagName === "OUTFIT-LAYER" &&
newNode.getAttribute("data-asset-id") !==
currentNode.getAttribute("data-asset-id")
) {
currentNode.replaceWith(newNode);
return false;
}
},
},
});
}
addEventListener("turbo:before-frame-render", (event) => {
// Rather than enforce Idiomorph must be loaded, let's just be resilient
// and only bother if we have it. (Replacing content is not *that* bad!)
if (typeof Idiomorph !== "undefined") {
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
}
});

View file

@ -1,253 +0,0 @@
(function () {
function petImage(id, size) {
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
}
var PetQuery = {},
query_string = document.location.hash || document.location.search;
for (const [key, value] of new URLSearchParams(query_string).entries()) {
PetQuery[key] = value;
}
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");
}
}
var preview_el = $("#pet-preview"),
img_el = preview_el.find("img"),
response_el = preview_el.find("span");
var defaultPreviewUrl = img_el.attr("src");
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();
}
},
};
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();
}
}
});
}
loadFeature();
Preview.Job = function (key, base) {
var job = this,
quality = 2;
job.loading = false;
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);
}
}
function load() {
job.loading = true;
img_el.attr("src", getImageSrc());
}
this.increaseQualityIfPossible = function () {
if (quality == 2) {
quality = 4;
load();
}
};
this.setAsCurrent = function () {
Preview.Job.current = job;
load();
};
this.notFound = function () {
Preview.notFound("pet-not-found");
};
};
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.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();
};
};
Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]);
this.visit = function () {
window.location =
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
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 = "/donate";
};
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();
};
};
$(function () {
var previewWithNameTimeout;
var name_el = $(".main-pet-name");
name_el.val(PetQuery.name);
Preview.updateWithName(name_el);
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);
});
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();
}
});
$(".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,
});
}
},
});
});
$(".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();
})();

View file

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

View file

@ -1,110 +0,0 @@
/* 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;
$(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";
}
return "https://pets.neopets.com/cpn/" + pet_name + "/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;
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el);
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,
});
recently_sent_count++;
setTimeout(function () {
recently_sent_count--;
loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
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();
}
};
function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load();
}
}
})();
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();
});
})();

View file

@ -1,376 +0,0 @@
const canvas = document.getElementById("asset-canvas");
const libraryScript = document.getElementById("canvas-movie-library");
const libraryUrl = libraryScript.getAttribute("src");
// Read the asset ID from the URL, as an extra hint of what asset we're
// logging for. (This is helpful when there's a lot of assets animating!)
const assetId = document.location.pathname.split("/").at(-1);
const logPrefix = `[${assetId}] `.padEnd(9);
// State for controlling the movie.
let loadingStatus = "loading";
let playingStatus = getInitialPlayingStatus();
// State for loading the movie.
let library = null;
let movieClip = null;
let stage = null;
// State for animating the movie.
let frameRequestId = null;
let lastFrameTime = null;
let lastLogTime = null;
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";
const promise = new Promise((resolve, reject) => {
image.onload = () => {
resolve(image);
};
image.onerror = () => {
reject(new Error(`Failed to load image: ${JSON.stringify(src)}`));
};
image.src = src;
});
return promise;
}
async function getLibrary() {
if (Object.keys(window.AdobeAn?.compositions || {}).length === 0) {
throw new Error(
`Movie library ${libraryUrl} did not add a composition to window.AdobeAn.compositions.`,
);
}
const [compositionId, composition] = Object.entries(
window.AdobeAn.compositions,
)[0];
if (Object.keys(window.AdobeAn.compositions).length > 1) {
console.warn(
`Grabbing composition ${compositionId}, but there are >1 here: `,
Object.keys(window.AdobeAn.compositions).length,
);
}
delete window.AdobeAn.compositions[compositionId];
const library = composition.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.
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [
id,
loadImage(librarySrcDir + "/" + src),
]),
);
await Promise.all(manifestImages.values());
// Finally, once we have the images loaded, the library object expects us to
// mutate it (!) to give it the actual image and sprite sheet objects from
// the loaded images. That's how the MovieClip's internal JS objects will
// access the loaded data!
const images = composition.getImages();
for (const [id, image] of manifestImages.entries()) {
images[id] = await image;
}
const spriteSheets = composition.getSpriteSheet();
for (const { name, frames } of library.ssMetadata) {
const image = await manifestImages.get(name);
spriteSheets[name] = new window.createjs.SpriteSheet({
images: [image],
frames,
});
}
return library;
}
/////////////////////////////////////
//////// Rendering the movie ////////
/////////////////////////////////////
function buildMovieClip(library) {
let constructorName;
try {
const fileName = decodeURI(libraryUrl).split("/").pop();
const fileNameWithoutExtension = fileName.split(".")[0];
constructorName = fileNameWithoutExtension.replace(/[ -]/g, "");
if (constructorName.match(/^[0-9]/)) {
constructorName = "_" + constructorName;
}
} catch (e) {
throw new Error(
`Movie libraryUrl ${JSON.stringify(libraryUrl)} did not match expected ` +
`format: ${e.message}`,
);
}
const LibraryMovieClipConstructor = library[constructorName];
if (!LibraryMovieClipConstructor) {
throw new Error(
`Expected JS movie library ${libraryUrl} to contain a constructor ` +
`named ${constructorName}, but it did not: ${Object.keys(library)}`,
);
}
const movieClip = new LibraryMovieClipConstructor();
return movieClip;
}
function updateStage() {
try {
stage.update();
} catch (e) {
// If rendering the frame fails, log it and proceed. If it's an
// animation, then maybe the next frame will work? Also alert the user,
// just as an FYI. (This is pretty uncommon, so I'm not worried about
// being noisy!)
if (!hasLoggedRenderError) {
console.error(`Error rendering movie clip ${libraryUrl}`, e);
// TODO: Inform user about the failure
hasLoggedRenderError = true;
}
}
}
function updateCanvasDimensions() {
// Set the canvas's internal dimensions to be higher, if the device has high
// 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;
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() {
// 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 window.createjs.Stage(canvas);
stage.addChild(movieClip);
updateStage();
loadingStatus = "loaded";
canvas.setAttribute("data-status", "loaded");
updateAnimationState();
}
function updateAnimationState() {
const shouldRunAnimations =
loadingStatus === "loaded" && playingStatus === "playing";
if (shouldRunAnimations && frameRequestId == null) {
lastFrameTime = document.timeline.currentTime;
lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0;
documentHiddenSinceLastFrame = document.hidden;
frameRequestId = requestAnimationFrame(onAnimationFrame);
} else if (!shouldRunAnimations && frameRequestId != null) {
cancelAnimationFrame(frameRequestId);
lastFrameTime = null;
lastLogTime = null;
numFramesSinceLastLog = 0;
documentHiddenSinceLastFrame = false;
frameRequestId = null;
}
}
function onAnimationFrame() {
const targetFps = library.properties.fps;
const msPerFrame = 1000 / targetFps;
const msSinceLastFrame = document.timeline.currentTime - lastFrameTime;
const msSinceLastLog = document.timeline.currentTime - lastLogTime;
// If it takes too long to render a frame, cancel the movie, on the
// assumption that we're riding the CPU too hard. (Some movies do this!)
//
// But note that, if the page is hidden (e.g. the window is not visible),
// it's normal for the browser to pause animations. So, if we detected that
// the document became hidden between this frame and the last, no
// intervention is necesary.
if (msSinceLastFrame >= 2000 && !documentHiddenSinceLastFrame) {
pause();
console.warn(`Paused movie for taking too long: ${msSinceLastFrame}ms`);
// TODO: Display message about low FPS, and sync up to the parent.
return;
}
if (msSinceLastFrame >= msPerFrame) {
updateStage();
lastFrameTime = document.timeline.currentTime;
// If we're a little bit late to this frame, probably because the frame
// rate isn't an even divisor of 60 FPS, backdate it to what the ideal time
// for this frame *would* have been. (For example, without this tweak, a
// 24 FPS animation like the Floating Negg Faerie actually runs at 20 FPS,
// because it wants to run every 41.66ms, but a 60 FPS browser checks in
// every 16.66ms, so the best it can do is 50ms. With this tweak, we can
// *pretend* we ran at 41.66ms, so that the next frame timing correctly
// takes the extra 9.33ms into account.)
const msFrameDelay = msSinceLastFrame - msPerFrame;
if (msFrameDelay < msPerFrame) {
lastFrameTime -= msFrameDelay;
}
numFramesSinceLastLog++;
}
if (msSinceLastLog >= 5000) {
const fps = numFramesSinceLastLog / (msSinceLastLog / 1000);
console.debug(`${logPrefix} FPS: ${fps.toFixed(2)} (Target: ${targetFps})`);
lastLogTime = document.timeline.currentTime;
numFramesSinceLastLog = 0;
}
frameRequestId = requestAnimationFrame(onAnimationFrame);
documentHiddenSinceLastFrame = document.hidden;
}
// If `document.hidden` becomes true at any point, log it for the next
// animation frame. (The next frame will reset the state, as will starting or
// stopping the animation.)
document.addEventListener("visibilitychange", () => {
if (document.hidden) {
documentHiddenSinceLastFrame = true;
}
});
function play() {
playingStatus = "playing";
updateAnimationState();
}
function pause() {
playingStatus = "paused";
updateAnimationState();
}
function getInitialPlayingStatus() {
const params = new URLSearchParams(document.location.search);
if (params.has("playing")) {
return "playing";
} else {
return "paused";
}
}
//////////////////////////////////////////
//// Syncing with the parent document ////
//////////////////////////////////////////
/**
* Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas.
*/
function hasAnimations(createjsNode) {
return (
// Some nodes have simple animation frames.
createjsNode.totalFrames > 1 ||
// Tweens are a form of animation that can happen separately from frames.
// They expect timer ticks to happen, and they change the scene accordingly.
createjsNode?.timeline?.tweens?.length >= 1 ||
// And some nodes have _children_ that are animated.
(createjsNode.children || []).some(hasAnimations)
);
}
function sendStatus() {
if (loadingStatus === "loading") {
sendMessage({ type: "status", status: "loading" });
} else if (loadingStatus === "loaded") {
sendMessage({
type: "status",
status: "loaded",
hasAnimations: hasAnimations(movieClip),
});
} else if (loadingStatus === "error") {
sendMessage({ type: "status", status: "error" });
} else {
throw new Error(
`unexpected loadingStatus ${JSON.stringify(loadingStatus)}`,
);
}
}
function sendMessage(message) {
parent.postMessage(message, document.location.origin);
}
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
// okay with whatever site is embedding us being able to send play/pause!
if (data.type === "play") {
play();
} else if (data.type === "pause") {
pause();
} else if (data.type === "requestStatus") {
sendStatus();
} else {
throw new Error(`unexpected message: ${JSON.stringify(data)}`);
}
});
/////////////////////////////////
//// The actual entry point! ////
/////////////////////////////////
startMovie()
.then(() => {
sendStatus();
})
.catch((error) => {
console.error(logPrefix, error);
loadingStatus = "error";
sendStatus();
// If loading the movie fails, show the fallback image instead, by moving
// it out of the canvas content and into the body.
document.body.appendChild(document.getElementById("fallback"));
console.warn("Showing fallback image instead.");
});

View file

@ -1,254 +0,0 @@
@import "partials/icon"
@import "partials/clean/constants"
@import "partials/clean/mixins"
/* Reset
html, body, div, span, applet, object, iframe, h1, h2, h3, h4, h5, h6, p,
blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em,
font, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, b,
u, i, center, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table,
caption, tbody, tfoot, thead, tr, th, td
margin: 0
padding: 0
border: 0
font-size: 100%
vertical-align: baseline
background: transparent
/* Typography
html, body
height: 100%
body
background: $background-color
color: $text-color
font:
family: $main-font
size: 90%
line-height: 1.5
a[href]
color: $link-color
input, button, select
font:
family: inherit
size: 100%
p
margin-bottom: 1em
h1, h2, h3
+header-text
h1
font-size: 3em
line-height: 1
margin-bottom: 0.50em
h2
font-size: 2em
margin-bottom: 0.75em
h3
font-size: 1.5em
line-height: 1
margin-bottom: 1.00em
.inline-image
margin-right: 1em
vertical-align: middle
/* Main
$container_width: 800px
#container
margin: .25em auto
padding-top: $container-top-padding
position: relative
width: $container_width
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
border-radius: 3px
background: #fff
border: 1px solid $input-border-color
color: $text-color + #444444
padding: .25em
&:focus, &:active
color: inherit
select:has(option[value='']:checked)
color: #666
option[value='']
color: #666
option:not([value=''])
color: $text-color
textarea
font: inherit
// TODO: This conflicts with button styles in embedded wardrobe-2020
// components. It'd be nice to not apply it to ALL button elements.
a.button, input[type=submit], button
+awesome-button
&.loud
+loud-awesome-button
.icon
margin-right: .25em
vertical-align: middle
ul.buttons
margin-bottom: 1em
li
list-style: none
margin: 0 .5em
&, form
display: inline
#footer
clear: both
font-size: 75%
margin-bottom: 1em
padding-top: 2em
text-align: center
ul, div
display: inline
margin: 0 1em
li, div ul
display: inline
margin: 0 .5em
#locale-form
float: right
.terms
&[data-updated-recently]
font-weight: bold
=flash
margin-bottom: 1em
padding: .25em .5em
text-align: center
.notice, .alert, .warning
+flash
.notice
+notice
.alert
+error
.warning
+warning
#userbar
+header-text
position: absolute
right: 0
top: 0
> *
display: inline
margin: 0 .25em
#userbar-image-mode
font-weight: bold
margin-right: 1em
text-decoration: none
img
+icon
#userbar-log-in
text-decoration: none
img
margin:
bottom: -4px
right: .25em
span
text-decoration: underline
&:hover span
text-decoration: none
.object
+inline-block
margin: $object-padding 0
padding: 0 $object-padding
position: relative
text-align: center
vertical-align: top
width: $object-width
a
text-decoration: none
img
+opacity(0.75)
img
display: block
height: $object-img-size
margin: 0 auto
width: $object-img-size
&:hover img, a:hover img
// behave in browsers that only respond to a:hover, but also be in the
// hover state more often for browsers who support div:hover
// (quantity form in user items)
+opacity(1)
.nc-icon, .closeted-icons
+opacity(1)
background: rgba(255, 255, 255, 0.75)
line-height: 1
position: absolute
top: $object-img-size - $nc-icon-size
&:hover
+opacity(0.5)
background: transparent
.nc-icon, .closeted-icons img
display: inline
height: $nc-icon-size
width: $nc-icon-size
.nc-icon
right: ($object-width - $object-img-size) / 2 + $object-padding
$closeted-icons-left: ($object-width - $object-img-size) / 2 + $object-padding
.closeted-icons
left: $closeted-icons-left
dt
font-weight: bold
dd
margin: 0 0 1.5em 1em
#home-link
+header-text
font:
size: 175%
weight: bold
left: 0
line-height: 1
padding-left: .25em
padding-right: .25em
position: absolute
top: 0
&:hover
background: $module-bg-color
text-decoration: none
span:before
content: "<< "
#home-link, #userbar
padding-top: 6px
padding-bottom: 6px
.pagination
a, span
margin: 0 .5em
.current
font-weight: bold

View file

@ -1,22 +0,0 @@
body.use-responsive-design
#container
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
#userbar
margin-left: auto
text-align: right

View file

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

View file

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

View file

@ -1,18 +0,0 @@
@charset "UTF-8"
@import partials/clean/constants
@import partials/clean/mixins
@import layout
@import responsive
@import partials/jquery.jgrowl
@import closet_hangers/index
@import closet_lists/form
@import neopets_page_import_tasks/new
@import contributions/index
@import outfits/index
@import outfits/new
@import pets/bulk
@import users/top_contributors

View file

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

View file

@ -1,64 +0,0 @@
.hanger-spinner {
height: 32px;
width: 32px;
@media (prefers-reduced-motion: no-preference) {
animation: 1.2s infinite hanger-spinner-swing;
transform-origin: top center;
}
@media (prefers-reduced-motion: reduce) {
animation: 1.6s infinite hanger-spinner-fade-pulse;
}
}
/*
Adapted from animate.css "swing". We spend 75% of the time swinging,
then 25% of the time pausing before the next loop.
We use this animation for folks who are okay with dizzy-ish motion.
For reduced motion, we use a pulse-fade instead.
*/
@keyframes hanger-spinner-swing {
15% {
transform: rotate3d(0, 0, 1, 15deg);
}
30% {
transform: rotate3d(0, 0, 1, -10deg);
}
45% {
transform: rotate3d(0, 0, 1, 5deg);
}
60% {
transform: rotate3d(0, 0, 1, -5deg);
}
75% {
transform: rotate3d(0, 0, 1, 0deg);
}
100% {
transform: rotate3d(0, 0, 1, 0deg);
}
}
/*
A homebrew fade-pulse animation. We use this for folks who don't
like motion. It's an important accessibility thing!
*/
@keyframes hanger-spinner-fade-pulse {
0% {
opacity: 0.2;
}
50% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,55 +0,0 @@
@import "../partials/clean/constants"
.settings-form
border: 1px solid $module-border-color
background: $module-bg-color
border-radius: 1em
padding: 1em 1.25em
&:not(:last-of-type)
margin-bottom: 2em
h2
font-size: 1.5rem
margin-bottom: .25em
.hint
font-style: italic
font-size: .85em
opacity: .9
fieldset
padding-block: .5em
fieldset:not(:last-of-type)
border-bottom: 1px solid $module-border-color
margin-bottom: .5em
.field
margin-bottom: 1em
.field_with_errors
display: inline
label
font-weight: bold
.error-explanation
color: $error-color
background: $error-bg-color
border: 1px solid $error-border-color
border-radius: .5em
padding: .5em
margin-bottom: .5em
header
font-weight: bold
ul
padding-left: 2em
.neopass-info
margin-bottom: .5em
.neopass-explanation
font-size: .85em

View file

@ -1,474 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/campaign-progress"
@import "../partials/context_button"
@import "../partials/icon"
@import "../partials/secondary_nav"
body.closet_hangers-index
+campaign-progress
+secondary-nav
#title
margin-bottom: 0
#import-link
+awesome-button
+loud-awesome-button-color
#closet-hangers-items-search
float: right
input[name=q]
&.loading
background:
image: image-url("loading.gif")
position: 2px center
repeat: no-repeat
padding-left: $icon-width + 4px
#closet-hangers-contact
clear: both
color: $soft-text-color
margin-bottom: 1em
margin-left: 2em
min-height: $icon-height
display: flex
gap: .5em
align-items: center
a
color: inherit
text-decoration: none
&:hover
text-decoration: underline
a, > form
background:
position: left center
repeat: no-repeat
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
label
font-weight: bold
margin-right: .5em
&:after
content: ":"
#toggle-help, #toggle-compare
+awesome-button
cursor: pointer
display: none
#closet-hangers-help.hidden
display: none
#closet-hangers-extras
font-size: 85%
margin:
bottom: 2em
top: 2em
text-align: center
a
+awesome-button
margin: 0 0.5em
#closet-hangers-share
margin-bottom: 1em
label
font-weight: bold
margin-right: .5em
input
width: 30em
.bulk-actions
display: none
#closet-hangers
clear: both
text-align: center
border-top: 1px solid $module-border-color
.object
.quantity
+opacity(.75)
background: white
padding: 6px 4px 4px
position: absolute
left: ($object-width - $object-img-size) / 2 + $object-padding
line-height: 1
text-align: left
top: 0
span, input[type=number]
font-size: 16px
font-weight: bold
form
display: none
&[data-quantity="1"]
.quantity
display: none
a
/* It's jarring to have line-height gaps in the linkiness. */
display: block
&:hover
text-decoration: underline
.object
margin: 0
label
display: block
input[type=checkbox]
display: none
position: absolute
top: 0
right: ($object-width - $object-img-size) / 2 + $object-padding
height: 16px
width: 16px
&:checked
display: block
& + label
background: $module-bg-color
outline: 1px solid $module-border-color
.closet-hangers-group
border-top: 1px solid $module-border-color
margin-bottom: 2em
padding-bottom: 1em
&:first-of-type
border-top: 0
> header
border-bottom: 1px solid $soft-border-color
display: block
margin-bottom: .25em
padding: .25em 0
position: relative
h3
font-size: 250%
margin: 0
.add-closet-list
+awesome-button
bottom: 50%
margin-bottom: -1em
position: absolute
right: 1em
&:active
margin-bottom: -1.1em
top: auto
span.show, span.hide
color: $soft-text-color
display: none
font-size: 85%
left: 1em
position: absolute
top: 1em
&:hover
color: inherit
text-decoration: underline
.closet-list
border-bottom: 1px solid $soft-border-color
padding: .5em 0
position: relative
.visibility-form
font-size: 85%
left: .5em
position: absolute
text-align: left
top: .25em
z-index: 10
input, select
font-size: inherit
margin:
bottom: 0
top: 0
select
border-color: $background-color
input[type=submit]
+context-button
font-size: inherit
visibility: hidden
&:active
top: 1px
.visibility-descriptions
+opacity(.75)
background: $background-color
font-style: italic
list-style: none
padding: 0 .5em
li
display: none
&:hover
.visibility-descriptions li.current
display: block
header
display: block
position: relative
h4
+header-text
font-size: 150%
line-height: 1
margin: 0 auto .67em
width: 50%
.empty-list
display: none
font-style: italic
.closet-list-controls
display: none
position: absolute
right: 1em
top: 0
a, input[type=submit], button
+context-button
form
display: inline
&[data-hangers-count="0"]
.empty-list
display: block
&.unlisted
h4
font:
size: 125%
style: italic
&:hover
.closet-list-controls
display: block
.visibility-form
input[type=submit]
visibility: visible
select
border-color: $soft-border-color
&:last-child
border-bottom: 0
&.droppable-active
border-radius: 1em
+module
border-bottom-width: 1px
border-style: dotted
margin: 1em 0
.object
// totally hiding these elements causes the original element to change
// position, throwing off the drag
+opacity(.25)
&.ui-draggable-dragging
+opacity(1)
.closet-list-controls
display: none
.closet-list-hangers
overflow: hidden
.visibility-form
display: none
.closet-hangers-group-autocomplete-item, .closet-list-autocomplete-item
span
+opacity(.5)
font-style: italic
padding: .2em .4em
.closet-list-autocomplete-item
a, span
font-size: 85%
padding-left: 2em
.closet-hangers-group
&[data-owned=true] .user-wants, &[data-owned=false] .user-owns
background: $module-bg-color
font-weight: bold
&.current-user
#closet-hangers
.object:hover
form
display: inline
.closet-hanger-destroy
position: absolute
right: ($object-width - $object-img-size) / 2 + $object-padding
top: $object-img-size - 28px
input
+context-button
.quantity
+opacity(1)
background: transparent
top: 0
padding: 0
span
display: none
input[type=number]
padding: 2px
width: 2em
input[type=submit]
font-size: 85%
input[type=checkbox]
display: block
&.js
#closet-hangers
.object:hover .quantity
display: block
input[type=number]
width: 2.5em
input[type=submit]
display: none
.object.loading
background: $module-bg-color
outline: 1px solid $module-border-color
.quantity
display: block
span:after
content: ""
.bulk-actions
background: $background-color
box-sizing: border-box
display: block
font-size: 85%
padding: .5em 1em
text-align: center
width: 800px
position: sticky
top: 0
border-bottom: 1px solid $module-border-color
z-index: 11 /* beat the visibility form */
.bulk-actions-intro, .bulk-actions-target-desc-singular
display: none
.bulk-actions-target-desc
display: inline-block
.bulk-actions-options
display: inline-block
list-style: none
> li
display: inline-block
margin-left: .75em
form
display: inline-block
&:not(:first-child)::before
content: " or "
display: inline-block
margin-right: .75em
&[data-target-count="0"]
.bulk-actions-intro
display: block
.bulk-actions-form
display: none
&[data-target-count="1"]
.bulk-actions-target-desc-singular
display: inline
.bulk-actions-target-desc-plural
display: none
#closet-hangers-contact
input[type=submit]
display: none
.closet-hangers-group
header
.show, .hide
cursor: pointer
.hide
display: block
&.hidden
header .hide, .closet-hangers-group-content
display: none
header .show
display: block
#toggle-help
display: inline-block
.remove-all
display: none
.select-all
display: inline-block
&.js
#toggle-compare
display: inline-block
#closet-hangers.comparing
.object
display: none
.closet-hangers-group
&[data-owned=true] .user-wants, &[data-owned=false] .user-owns
display: inline-block

View file

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

View file

@ -1,45 +0,0 @@
@import "../partials/secondary_nav"
@import "../partials/clean/mixins"
body.closet_lists-new, body.closet_lists-create, body.closet_lists-edit, body.closet_lists-update
+secondary-nav
form ul.fields
clear: both
list-style: none
label
float: left
font-weight: bold
margin-right: 1em
li
padding: 0.75em 0
width: 35em
input, textarea, select
clear: both
display: block
margin-top: .25em
width: 80%
textarea
height: 12em
.hint
display: block
font:
size: 85%
.trade-warning
+warning
margin-bottom: 1em
padding: .75em .5em
text-align: center
p:last-of-type
margin-bottom: 0
// Only show the trade warning when the list is marked as Trading!
form:not([data-list-visibility="2"]) .trade-warning
display: none

View file

@ -1,39 +0,0 @@
@import "../partials/clean/mixins"
body.contributions-index
text-align: center
.contributions
li
list-style: none
height: 80px
overflow: hidden
padding: 1em 0 0 100px
position: relative
text-align: left
.point-value
+header-text
color: #fff
font-size: 80px
left: 0
line-height: 1
position: absolute
text-align: center
text-shadow: 2px 2px 0 #000
top: 0
width: 80px
z-index: 3
&:hover
+opacity(0.5)
img
height: 80px
left: 0
position: absolute
top: 0
width: 80px
z-index: 2
.username, .contributed-name
font-weight: bold
.time-ago
display: block
font-size: 75%

View file

@ -1,83 +0,0 @@
@import "../../partials/clean/constants"
#title
text-align: center
font-size: 2.5rem
.login-options
display: flex
margin-block: 3em
section
flex: 1 1 0
padding-block: 1em
display: flex
flex-direction: column
justify-content: center
align-items: center
&:not(:last-of-type)
border-right: 1px solid $module-border-color
margin-right: 1em
padding-right: 1em
.login-option-neopass
text-align: center
header
font-weight: bold
font-size: 125%
margin-bottom: .5em
.neopass-explanation
font-size: 85%
summary
cursor: pointer
margin-bottom: 1em
.login-form
margin-bottom: 1em
.field
margin-bottom: .75em
.input-field label
font-weight: bold
.actions
display: flex
align-items: center
gap: .5em
.remember-me
display: flex
align-items: center
gap: .25em
font-size: 85%
input[type=checkbox]
height: 1em
width: 1em
.login-links
font-size: 85%
display: flex
gap: .5em
.log-in-with-neopass-button
background: linear-gradient(#ebb233, #f6e250, #ebb233)
color: #111
font-size: 1rem
text-shadow: none
font-family: "Arial Black", sans-serif
margin-bottom: 1em
&:hover
color: #111 // override default DTI styles
filter: brightness(105%)
.neopass-icon
vertical-align: middle
height: 2em
width: 2em

View file

@ -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" %>");
}

View file

@ -1,227 +0,0 @@
@import "../../partials/clean/constants"
@import "../../partials/campaign-progress"
@import "../../partials/outfit"
/* TODO: redundant with outfits/index; why is it not in the partial? */
$outfit-inner-height: 150px
$outfit-inner-width: 150px
$outfit-banner-h-padding: 4px
$outfit-banner-v-padding: 2px
$outfit-banner-inner-width: $outfit-inner-width - (2 * $outfit-banner-h-padding)
body
+campaign-progress
color: $campaign-text-color
a
color: $campaign-text-color + #222 !important
#home-link:hover
background-color: $campaign-background-color
#userbar, #footer
color: $text-color
a
color: $link-color
#home-link
color: $link-color
#title
display: none
#donation-form
+module
background: $campaign-background-color
border-color: $campaign-border-color
display: flex
flex-direction: row
margin-top: 1em
margin-bottom: 1.5em
padding-bottom: 32px
padding-left: 24px
padding-right: 24px
padding-top: 32px
position: relative
// We ignore the theme attribute on campaigns now, and just do purple.
&::after
background:
image: url(image_path("fundraising/purple.png"))
repeat: no-repeat
bottom: 0
content: " "
height: 123px
position: absolute
right: 4px
width: 150px
header, #donation-fields
flex: 1
#donation-form-title
font-size: 125%
font-weight: bold
margin-bottom: .25em
margin-top: 0
p
font-family: $main-font
font-size: 85%
margin-bottom: 0
margin-top: .5em
#donation-fields
margin-left: 20px
padding-top: 7px
#amount-header
font-size: 85%
font-weight: bold
line-height: 1
#amount-choices
$amount-choices-border-color: desaturate(lighten($campaign-border-color, 30%), 30%)
display: flex
flex-direction: row
margin-bottom: .75em
margin-top: .5em
li
border: 1px solid $amount-choices-border-color
border-radius: 5px
display: block
flex: 1
list-style: none
overflow: hidden
text-align: center
&:not(:last-of-type)
margin-right: .75em
input[type=radio]
height: 0
margin: 0
padding: 0
opacity: 0
position: absolute
width: 0
label
border: 1px solid transparent
box-sizing: border-box
display: block
padding: .5em .5em
width: 100%
input[type=radio]:checked ~ label
background: lighten($amount-choices-border-color, 15%)
font-weight: bold
input[type=radio]:focus ~ label
border-color: white
border-radius: 5px
#amount-custom-fields
display: none
input[type=text]
font-family: inherit
font-size: inherit
line-height: 1
padding: 0
text-align: center
#amount-custom:checked ~ #amount-custom-fields
display: block
#amount-custom:checked ~ label[for=amount-custom]
display: none
input[type=text]
border-color: #cce
color: $campaign-text-color
width: 3em
#donation-controls
button
+awesome-button-color(#004)
font-size: 120%
#campaign-text[data-campaign-complete]
#description
display: none
&[data-show]
display: block
#success-thanks
border: 1px dashed $module-border-color
margin-bottom: 1em
padding: 1em
position: relative
p:last-child
margin-bottom: 0
#success-thanks-toggle-description
position: absolute
bottom: 1em
font-style: italic
right: 1em
#outfits
+outfits-list
text-align: center
> li
+outfit
height: $outfit-inner-height
margin: 2px
width: $outfit-inner-width
header, footer
font-size: 85%
padding: $outfit-banner-v-padding $outfit-banner-h-padding
width: $outfit-banner-inner-width
img
height: $outfit-inner-height
width: $outfit-inner-width
&.banner
background-image: url(https://images.neopets.com/themes/004_bir_a2e60/footer_bg.png)
background-position: 0 -60px
border: 2px solid #006
color: white
height: 100px
line-height: 100px
margin: 4px 0
text-shadow: #335 2px 2px 1px
width: 800px - 4px
span
+inline-block
font-size: 32px
font-weight: bold
line-height: 1.5
vertical-align: middle
#last-years-donors
font-weight: bold
margin-top: 1em
text-align: center
#outfits-header > *
display: inline-block
#all-campaigns-list
li
display: inline-block
list-style: none
margin-left: 1em
#fine-print
font-size: 85%
margin-top: 2em

View file

@ -1,28 +0,0 @@
@import "../../partials/clean/constants"
@import "../../partials/clean/mixins"
#thank-you
border: 3px solid $module-border-color
display: block
margin: 0 auto 1em
#edit-donation
ul
list-style: none
label
+inline-block
width: 16em
.name input[type=text]
width: 10em
.feature input[type=text]
width: 23em
.choose-outfit
font-size: 85%
margin-left: 1em
select
width: 10em

View file

@ -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: ", "

View file

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

View file

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

View file

@ -1,139 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
.item-list
border-collapse: collapse
border: 1px solid $soft-border-color
width: 60%
table-layout: auto
margin-bottom: 2em
td, th
border-top: 1px solid $soft-border-color
border-bottom: 1px solid $soft-border-color
padding: .25em
vertical-align: middle
&:first-child
padding-left: .5em
&:last-child
padding-right: .5em
.thumbnail-cell
width: 2.5em
height: 2.5em
img
display: block
width: 2.5em
height: 2.5em
.name-cell a
text-decoration: none
&:hover, &:focus
text-decoration: underline
.actions-cell
text-align: right
padding-left: 1em
font-size: 85%
a, button
/* When item names get long, don't let the buttons wrap to give the
* item names more space. The names should wrap more instead! */
text-wrap: nowrap
margin: .25em
tbody
tr
&:hover, &:focus-within
background: rgba($module-bg-color, 0.5)
thead
background: $module-bg-color
th
text-align: left
.name-cell
text-wrap: nowrap
.thumbnail-cell img
outline: 1px solid $soft-border-color
tr[data-item-owned]
color: #aaa
a:not(.button)
color: inherit
.thumbnail-cell
filter: grayscale(1)
opacity: .75
.item-name
font-style: italic
text-decoration: line-through
text-decoration-color: rgba($text-color, 0.35)
.owned-explanation
font-style: italic
font-size: 85%
.actions-cell
button, a.button
+awesome-button-color(#999)
.price-breakdown
text-decoration-line: underline
text-decoration-style: dotted
cursor: help
.dyeworks-timeframe
font-style: italic
text-decoration-line: underline
text-decoration-style: dotted
cursor: help
.special-color-explanation
text-wrap: balance
font-style: italic
.subtitle
font-size: 85%
opacity: .85
a
color: inherit
.nc-trade-guide-info-link
cursor: help
.nc-trade-guide-info-label
text-decoration-line: underline
text-decoration-style: dotted
.actions-cell
button, a.button
&[data-action-kind=bulk-nc-mall]
/* Bootstrap's Purple 600 */
+awesome-button-color(#59359a)
&[data-complexity="high"]
width: 70%
/* For wearable items that belong to a specific set that all come together,
* like a Paint Brush. */
&[data-group-type="bundle"]
tbody
.thumbnail-cell
opacity: 0.65
tr:hover .thumbnail-cell
opacity: 0.85
&[data-group-owned]
thead
button, a.button
+awesome-button-color(#999)

View file

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

View file

@ -1,60 +0,0 @@
@import "../partials/clean/mixins"
body.neopets_page_import_tasks-new, body.neopets_page_import_tasks-create
#title
float: left
.flash
clear: both
#back-to-items
+awesome-button
margin:
left: 1em
top: .75em
#closet-page-form
+clearfix
clear: both
margin-bottom: 1em
text-align: right
> *
text-align: left
#closet-page-frame-wrapper
float: left
margin-right: 2%
width: 48%
#closet-page-frame
height: 19em
width: 100%
#closet-page-source
float: left
width: 50%
label
font-weight: bold
textarea
height: 19em
ol
padding-left: 1em
> li
margin-bottom: 1em
ul
font-size: 85%
margin:
bottom: 1em
top: 0
padding-left: 1em
p
margin: 0

View file

@ -1,55 +0,0 @@
@import "partials/outfit"
@import star
$outfit-inner-height: 150px
$outfit-inner-width: 150px
$outfit-banner-h-padding: 4px
$outfit-banner-v-padding: 2px
$outfit-banner-inner-width: $outfit-inner-width - (2 * $outfit-banner-h-padding)
body.outfits-index
#outfits
+outfits-list
> li
height: $outfit-inner-height
margin: 2px
width: $outfit-inner-width
header, footer
padding: $outfit-banner-v-padding $outfit-banner-h-padding
width: $outfit-banner-inner-width
footer
display: none
.outfit-edit-link
float: left
text-decoration: none
form
float: right
.outfit-delete-button
margin: 0
padding: 0
.outfit-edit-link, .outfit-delete-button
&:hover
text-decoration: underline
.outfit-star
cursor: auto
.outfit-name
text-decoration: none
&:hover
text-decoration: underline
&:hover
footer
display: block
.outfit-delete-button
+reset-awesome-button

View file

@ -1,299 +0,0 @@
@import "partials/clean/constants"
@import "partials/campaign-progress"
body.outfits-new
+campaign-progress
#pet-not-found
display: none
.announcement
border: 1px solid $module-border-color
padding: .5em
display: grid
grid-template-areas: "thumbnail content"
grid-template-columns: auto 1fr
column-gap: .5em
margin-bottom: 1em
p
font-family: $main-font
margin-bottom: .5em
p:last-of-type
margin-bottom: 0
#outfit-forms
+clearfix
+module
position: relative
h1
margin-bottom: 0
h2
font:
size: 150%
style: italic
text-indent: 1em
#pet-preview
float: left
height: 300px
margin-right: 2em
position: relative
width: 300px
img
height: 100%
width: 100%
&.loading img
+opacity(0.5)
&.hidden img
display: none
&.loaded
cursor: pointer
span
background: rgb(128, 128, 128)
background: rgba(0, 0, 0, 0.5)
bottom: 0
color: #fff
padding: .25em .5em
position: absolute
right: 0
&:empty
display: none
fieldset
position: relative
left: 16px
legend
margin-left: -16px
.primary
margin:
bottom: 2em
top: 3em
input
font-size: 115%
padding: .5em
width: 10em
button
+loud-awesome-button
legend
font-size: 175%
select
font-size: 120%
#sections
display: grid
grid-template-columns: 1fr 1fr 1fr
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
div
grid-area: info
color: $soft-text-color
font-size: 75%
margin-left: 1em
z-index: 2
strong
font-size: 116%
a:has(img)
grid-area: image
img
opacity: 0.75
float: right
margin-left: .5em
&:hover
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
h3
font-size: 125%
font-style: italic
margin-bottom: .5em
#modeling-neopets-users
float: right
font-size: 85%
margin-top: -5px
max-width: 55%
text-align: right
ul
display: inline
li, form
display: inline-block
li
border-radius: 6px
border: 1px solid $soft-border-color
line-height: 1
margin: 0 4px
padding: 2px 4px 2px 8px
button
+reset-awesome-button
margin-left: 6px
#newest-unmodeled-items
clear: both
list-style: none
> li
+clearfix
margin: .5em 0
a.header
background: $module-bg-color
border: 1px solid $module-border-color
border-left: 0
border-radius: 0 6px 6px 0
color: white
display: block
margin-left: 81px
padding: .5em 8px
position: relative
text-decoration: none
text-shadow: $text-color 1px 1px 2px
a.header:hover, .image-link:hover + a.header
text-decoration: underline
h2
font-family: $main-font
font-size: 120%
margin: 0
position: relative
z-index: 2
.meter
background: desaturate(lighten($module-border-color, 25%), 60%)
display: block
height: 100%
left: 0
position: absolute
top: 0
z-index: 1
.image-link
float: left
img
border: 1px solid $module-border-color
border-radius: 6px 0 6px 6px
height: 80px
width: 80px
.missing-bodies, .models
margin-left: 82px
padding-left: 8px
padding-right: 8px
.missing-bodies
font-size: 85%
padding-bottom: .5em
padding-top: .5em
p
font-family: $main-font
margin-bottom: .5em
.modeled
color: $soft-text-color
text-decoration: line-through
.models
font-size: 85%
li
display: inline-block
margin-bottom: 2px
margin-right: 2px
button
+reset-awesome-button
border-radius: 4px
box-shadow: 0 1px 3px rgba(0, 0, 0, .5)
background: $module-bg-color
border: 1px solid $module-border-color
padding-right: 8px
text-align: left
&:active
position: relative
top: 1px
img, div
display: inline-block
vertical-align: top
img
height: 40px
margin-right: 8px
width: 40px
div
line-height: 1.25
padding-top: 10px
.pet-name, .message
display: block
.message
font-style: italic
font-size: 85%
#newest-modeled-items
text-align: center
.object
margin: 2px
padding: 0
width: 80px
.name
display: none
img
margin: 0
.nc-icon
right: 0
#latest-contribution
+subtle-banner
#recent-contributions-link
font-weight: bold
margin-right: .5em
&::after
content: ":"
#latest-contribution-created-at
color: $soft-text-color
margin-left: .5em

View file

@ -1,25 +0,0 @@
@import "partials/icon"
=outfit-star
.outfit-star
+icon
background:
image: image-url("unstarred.png")
position: left top
repeat: no-repeat
cursor: pointer
display: block
float: left
margin-right: $icon-width / 2
&.starred .outfit-star
background-image: image-url("star.png")
&.loading .outfit-star
background-image: image-url("loading.gif")
=outfit-star-shifted
+outfit-star
.outfit-star
margin-left: -$icon-width * 1.5
margin-right: 0

View file

@ -1,21 +0,0 @@
@import "clean/constants"
@import "clean/mixins"
=assets-list
li
border-radius: .5em
+inline-block
border: 1px solid $soft-border-color
margin: .5em
padding: .5em
text-align: center
vertical-align: top
width: 150px
img, span, input
display: block
width: 100%
img
height: 150px
width: 150px

View file

@ -1,62 +0,0 @@
@import "clean/mixins"
// 2015 campaign blue
// $campaign-border-color: #006
// $campaign-background-color: #eef
// $campaign-text-color: #004
// $campaign-link-color: $campaign-text-color + #222
$campaign-border-color: hsl(310, 60%, 40%)
$campaign-background-color: hsl(310, 60%, 97%)
$campaign-text-color: hsl(310, 60%, 20%)
$campaign-link-color: $campaign-text-color + #222
=campaign-progress
.campaign-progress-wrapper
border-radius: 8px
background: desaturate(lighten($campaign-border-color, 30%), 30%)
background-image: linear-gradient(color-stops(desaturate(lighten($campaign-border-color, 50%), 30%), desaturate(lighten($campaign-border-color, 30%), 30%)))
border: 4px solid $campaign-border-color
clear: both
margin-bottom: 1em
margin-top: .5em
position: relative
.button
+awesome-button
+awesome-button-color(#004)
font-size: 75%
margin-left: 1em
padding: .25em .75em
.campaign-progress
background: $campaign-border-color
background-image: linear-gradient(color-stops($campaign-border-color + #222, $campaign-border-color))
border-right: 1px solid $campaign-border-color
.campaign-progress-wrapper, .campaign-progress
height: 2.5em
.campaign-progress-label
text-shadow: 1px 1px 0 $campaign-border-color
color: white
font-size: 150%
left: 0
position: absolute
top: 0
text-align: center
width: 100%
a
color: inherit
display: block
text-decoration: none
.button
position: relative
top: -2px /* lame hack to align well with text; sigh */
&.campaign-loaded
.campaign-progress-wrapper
visibility: visible

View file

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

View file

@ -1,9 +0,0 @@
$icon-width: 16px
$icon-height: 16px
=icon
bottom: -2px
height: $icon-height
position: relative
width: $icon-width

View file

@ -1,179 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
=item-header
border-bottom: 1px solid $module-border-color
margin-top: 1em
margin-bottom: 1em
.item-header-main
display: grid
grid-template-areas: "img gap1" "img name" "img links" "img lists" "img gap2" "nav nav"
align-items: center
justify-content: center
column-gap: 1em
row-gap: .5em
.item-thumbnail
grid-area: img
border: 1px solid $module-border-color
height: 80px
width: 80px
.item-name
grid-area: name
text-align: left
line-height: 100%
margin-bottom: 0
.item-links
grid-area: links
font-size: 85%
text-align: left
display: flex
align-items: center
flex-wrap: wrap
gap: 1em
abbr
cursor: help
.item-kind, .first-seen-at
padding: .25em .5em
border-radius: .25em
text-decoration: none
font-weight: bold
line-height: 1
background: #E2E8F0
color: #1A202C
.icon
vertical-align: middle
.item-kind
// These colors are copied from DTI 2020, for initial consistency!
// They're based on the Chakra UI colors, which I think are in turn the
// Bootstrap colors? Or something?
// NOTE: For the data-type=np case, we use the default gray colors.
&[data-type=nc]
background: #E9D8FD
color: #44337A
&[data-type=pb]
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
background: $background-color
border: 1px solid $module-border-color
border-radius: .5em
padding: 1em
width: 30em
text-align: center
z-index: 2
margin-top: .5em
margin-inline: auto
margin-bottom: 1.5em
font-size: 85%
h3
font-size: 150%
font-weight: bold
margin-bottom: .75em
.closet-hangers-ownership-groups
+clearfix
margin-bottom: .5em
div
float: left
margin: 0 5%
text-align: left
width: 40%
li
list-style: none
word-wrap: break-word
label.unlisted
font-style: italic
form
padding: .5em 0
select
width: 9em
input[type=number]
margin-right: .5em
width: 3em
.item-description
margin-top: .5em
margin-bottom: 1em
.item-subpages-nav
display: flex
align-items: flex-end
gap: 1em
.preview-link
margin-right: auto
.trades-section
display: flex
gap: .5em
header
align-self: center
font-weight: bold
&::after
content: ":"
ul
align-self: flex-end
list-style: none
display: flex
gap: .5em
li
display: block
a
display: block
background: $module-bg-color
border: 1px solid $module-border-color
border-bottom: 0
border-radius: .5em .5em 0 0
padding: .5em 1em
text-decoration: none
&:hover, &:focus
text-decoration: underline
&[data-is-current=true]
background: $background-color
padding-bottom: calc(.5em + 1px)
font-weight: bold
margin-bottom: -1px

View file

@ -1,98 +0,0 @@
div
&.jGrowl
padding: 10px
z-index: 9999
color: #fff
font-size: 12px
&.ie6
position: absolute
&.top-right
right: auto
bottom: auto
left: expression(( 0 - jGrowl.offsetWidth + ( document.documentElement.clientWidth ? document.documentElement.clientWidth : document.body.clientWidth ) + ( ignoreMe2 = document.documentElement.scrollLeft ? document.documentElement.scrollLeft : document.body.scrollLeft ) ) + 'px' )
top: expression(( 0 + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop ) ) + 'px' )
&.top-left
left: expression(( 0 + ( ignoreMe2 = document.documentElement.scrollLeft ? document.documentElement.scrollLeft : document.body.scrollLeft ) ) + 'px' )
top: expression(( 0 + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop ) ) + 'px' )
&.bottom-right
left: expression(( 0 - jGrowl.offsetWidth + ( document.documentElement.clientWidth ? document.documentElement.clientWidth : document.body.clientWidth ) + ( ignoreMe2 = document.documentElement.scrollLeft ? document.documentElement.scrollLeft : document.body.scrollLeft ) ) + 'px' )
top: expression(( 0 - jGrowl.offsetHeight + ( document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight ) + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop ) ) + 'px' )
&.bottom-left
left: expression(( 0 + ( ignoreMe2 = document.documentElement.scrollLeft ? document.documentElement.scrollLeft : document.body.scrollLeft ) ) + 'px' )
top: expression(( 0 - jGrowl.offsetHeight + ( document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight ) + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop ) ) + 'px' )
&.center
left: expression(( 0 + ( ignoreMe2 = document.documentElement.scrollLeft ? document.documentElement.scrollLeft : document.body.scrollLeft ) ) + 'px' )
top: expression(( 0 + ( ignoreMe = document.documentElement.scrollTop ? document.documentElement.scrollTop : document.body.scrollTop ) ) + 'px' )
width: 100%
/** Special IE6 Style Positioning *
/** Normal Style Positions *
body > div.jGrowl
position: fixed
&.top-left
left: 0px
top: 0px
&.top-right
right: 0px
top: 0px
&.bottom-left
left: 0px
bottom: 0px
&.bottom-right
right: 0px
bottom: 0px
&.center
top: 0px
width: 50%
left: 25%
/** Cross Browser Styling *
div
&.center div
&.jGrowl-notification, &.jGrowl-closer
margin-left: auto
margin-right: auto
&.jGrowl div
&.jGrowl-notification, &.jGrowl-closer
background-color: #000
opacity: .85
-ms-filter: "progid:DXImageTransform.Microsoft.Alpha(Opacity=85)"
filter: progid:DXImageTransform.Microsoft.Alpha(Opacity=85)
zoom: 1
width: 235px
padding: 10px
margin-top: 5px
margin-bottom: 5px
font-family: Tahoma, Arial, Helvetica, sans-serif
font-size: 1em
text-align: left
display: none
-moz-border-radius: 5px
-webkit-border-radius: 5px
&.jGrowl-notification
min-height: 40px
div
&.header
font-weight: bold
font-size: .85em
&.close
z-index: 99
float: right
font-weight: bold
font-size: 1em
cursor: pointer
&.jGrowl-closer
padding-top: 4px
padding-bottom: 4px
cursor: pointer
font-size: .9em
font-weight: bold
text-align: center
/** Hide jGrowl when printing *
@media print
div.jGrowl
display: none

View file

@ -1,41 +0,0 @@
@import "clean/constants"
@import "clean/mixins"
@import "outfits/star"
=outfit
+inline-block
+outfit-star
overflow: hidden
position: relative
header, footer
+outfit-banner
+outfit-banner-background(black)
header
bottom: 0
footer
top: 0
a
color: white
=outfits-list
// remove whitespace between inline-block elements
font-size: 0
list-style: none
> li
+outfit
font-size: 14px
=outfit-banner
color: white
left: 0
position: absolute
z-index: 2
=outfit-banner-background($color)
background: $color
background: rgba($color, 0.75)

View file

@ -1,12 +0,0 @@
=secondary-nav
#title
float: left
margin-right: .5em
.flash
clear: both
#secondary-nav
display: block
margin-top: .75em

View file

@ -1,6 +0,0 @@
=wardrobe-image-wrapper
img
left: 0
position: absolute
top: 0

View file

@ -1,30 +0,0 @@
$background-color: #fff
$text-color: #040
$soft-text-color: $text-color + #444
$link-color: $text-color + #222
$module-bg-color: #efe
$module-border-color: #060
$soft-border-color: #ada
$input-border-color: #cec
$marked-button-color: #0b61a4
$notice-color: #264409
$notice-bg-color: #e6efc2
$notice-border-color: #c6d880
$warning-color: #514721
$warning-bg-color: #fff6bf
$warning-border-color: #ffd324
$error-color: #8a1f11
$error-bg-color: #fbe3e4
$error-border-color: #fbc2c4
$header-font: Delicious, system-ui, sans-serif
$main-font: system-ui, sans-serif
$object-img-size: 80px
$object-width: 100px
$object-padding: 8px
$nc-icon-size: 16px
$container-top-padding: 2.5em

View file

@ -1,118 +0,0 @@
@import "constants"
=clearfix
overflow: hidden
display: inline-block
&
display: block
=border-radius($r)
-moz-border-radius: $r
-webkit-border-radius: $r
=inline-block
display: -moz-inline-box
-moz-box-orient: vertical
display: inline-block
vertical-align: middle
*display: inline
*vertical-align: auto
=opacity($o)
-moz-opacity: $o
-webkit-opacity: $o
-o-opacity: $o
-khtml-opacity: $o
=header-text
font-family: $header-font
=awesome-button-color($c)
background: $c image-url("alert-overlay.png") repeat-x
&:hover
background-color: $c - #111111
=awesome-button
/* http://www.zurb.com/blog_uploads/0000/0617/buttons-03.html
border-radius: 5px
+awesome-button-color(#006400)
border: 0
display: inline-block
padding: .5em .75em .45em
color: #fff
text-decoration: none
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.5)
text-shadow: 0 -1px 1px rgba(0, 0, 0, 0.25)
border-bottom: 1px solid rgba(0, 0, 0, 0.25)
position: relative
font-weight: bold
line-height: 1
&:hover:not(:disabled)
color: #fff
&:active:not(:disabled)
transform: translateY(1px)
&:disabled
background: #999999 image-url("alert-overlay.png") repeat-x
cursor: not-allowed
=reset-awesome-button
border-radius: 0
background: transparent
display: inline
padding: 0
color: inherit
-moz-box-shadow: none
-webkit-box-shadow: none
text-shadow: none
border-bottom: 0
position: static
font-weight: normal
line-height: inherit
&:hover
background: transparent
color: inherit
&:active
top: auto
=loud-awesome-button-color
+awesome-button-color(#ff5c00)
=loud-awesome-button
+loud-awesome-button-color
font-size: 125%
padding: 8px 14px 9px
=arrowed-awesome-button
&:after
content: " >>"
=module
background: $module-bg-color
border: 1px solid $module-border-color
padding: 1em
=notice
background: $notice-bg-color
border: 1px solid $notice-border-color
color: $notice-color
=error
background: $error-bg-color
border: 1px solid $error-border-color
color: $error-color
=warning
background: $warning-bg-color
border: 1px solid $warning-border-color
color: $warning-color
=subtle-banner
border:
color: $soft-border-color
style: solid
width: 1px 0
font-size: 85%
margin: 1em 0
padding: .5em 0
text-align: center

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