Compare commits
No commits in common. "main" and "use-head-request-for-hash" have entirely different histories.
main
...
use-head-r
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
#!/bin/bash
|
||||
# Creates SSH config for devcontainer to use host's SSH identity
|
||||
# This allows `ssh impress.openneo.net` to work without hardcoding usernames
|
||||
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
|
||||
# Only create SSH config if IMPRESS_DEPLOY_USER is explicitly set
|
||||
if [ -z "$IMPRESS_DEPLOY_USER" ]; then
|
||||
echo "⚠️ IMPRESS_DEPLOY_USER not set - skipping SSH config creation."
|
||||
echo " This should be automatically set from your host \$USER environment variable."
|
||||
echo " See docs/deployment-setup.md for details."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat > ~/.ssh/config <<EOF
|
||||
# Deployment server config
|
||||
# Username: ${IMPRESS_DEPLOY_USER}
|
||||
Host impress.openneo.net
|
||||
User ${IMPRESS_DEPLOY_USER}
|
||||
ForwardAgent yes
|
||||
|
||||
# Add other host configurations as needed
|
||||
EOF
|
||||
|
||||
chmod 600 ~/.ssh/config
|
||||
|
||||
echo "✓ SSH config created. Deployment username: ${IMPRESS_DEPLOY_USER}"
|
||||
|
|
@ -1,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
|
|
@ -0,0 +1 @@
|
|||
*.sql.gz filter=lfs diff=lfs merge=lfs -text
|
||||
21
.github/workflows/release.yml
vendored
Normal 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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
_
|
||||
3
.husky/post-checkout
Executable 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
|
|
@ -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
|
|
@ -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 "$@"
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -1 +0,0 @@
|
|||
--require spec_helper
|
||||
|
|
@ -1 +0,0 @@
|
|||
3.4.5
|
||||
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
593
Gemfile.lock
|
|
@ -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
|
||||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
Contributor: Matchu
|
||||
|
||||
Source Code: https://code.openneo.net/OpenNeo/impress
|
||||
Source Code: https://github.com/matchu/impress-2020
|
||||
|
||||
## Modification
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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.
|
||||
|
|
|
|||
11
Rakefile
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 135 B |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 957 B |
|
Before Width: | Height: | Size: 801 B |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 7 KiB |
|
Before Width: | Height: | Size: 655 B |
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 793 B |
|
Before Width: | Height: | Size: 732 B |
|
Before Width: | Height: | Size: 754 B |
|
Before Width: | Height: | Size: 756 B |
|
Before Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 537 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 670 B |
|
Before Width: | Height: | Size: 596 B |
|
Before Width: | Height: | Size: 671 B |
|
Before Width: | Height: | Size: 5.2 KiB |
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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());
|
||||
});
|
||||
})();
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
class MagicMagnifier extends HTMLElement {
|
||||
#internals = this.attachInternals();
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#attachLens(), 0);
|
||||
this.addEventListener("mousemove", this.#onMouseMove);
|
||||
}
|
||||
|
||||
#attachLens() {
|
||||
const lens = document.createElement("magic-magnifier-lens");
|
||||
lens.inert = true;
|
||||
lens.useContent(this.children);
|
||||
this.appendChild(lens);
|
||||
}
|
||||
|
||||
#onMouseMove(e) {
|
||||
const lens = this.querySelector("magic-magnifier-lens");
|
||||
const rect = this.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
this.style.setProperty("--magic-magnifier-x", x + "px");
|
||||
this.style.setProperty("--magic-magnifier-y", y + "px");
|
||||
this.#internals.states.add("ready");
|
||||
}
|
||||
}
|
||||
|
||||
class MagicMagnifierLens extends HTMLElement {
|
||||
useContent(contentNodes) {
|
||||
for (const contentNode of contentNodes) {
|
||||
this.appendChild(contentNode.cloneNode(true));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("magic-magnifier", MagicMagnifier);
|
||||
customElements.define("magic-magnifier-lens", MagicMagnifierLens);
|
||||
|
|
@ -1,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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
})();
|
||||
|
|
@ -1,46 +0,0 @@
|
|||
class SupportOutfitViewer extends HTMLElement {
|
||||
#internals = this.attachInternals();
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("mouseenter", this.#onMouseEnter, { capture: true });
|
||||
this.addEventListener("mouseleave", this.#onMouseLeave, { capture: true });
|
||||
this.addEventListener("click", this.#onClick);
|
||||
this.#internals.states.add("ready");
|
||||
}
|
||||
|
||||
// When a row is hovered, highlight its corresponding outfit viewer layer.
|
||||
#onMouseEnter(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.setAttribute("highlighted", "");
|
||||
}
|
||||
}
|
||||
|
||||
// When a row is unhovered, unhighlight its corresponding outfit viewer layer.
|
||||
#onMouseLeave(e) {
|
||||
if (!e.target.matches("tr")) return;
|
||||
|
||||
const id = e.target.querySelector("[data-field=id]").innerText;
|
||||
const layers = this.querySelectorAll(
|
||||
`outfit-viewer [data-asset-id="${CSS.escape(id)}"]`,
|
||||
);
|
||||
for (const layer of layers) {
|
||||
layer.removeAttribute("highlighted");
|
||||
}
|
||||
}
|
||||
|
||||
// When clicking a row, redirect the click to the first link.
|
||||
#onClick(e) {
|
||||
const row = e.target.closest("tr");
|
||||
if (row == null) return;
|
||||
|
||||
row.querySelector("[data-field=links] a").click();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("support-outfit-viewer", SupportOutfitViewer);
|
||||
|
|
@ -1,110 +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();
|
||||
});
|
||||
})();
|
||||
|
|
@ -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.");
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
.alt-style-preview
|
||||
width: 300px
|
||||
height: 300px
|
||||
margin: 0 auto
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
// Prefer to break the name at visually appealing points.
|
||||
.rainbow-pool-list
|
||||
.name
|
||||
text-wrap: balance
|
||||
|
||||
// De-emphasize Prismatic styles, in browsers that support it.
|
||||
.rainbow-pool-filters
|
||||
select[name="series"]
|
||||
option[value*=": "]
|
||||
color: $soft-text-color
|
||||
font-style: italic
|
||||
|
|
@ -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
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
#title:has(+ .breadcrumbs)
|
||||
margin-bottom: .125em
|
||||
|
||||
.breadcrumbs
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: row
|
||||
margin-block: .5em
|
||||
font-size: .85em
|
||||
|
||||
li
|
||||
display: flex
|
||||
|
||||
li:not(:first-child)
|
||||
&::before
|
||||
margin-inline: .35em
|
||||
content: "→"
|
||||
|
||||
&[data-relation-to-prev=sibling]::before
|
||||
content: "+"
|
||||
|
||||
&[data-relation-to-prev=menu]::before
|
||||
content: "-"
|
||||
|
|
@ -1,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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
magic-magnifier
|
||||
display: block
|
||||
position: relative
|
||||
|
||||
// Only show the lens when we are hovering, and the magnifier's X and Y
|
||||
// coordinates are set. (This ensures the component is running, and has
|
||||
// received a mousemove event, instead of defaulting to (0, 0).)
|
||||
magic-magnifier-lens
|
||||
display: none
|
||||
|
||||
// TODO: Once container query support is broader, we can remove the CSS state
|
||||
// and read for the presence of the X and Y custom properties instead.
|
||||
&:hover:state(ready)
|
||||
magic-magnifier-lens
|
||||
display: block
|
||||
|
||||
magic-magnifier-lens
|
||||
display: block
|
||||
width: var(--magic-magnifier-lens-width, 100px)
|
||||
height: var(--magic-magnifier-lens-height, 100px)
|
||||
overflow: hidden
|
||||
border-radius: 100%
|
||||
|
||||
background: white
|
||||
border: 2px solid black
|
||||
box-shadow: 3px 3px 3px rgba(0, 0, 0, .5)
|
||||
|
||||
position: absolute
|
||||
left: var(--magic-magnifier-x, 0px)
|
||||
top: var(--magic-magnifier-y, 0px)
|
||||
|
||||
> *
|
||||
// Translations are applied in the opposite of the order they're specified.
|
||||
// So, here's what we're doing:
|
||||
//
|
||||
// 1. Translate the content left by --magic-magnifier-x and up by
|
||||
// --magic-magnifier-y, to align the target location with the lens's
|
||||
// top-right corner.
|
||||
// 2. Zoom in by --magic-magnifier-scale.
|
||||
// 3. Translate the content right by half of --magic-magnifier-lens-width,
|
||||
// and down by half of --magic-magnifier-lens-height, to align the
|
||||
// target location with the lens's center.
|
||||
//
|
||||
// Note that it *is* possible to specify transforms relative to the center,
|
||||
// rather than the top-left corner—this is in fact the default!—but that
|
||||
// gets confusing fast with scale in play. I think this is easier to reason
|
||||
// about with the top-left corner in terms of math, and center it after the
|
||||
// fact.
|
||||
transform: translateX(calc(var(--magic-magnifier-lens-width, 100px) / 2)) translateY(calc(var(--magic-magnifier-lens-height, 100px) / 2)) scale(var(--magic-magnifier-scale, 2)) translateX(calc(-1 * var(--magic-magnifier-x, 0px))) translateY(calc(-1 * var(--magic-magnifier-y, 0px)))
|
||||
transform-origin: left top
|
||||
|
|
@ -1,125 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We only apply
|
||||
// the delay here, not on the base styles, because fading *out* on load should
|
||||
// be instant.
|
||||
//
|
||||
// This is implemented as a mixin, so that the item page can leverage the same
|
||||
// loading state when loading a new preview altogether. Once CSS container
|
||||
// style queries gain wider support, maybe use that instead.
|
||||
=outfit-viewer-loading
|
||||
cursor: wait
|
||||
|
||||
.loading-indicator
|
||||
opacity: 1
|
||||
transition-delay: 2s
|
||||
|
||||
// If the outfit *starts* in loading state, still delay the fade-in.
|
||||
@starting-style
|
||||
opacity: 0
|
||||
|
||||
outfit-viewer
|
||||
display: block
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
// These are default widths, expected to often be overridden.
|
||||
width: 300px
|
||||
height: 300px
|
||||
|
||||
// There's no useful text in here, but double-clicking the play/pause
|
||||
// button can cause a weird selection state. Disable text selection.
|
||||
user-select: none
|
||||
-webkit-user-select: none
|
||||
|
||||
outfit-layer
|
||||
display: block
|
||||
position: absolute
|
||||
inset: 0
|
||||
|
||||
// We disable pointer-events most importantly for the iframes, which
|
||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
||||
// inside of its own document. But also, the context menus for these
|
||||
// elements are kinda actively misleading, too!
|
||||
pointer-events: none
|
||||
|
||||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
z-index: 1000
|
||||
bottom: 0px
|
||||
right: 4px
|
||||
padding: 8px
|
||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
||||
|
||||
opacity: 0
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: none
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
.play-pause-toggle
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
&:has(outfit-layer:state(loading))
|
||||
+outfit-viewer-loading
|
||||
|
||||
// If a layer has the `[highlighted]` attribute, it's brought to the front,
|
||||
// and other layers are grayed out and blurred. We use this in the support
|
||||
// outfit viewer, when you hover over a layer.
|
||||
&:has(outfit-layer[highlighted])
|
||||
outfit-layer[highlighted]
|
||||
z-index: 999
|
||||
|
||||
// Filter everything behind the bottom-most highlighted layer, using a
|
||||
// backdrop filter. This gives us the best visual consistency by applying
|
||||
// effects to the entire backdrop, instead of each layer and then
|
||||
// re-compositing them.
|
||||
backdrop-filter: grayscale(1) brightness(2) blur(1px)
|
||||
& ~ outfit-layer[highlighted]
|
||||
backdrop-filter: none
|
||||
|
|
@ -1,74 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.rainbow-pool-filters
|
||||
margin-block: .5em
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
legend
|
||||
display: contents
|
||||
font-weight: bold
|
||||
|
||||
select
|
||||
width: 16ch
|
||||
|
||||
.rainbow-pool-list
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
justify-content: center
|
||||
gap: .5em
|
||||
|
||||
--preview-base-width: 150px
|
||||
|
||||
> li
|
||||
width: var(--preview-base-width)
|
||||
max-width: calc(50% - .25em)
|
||||
min-width: 150px
|
||||
box-sizing: border-box
|
||||
text-align: center
|
||||
|
||||
a
|
||||
display: block
|
||||
border-radius: 1em
|
||||
padding: .5em
|
||||
text-decoration: none
|
||||
background: white
|
||||
&:hover
|
||||
outline: 1px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
|
||||
.preview
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 1 / 1
|
||||
margin-bottom: -1em
|
||||
|
||||
.name
|
||||
background: inherit
|
||||
padding: .25em .5em
|
||||
border-radius: .5em
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
z-index: 1
|
||||
|
||||
.info
|
||||
font-size: .85em
|
||||
p
|
||||
margin-block: .25em
|
||||
|
||||
.rainbow-pool-pagination
|
||||
margin-block: .5em
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
|
||||
.rainbow-pool-no-results
|
||||
margin-block: 1em
|
||||
text-align: center
|
||||
font-style: italic
|
||||
|
|
@ -1,102 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
|
||||
.support-form
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 1em
|
||||
align-items: flex-start
|
||||
|
||||
.fields
|
||||
list-style-type: none
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
width: 100%
|
||||
|
||||
> li
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
max-width: 60ch
|
||||
|
||||
> label, > .field_with_errors label
|
||||
display: block
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
> label
|
||||
color: $error-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
&[data-type=radio]
|
||||
ul
|
||||
list-style-type: none
|
||||
|
||||
&[data-type=radio-grid] // Set the `--num-columns` property to configure!
|
||||
max-width: none
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: grid
|
||||
grid-template-columns: repeat(var(--num-columns, 1), 1fr)
|
||||
gap: .25em
|
||||
|
||||
li
|
||||
display: flex
|
||||
align-items: stretch // Give the bubbles equal heights!
|
||||
|
||||
label
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .5em
|
||||
padding: .5em 1em
|
||||
border: 1px solid $soft-border-color
|
||||
border-radius: 1em
|
||||
flex: 1 1 auto
|
||||
|
||||
input
|
||||
margin: 0
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-color: $module-border-color
|
||||
|
||||
input[type=text], input[type=url]
|
||||
width: 100%
|
||||
min-width: 10ch
|
||||
box-sizing: border-box
|
||||
|
||||
.thumbnail-input
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
|
||||
img
|
||||
width: 40px
|
||||
height: 40px
|
||||
|
||||
fieldset
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .25em
|
||||
|
||||
legend
|
||||
font-weight: bold
|
||||
|
||||
.field_with_errors
|
||||
display: contents
|
||||
|
||||
.actions
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: 1em
|
||||
|
||||
.go-to-next
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: .25em
|
||||
font-size: .85em
|
||||
font-style: italic
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1,57 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/secondary_nav"
|
||||
|
||||
+secondary-nav
|
||||
|
||||
#intro
|
||||
clear: both
|
||||
|
||||
#petpage-closet-lists
|
||||
+clearfix
|
||||
border-radius: 10px
|
||||
border: 1px solid $soft-border-color
|
||||
margin-bottom: 1.5em
|
||||
padding: .5em 1.5em
|
||||
|
||||
> div
|
||||
margin: .25em 0
|
||||
|
||||
h4
|
||||
display: inline-block
|
||||
vertical-align: middle
|
||||
|
||||
&::after
|
||||
content: ":"
|
||||
|
||||
ul
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
li
|
||||
display: inline-block
|
||||
font-size: 85%
|
||||
margin: .25em .5em
|
||||
padding: 1px
|
||||
|
||||
label
|
||||
padding: .25em .75em .25em .25em
|
||||
|
||||
&:has(:checked)
|
||||
background: $module-bg-color
|
||||
border-radius: 3px
|
||||
border: 1px solid $module-border-color
|
||||
padding: 0
|
||||
|
||||
&.unlisted
|
||||
font-style: italic
|
||||
|
||||
input[type=submit]
|
||||
float: right
|
||||
|
||||
#petpage-output
|
||||
display: block
|
||||
height: 30em
|
||||
margin: 0 auto
|
||||
width: 50%
|
||||
|
|
@ -1,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
|
||||
|
|
@ -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%
|
||||
|
|
@ -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
|
||||
|
|
@ -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" %>");
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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: ", "
|
||||
|
|
@ -1,23 +0,0 @@
|
|||
=main_unit
|
||||
float: left
|
||||
width: 49%
|
||||
h2
|
||||
font-size: 125%
|
||||
|
||||
form
|
||||
margin-bottom: 2em
|
||||
|
||||
#search-info
|
||||
+main_unit
|
||||
padding-right: 1%
|
||||
dl
|
||||
text-align: left
|
||||
dd
|
||||
margin-bottom: 1em
|
||||
|
||||
#species-search-links
|
||||
+main_unit
|
||||
padding-left: 1%
|
||||
img
|
||||
height: 80px
|
||||
width: 80px
|
||||
|
|
@ -1,309 +0,0 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../partials/item_header"
|
||||
|
||||
@import "../application/outfit-viewer"
|
||||
|
||||
#container
|
||||
width: 900px // A bit more generous to the preview area!
|
||||
|
||||
.item-header
|
||||
+item-header
|
||||
|
||||
#item-contributors
|
||||
+subtle-banner
|
||||
clear: both
|
||||
margin:
|
||||
bottom: 0
|
||||
top: 2em
|
||||
|
||||
header
|
||||
display: inline
|
||||
font-weight: bold
|
||||
margin-right: .25em
|
||||
|
||||
footer
|
||||
display: inline
|
||||
|
||||
ul
|
||||
display: inline
|
||||
list-style: none
|
||||
|
||||
li
|
||||
display: inline
|
||||
|
||||
&::after
|
||||
content: ", "
|
||||
|
||||
&:last-child::after
|
||||
content: "."
|
||||
|
||||
.nc-icon
|
||||
height: 16px
|
||||
width: 16px
|
||||
|
||||
.preview-area
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
|
||||
.customize-more
|
||||
position: absolute
|
||||
top: 1em
|
||||
right: 1em
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
text-decoration: none
|
||||
|
||||
background: #EDF2F7
|
||||
padding-inline: .75em
|
||||
border-radius: .375em
|
||||
min-height: 2rem
|
||||
min-width: 2rem
|
||||
box-sizing: border-box
|
||||
|
||||
.customize-more-label
|
||||
width: 0
|
||||
overflow: hidden
|
||||
transition: width .25s
|
||||
white-space: nowrap
|
||||
--natural-width: auto
|
||||
|
||||
measured-content
|
||||
padding-right: .5em
|
||||
|
||||
&:hover, &:focus
|
||||
// Expand the label to its natural width. If the JS ran to tell us
|
||||
// what it is in px, we can use that for a smooth transition. If not,
|
||||
// okay, we just pop out to `auto`, which CSS can't make smooth.
|
||||
.customize-more-label
|
||||
width: var(--natural-width)
|
||||
|
||||
outfit-viewer
|
||||
width: 300px
|
||||
height: 300px
|
||||
border: 1px solid $module-border-color
|
||||
border-radius: 1em
|
||||
|
||||
.error-indicator
|
||||
font-size: 85%
|
||||
color: $error-color
|
||||
margin-top: .25em
|
||||
margin-bottom: .5em
|
||||
display: none
|
||||
|
||||
// When loading, fade in the loading spinner after a brief delay. We are
|
||||
// loading when the <turbo-frame> is busy, or when at least one layer
|
||||
// is loading.
|
||||
//
|
||||
// We only apply the delay here, not on the base styles, because fading
|
||||
// *out* on load should be instant.
|
||||
#item-preview[busy] outfit-viewer
|
||||
+outfit-viewer-loading
|
||||
|
||||
#item-preview:has(outfit-layer:state(error))
|
||||
outfit-viewer
|
||||
border: 2px solid red
|
||||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
||||
form[data-is-valid="false"]
|
||||
select
|
||||
border-color: $error-border-color
|
||||
color: $error-color
|
||||
|
||||
// If JS is enabled, but auto-loading isn't ready yet (script loading or
|
||||
// failed?), hide the submit button for .75sec, to give it time to load.
|
||||
@media (scripting: enabled)
|
||||
input[type=submit]
|
||||
position: absolute
|
||||
margin-left: .5em
|
||||
opacity: 0
|
||||
animation: fade-in .25s forwards
|
||||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
species-face-picker
|
||||
display: block
|
||||
position: relative
|
||||
margin-top: -10px
|
||||
|
||||
species-face-picker-options
|
||||
display: flex
|
||||
justify-content: center
|
||||
flex-wrap: wrap
|
||||
isolation: isolate // avoid z-index conflicts between pets and noscript
|
||||
overflow: auto
|
||||
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
|
||||
padding: 10px // leave enough room for the zoomed-in selected face
|
||||
|
||||
img
|
||||
width: 54px
|
||||
height: 54px
|
||||
transition: all 0.2s
|
||||
|
||||
// Calm down the default color, just a smidge! There's a lot of color
|
||||
// on this page already, y'know?
|
||||
opacity: .9
|
||||
filter: saturate(90%)
|
||||
|
||||
label
|
||||
display: flex
|
||||
overflow: hidden
|
||||
transition: all 0.2s
|
||||
position: relative
|
||||
line-height: 1
|
||||
|
||||
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
|
||||
// Chakra UI's styling system to generate them! (The colors are from their
|
||||
// color palette, too.)
|
||||
&:has(input:checked)
|
||||
border-radius: 6px
|
||||
z-index: 1
|
||||
background: #9AE6B4
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
|
||||
transform: scale(1.1)
|
||||
|
||||
&:has(input:focus)
|
||||
background: #BEE3F8
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
|
||||
transform: scale(1.2)
|
||||
|
||||
input[type=radio]
|
||||
position: absolute
|
||||
left: -10000px
|
||||
top: auto
|
||||
width: 1px
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
|
||||
&:checked + img
|
||||
opacity: 1
|
||||
filter: saturate(110%)
|
||||
|
||||
&:disabled + img
|
||||
opacity: .6
|
||||
filter: saturate(0%)
|
||||
|
||||
label:has(input[type=radio]:disabled)
|
||||
cursor: not-allowed
|
||||
|
||||
noscript
|
||||
position: absolute
|
||||
inset: 0
|
||||
padding: 1em
|
||||
background: rgba(white, .8)
|
||||
z-index: 1
|
||||
cursor: auto
|
||||
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
text-align: center
|
||||
|
||||
&:has(species-face-picker-options[inert])
|
||||
cursor: wait
|
||||
|
||||
.item-preview-meta-info
|
||||
display: grid
|
||||
grid-template-columns: 1fr auto
|
||||
gap: .5em
|
||||
align-items: center
|
||||
|
||||
.item-zones-info
|
||||
h3
|
||||
display: inline
|
||||
font: inherit
|
||||
font-weight: bold
|
||||
&:after
|
||||
content: ": "
|
||||
|
||||
ul
|
||||
list-style-type: none
|
||||
display: inline
|
||||
|
||||
li
|
||||
display: inline
|
||||
&:not(:last-of-type):after
|
||||
content: ", "
|
||||
|
||||
.no-zones
|
||||
font-style: italic
|
||||
opacity: .85
|
||||
|
||||
.zone-species-info
|
||||
font-style: italic
|
||||
text-decoration: underline dotted
|
||||
|
||||
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
|
||||
.item-html5-info
|
||||
display: flex
|
||||
align-items: center
|
||||
border: 1px solid
|
||||
border-radius: .375em
|
||||
padding: 4px 8px
|
||||
min-height: 30px
|
||||
box-sizing: border-box
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
|
||||
|
||||
&[data-status=converted]
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
svg:nth-of-type(2)
|
||||
margin-right: -4px // spacing hacks!
|
||||
|
||||
&[data-status=unconverted]
|
||||
background: $warning-bg-color
|
||||
color: #975A16
|
||||
gap: .25em // spacing hacks!
|
||||
|
||||
svg:first-of-type
|
||||
width: 12px
|
||||
height: 12px
|
||||
|
||||
svg:nth-of-type(2)
|
||||
width: 20px
|
||||
height: 20px
|
||||
|
||||
#item-preview
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: .75em
|
||||
|
||||
@media (min-width: 700px)
|
||||
display: grid
|
||||
grid-template-areas: "viewer faces" "picker meta"
|
||||
gap: .5em
|
||||
|
||||
.preview-area
|
||||
grid-area: viewer
|
||||
outfit-viewer
|
||||
width: 380px
|
||||
height: 380px
|
||||
|
||||
species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
grid-area: faces
|
||||
species-face-picker-options
|
||||
max-height: 380px
|
||||
|
||||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
to
|
||||
opacity: 1
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
@import "clean/mixins"
|
||||
|
||||
=context-button
|
||||
+awesome-button
|
||||
+awesome-button-color(#aaaaaa)
|
||||
+opacity(0.9)
|
||||
font-size: 80%
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
$icon-width: 16px
|
||||
$icon-height: 16px
|
||||
|
||||
=icon
|
||||
bottom: -2px
|
||||
height: $icon-height
|
||||
position: relative
|
||||
width: $icon-width
|
||||
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
=secondary-nav
|
||||
#title
|
||||
float: left
|
||||
margin-right: .5em
|
||||
|
||||
.flash
|
||||
clear: both
|
||||
|
||||
#secondary-nav
|
||||
display: block
|
||||
margin-top: .75em
|
||||
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
=wardrobe-image-wrapper
|
||||
img
|
||||
left: 0
|
||||
position: absolute
|
||||
top: 0
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||