forked from OpenNeo/impress
Compare commits
No commits in common. "2ffad8120e241f65d818cf8d501b98a349e6c5f4" and "acade360a862660e6a303227b8c3e84f996e86f2" have entirely different histories.
2ffad8120e
...
acade360a8
91 changed files with 1918 additions and 2808 deletions
6
Gemfile
6
Gemfile
|
@ -4,7 +4,7 @@ ruby '3.3.4'
|
||||||
gem 'rails', '~> 7.1', '>= 7.1.3.4'
|
gem 'rails', '~> 7.1', '>= 7.1.3.4'
|
||||||
|
|
||||||
# The HTTP server running the Rails instance.
|
# The HTTP server running the Rails instance.
|
||||||
gem 'falcon', '~> 0.48.0'
|
gem 'falcon', '~> 0.43.0'
|
||||||
|
|
||||||
# Our database is MySQL, in both development and production.
|
# Our database is MySQL, in both development and production.
|
||||||
gem 'mysql2', '~> 0.5.5'
|
gem 'mysql2', '~> 0.5.5'
|
||||||
|
@ -61,8 +61,8 @@ gem "httparty", "~> 0.22.0"
|
||||||
gem "addressable", "~> 2.8"
|
gem "addressable", "~> 2.8"
|
||||||
|
|
||||||
# For advanced batching of many HTTP requests.
|
# For advanced batching of many HTTP requests.
|
||||||
gem "async", "~> 2.17", require: false
|
gem "async", "~> 2.6", require: false
|
||||||
gem "async-http", "~> 0.75.0", require: false
|
gem "async-http", "~> 0.61.0", require: false
|
||||||
gem "thread-local", "~> 1.1", require: false
|
gem "thread-local", "~> 1.1", require: false
|
||||||
|
|
||||||
# For debugging.
|
# For debugging.
|
||||||
|
|
69
Gemfile.lock
69
Gemfile.lock
|
@ -81,30 +81,29 @@ GEM
|
||||||
public_suffix (>= 2.0.2, < 7.0)
|
public_suffix (>= 2.0.2, < 7.0)
|
||||||
aes_key_wrap (1.1.0)
|
aes_key_wrap (1.1.0)
|
||||||
ast (2.4.2)
|
ast (2.4.2)
|
||||||
async (2.17.0)
|
async (2.16.1)
|
||||||
console (~> 1.26)
|
console (~> 1.26)
|
||||||
fiber-annotation
|
fiber-annotation
|
||||||
io-event (~> 1.6, >= 1.6.5)
|
io-event (~> 1.6, >= 1.6.5)
|
||||||
async-container (0.18.3)
|
async-container (0.16.13)
|
||||||
async (~> 2.10)
|
async
|
||||||
async-http (0.75.0)
|
async-io
|
||||||
async (>= 2.10.2)
|
async-http (0.61.0)
|
||||||
async-pool (~> 0.7)
|
async (>= 1.25)
|
||||||
io-endpoint (~> 0.11)
|
async-io (>= 1.28)
|
||||||
io-stream (~> 0.4)
|
async-pool (>= 0.2)
|
||||||
protocol-http (~> 0.30)
|
protocol-http (~> 0.25.0)
|
||||||
protocol-http1 (~> 0.20)
|
protocol-http1 (~> 0.16.0)
|
||||||
protocol-http2 (~> 0.18)
|
protocol-http2 (~> 0.15.0)
|
||||||
traces (>= 0.10)
|
traces (>= 0.10.0)
|
||||||
async-http-cache (0.4.4)
|
async-http-cache (0.4.4)
|
||||||
async-http (~> 0.56)
|
async-http (~> 0.56)
|
||||||
|
async-io (1.43.2)
|
||||||
|
async
|
||||||
async-pool (0.8.1)
|
async-pool (0.8.1)
|
||||||
async (>= 1.25)
|
async (>= 1.25)
|
||||||
metrics
|
metrics
|
||||||
traces
|
traces
|
||||||
async-service (0.12.0)
|
|
||||||
async
|
|
||||||
async-container (~> 0.16)
|
|
||||||
attr_required (1.0.2)
|
attr_required (1.0.2)
|
||||||
babel-source (5.8.35)
|
babel-source (5.8.35)
|
||||||
babel-transpiler (0.7.0)
|
babel-transpiler (0.7.0)
|
||||||
|
@ -119,6 +118,7 @@ GEM
|
||||||
bindex (0.8.1)
|
bindex (0.8.1)
|
||||||
bootsnap (1.18.4)
|
bootsnap (1.18.4)
|
||||||
msgpack (~> 1.2)
|
msgpack (~> 1.2)
|
||||||
|
build-environment (1.13.0)
|
||||||
builder (3.3.0)
|
builder (3.3.0)
|
||||||
childprocess (5.1.0)
|
childprocess (5.1.0)
|
||||||
logger (~> 1.5)
|
logger (~> 1.5)
|
||||||
|
@ -150,19 +150,19 @@ GEM
|
||||||
activemodel
|
activemodel
|
||||||
erubi (1.13.0)
|
erubi (1.13.0)
|
||||||
execjs (2.9.1)
|
execjs (2.9.1)
|
||||||
falcon (0.48.0)
|
falcon (0.43.0)
|
||||||
async
|
async
|
||||||
async-container (~> 0.18)
|
async-container (~> 0.16.0)
|
||||||
async-http (~> 0.75)
|
async-http (~> 0.57)
|
||||||
async-http-cache (~> 0.4)
|
async-http-cache (~> 0.4.0)
|
||||||
async-service (~> 0.10)
|
async-io (~> 1.22)
|
||||||
|
build-environment (~> 1.13)
|
||||||
bundler
|
bundler
|
||||||
localhost (~> 1.1)
|
localhost (~> 1.1)
|
||||||
openssl (~> 3.0)
|
openssl (~> 3.0)
|
||||||
process-metrics (~> 0.2)
|
process-metrics (~> 0.2.0)
|
||||||
protocol-http (~> 0.31)
|
protocol-rack (~> 0.1)
|
||||||
protocol-rack (~> 0.7)
|
samovar (~> 2.1)
|
||||||
samovar (~> 2.3)
|
|
||||||
faraday (2.11.0)
|
faraday (2.11.0)
|
||||||
faraday-net_http (>= 2.0, < 3.4)
|
faraday-net_http (>= 2.0, < 3.4)
|
||||||
logger
|
logger
|
||||||
|
@ -190,9 +190,7 @@ GEM
|
||||||
i18n (1.14.5)
|
i18n (1.14.5)
|
||||||
concurrent-ruby (~> 1.0)
|
concurrent-ruby (~> 1.0)
|
||||||
io-console (0.7.2)
|
io-console (0.7.2)
|
||||||
io-endpoint (0.13.1)
|
|
||||||
io-event (1.6.5)
|
io-event (1.6.5)
|
||||||
io-stream (0.4.0)
|
|
||||||
irb (1.14.0)
|
irb (1.14.0)
|
||||||
rdoc (>= 4.0.0)
|
rdoc (>= 4.0.0)
|
||||||
reline (>= 0.4.2)
|
reline (>= 0.4.2)
|
||||||
|
@ -282,19 +280,18 @@ GEM
|
||||||
parser (3.3.4.2)
|
parser (3.3.4.2)
|
||||||
ast (~> 2.4.1)
|
ast (~> 2.4.1)
|
||||||
racc
|
racc
|
||||||
process-metrics (0.3.0)
|
process-metrics (0.2.1)
|
||||||
console (~> 1.8)
|
console (~> 1.8)
|
||||||
json (~> 2)
|
|
||||||
samovar (~> 2.1)
|
samovar (~> 2.1)
|
||||||
protocol-hpack (1.5.0)
|
protocol-hpack (1.5.0)
|
||||||
protocol-http (0.33.0)
|
protocol-http (0.25.0)
|
||||||
protocol-http1 (0.22.0)
|
protocol-http1 (0.16.1)
|
||||||
protocol-http (~> 0.22)
|
protocol-http (~> 0.22)
|
||||||
protocol-http2 (0.18.0)
|
protocol-http2 (0.15.1)
|
||||||
protocol-hpack (~> 1.4)
|
protocol-hpack (~> 1.4)
|
||||||
protocol-http (~> 0.18)
|
protocol-http (~> 0.18)
|
||||||
protocol-rack (0.7.0)
|
protocol-rack (0.6.0)
|
||||||
protocol-http (~> 0.27)
|
protocol-http (~> 0.23)
|
||||||
rack (>= 1.0)
|
rack (>= 1.0)
|
||||||
psych (5.1.2)
|
psych (5.1.2)
|
||||||
stringio
|
stringio
|
||||||
|
@ -498,13 +495,13 @@ PLATFORMS
|
||||||
DEPENDENCIES
|
DEPENDENCIES
|
||||||
RocketAMF!
|
RocketAMF!
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
async (~> 2.17)
|
async (~> 2.6)
|
||||||
async-http (~> 0.75.0)
|
async-http (~> 0.61.0)
|
||||||
bootsnap (~> 1.16)
|
bootsnap (~> 1.16)
|
||||||
devise (~> 4.9, >= 4.9.2)
|
devise (~> 4.9, >= 4.9.2)
|
||||||
devise-encryptable (~> 0.2.0)
|
devise-encryptable (~> 0.2.0)
|
||||||
dotenv-rails (~> 2.8, >= 2.8.1)
|
dotenv-rails (~> 2.8, >= 2.8.1)
|
||||||
falcon (~> 0.48.0)
|
falcon (~> 0.43.0)
|
||||||
haml (~> 6.1, >= 6.1.1)
|
haml (~> 6.1, >= 6.1.1)
|
||||||
http_accept_language (~> 2.1, >= 2.1.1)
|
http_accept_language (~> 2.1, >= 2.1.1)
|
||||||
httparty (~> 0.22.0)
|
httparty (~> 0.22.0)
|
||||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 7.9 KiB |
Binary file not shown.
Before Width: | Height: | Size: 23 KiB |
BIN
app/assets/images/about/neopass-survey.png
Normal file
BIN
app/assets/images/about/neopass-survey.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
BIN
app/assets/images/about/neopass-survey@2x.png
Normal file
BIN
app/assets/images/about/neopass-survey@2x.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 22 KiB |
|
@ -1,86 +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");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
|
||||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
|
||||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
|
15
app/assets/javascripts/lib/easeljs.min.js
vendored
15
app/assets/javascripts/lib/easeljs.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -1,850 +0,0 @@
|
||||||
// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/dist/idiomorph.js
|
|
||||||
|
|
||||||
// base IIFE to define idiomorph
|
|
||||||
var Idiomorph = (function () {
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// AND NOW IT BEGINS...
|
|
||||||
//=============================================================================
|
|
||||||
let EMPTY_SET = new Set();
|
|
||||||
|
|
||||||
// default configuration values, updatable by users now
|
|
||||||
let defaults = {
|
|
||||||
morphStyle: "outerHTML",
|
|
||||||
callbacks : {
|
|
||||||
beforeNodeAdded: noOp,
|
|
||||||
afterNodeAdded: noOp,
|
|
||||||
beforeNodeMorphed: noOp,
|
|
||||||
afterNodeMorphed: noOp,
|
|
||||||
beforeNodeRemoved: noOp,
|
|
||||||
afterNodeRemoved: noOp,
|
|
||||||
beforeAttributeUpdated: noOp,
|
|
||||||
|
|
||||||
},
|
|
||||||
head: {
|
|
||||||
style: 'merge',
|
|
||||||
shouldPreserve: function (elt) {
|
|
||||||
return elt.getAttribute("im-preserve") === "true";
|
|
||||||
},
|
|
||||||
shouldReAppend: function (elt) {
|
|
||||||
return elt.getAttribute("im-re-append") === "true";
|
|
||||||
},
|
|
||||||
shouldRemove: noOp,
|
|
||||||
afterHeadMorphed: noOp,
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
|
|
||||||
//=============================================================================
|
|
||||||
function morph(oldNode, newContent, config = {}) {
|
|
||||||
|
|
||||||
if (oldNode instanceof Document) {
|
|
||||||
oldNode = oldNode.documentElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof newContent === 'string') {
|
|
||||||
newContent = parseContent(newContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
let normalizedContent = normalizeContent(newContent);
|
|
||||||
|
|
||||||
let ctx = createMorphContext(oldNode, normalizedContent, config);
|
|
||||||
|
|
||||||
return morphNormalizedContent(oldNode, normalizedContent, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
|
|
||||||
if (ctx.head.block) {
|
|
||||||
let oldHead = oldNode.querySelector('head');
|
|
||||||
let newHead = normalizedNewContent.querySelector('head');
|
|
||||||
if (oldHead && newHead) {
|
|
||||||
let promises = handleHeadElement(newHead, oldHead, ctx);
|
|
||||||
// when head promises resolve, call morph again, ignoring the head tag
|
|
||||||
Promise.all(promises).then(function () {
|
|
||||||
morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
|
|
||||||
head: {
|
|
||||||
block: false,
|
|
||||||
ignore: true
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ctx.morphStyle === "innerHTML") {
|
|
||||||
|
|
||||||
// innerHTML, so we are only updating the children
|
|
||||||
morphChildren(normalizedNewContent, oldNode, ctx);
|
|
||||||
return oldNode.children;
|
|
||||||
|
|
||||||
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
|
|
||||||
// otherwise find the best element match in the new content, morph that, and merge its siblings
|
|
||||||
// into either side of the best match
|
|
||||||
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
|
|
||||||
|
|
||||||
// stash the siblings that will need to be inserted on either side of the best match
|
|
||||||
let previousSibling = bestMatch?.previousSibling;
|
|
||||||
let nextSibling = bestMatch?.nextSibling;
|
|
||||||
|
|
||||||
// morph it
|
|
||||||
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
|
|
||||||
|
|
||||||
if (bestMatch) {
|
|
||||||
// if there was a best match, merge the siblings in too and return the
|
|
||||||
// whole bunch
|
|
||||||
return insertSiblings(previousSibling, morphedNode, nextSibling);
|
|
||||||
} else {
|
|
||||||
// otherwise nothing was added to the DOM
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
throw "Do not understand how to morph style " + ctx.morphStyle;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param possibleActiveElement
|
|
||||||
* @param ctx
|
|
||||||
* @returns {boolean}
|
|
||||||
*/
|
|
||||||
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
|
|
||||||
return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param oldNode root node to merge content into
|
|
||||||
* @param newContent new content to merge
|
|
||||||
* @param ctx the merge context
|
|
||||||
* @returns {Element} the element that ended up in the DOM
|
|
||||||
*/
|
|
||||||
function morphOldNodeTo(oldNode, newContent, ctx) {
|
|
||||||
if (ctx.ignoreActive && oldNode === document.activeElement) {
|
|
||||||
// don't morph focused element
|
|
||||||
} else if (newContent == null) {
|
|
||||||
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
|
|
||||||
|
|
||||||
oldNode.remove();
|
|
||||||
ctx.callbacks.afterNodeRemoved(oldNode);
|
|
||||||
return null;
|
|
||||||
} else if (!isSoftMatch(oldNode, newContent)) {
|
|
||||||
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
|
|
||||||
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
|
|
||||||
|
|
||||||
oldNode.parentElement.replaceChild(newContent, oldNode);
|
|
||||||
ctx.callbacks.afterNodeAdded(newContent);
|
|
||||||
ctx.callbacks.afterNodeRemoved(oldNode);
|
|
||||||
return newContent;
|
|
||||||
} else {
|
|
||||||
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
|
|
||||||
|
|
||||||
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
|
|
||||||
// ignore the head element
|
|
||||||
} else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
|
|
||||||
handleHeadElement(newContent, oldNode, ctx);
|
|
||||||
} else {
|
|
||||||
syncNodeFrom(newContent, oldNode, ctx);
|
|
||||||
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
|
|
||||||
morphChildren(newContent, oldNode, ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
|
|
||||||
return oldNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
|
|
||||||
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
|
|
||||||
* by using id sets, we are able to better match up with content deeper in the DOM.
|
|
||||||
*
|
|
||||||
* Basic algorithm is, for each node in the new content:
|
|
||||||
*
|
|
||||||
* - if we have reached the end of the old parent, append the new content
|
|
||||||
* - if the new content has an id set match with the current insertion point, morph
|
|
||||||
* - search for an id set match
|
|
||||||
* - if id set match found, morph
|
|
||||||
* - otherwise search for a "soft" match
|
|
||||||
* - if a soft match is found, morph
|
|
||||||
* - otherwise, prepend the new node before the current insertion point
|
|
||||||
*
|
|
||||||
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
|
|
||||||
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
|
|
||||||
*
|
|
||||||
* @param {Element} newParent the parent element of the new content
|
|
||||||
* @param {Element } oldParent the old content that we are merging the new content into
|
|
||||||
* @param ctx the merge context
|
|
||||||
*/
|
|
||||||
function morphChildren(newParent, oldParent, ctx) {
|
|
||||||
|
|
||||||
let nextNewChild = newParent.firstChild;
|
|
||||||
let insertionPoint = oldParent.firstChild;
|
|
||||||
let newChild;
|
|
||||||
|
|
||||||
// run through all the new content
|
|
||||||
while (nextNewChild) {
|
|
||||||
|
|
||||||
newChild = nextNewChild;
|
|
||||||
nextNewChild = newChild.nextSibling;
|
|
||||||
|
|
||||||
// if we are at the end of the exiting parent's children, just append
|
|
||||||
if (insertionPoint == null) {
|
|
||||||
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
|
|
||||||
|
|
||||||
oldParent.appendChild(newChild);
|
|
||||||
ctx.callbacks.afterNodeAdded(newChild);
|
|
||||||
removeIdsFromConsideration(ctx, newChild);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if the current node has an id set match then morph
|
|
||||||
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
|
|
||||||
morphOldNodeTo(insertionPoint, newChild, ctx);
|
|
||||||
insertionPoint = insertionPoint.nextSibling;
|
|
||||||
removeIdsFromConsideration(ctx, newChild);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// otherwise search forward in the existing old children for an id set match
|
|
||||||
let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
|
|
||||||
|
|
||||||
// if we found a potential match, remove the nodes until that point and morph
|
|
||||||
if (idSetMatch) {
|
|
||||||
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
|
|
||||||
morphOldNodeTo(idSetMatch, newChild, ctx);
|
|
||||||
removeIdsFromConsideration(ctx, newChild);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// no id set match found, so scan forward for a soft match for the current node
|
|
||||||
let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
|
|
||||||
|
|
||||||
// if we found a soft match for the current node, morph
|
|
||||||
if (softMatch) {
|
|
||||||
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
|
|
||||||
morphOldNodeTo(softMatch, newChild, ctx);
|
|
||||||
removeIdsFromConsideration(ctx, newChild);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// abandon all hope of morphing, just insert the new child before the insertion point
|
|
||||||
// and move on
|
|
||||||
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
|
|
||||||
|
|
||||||
oldParent.insertBefore(newChild, insertionPoint);
|
|
||||||
ctx.callbacks.afterNodeAdded(newChild);
|
|
||||||
removeIdsFromConsideration(ctx, newChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove any remaining old nodes that didn't match up with new content
|
|
||||||
while (insertionPoint !== null) {
|
|
||||||
|
|
||||||
let tempNode = insertionPoint;
|
|
||||||
insertionPoint = insertionPoint.nextSibling;
|
|
||||||
removeNode(tempNode, ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// Attribute Syncing Code
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param attr {String} the attribute to be mutated
|
|
||||||
* @param to {Element} the element that is going to be updated
|
|
||||||
* @param updateType {("update"|"remove")}
|
|
||||||
* @param ctx the merge context
|
|
||||||
* @returns {boolean} true if the attribute should be ignored, false otherwise
|
|
||||||
*/
|
|
||||||
function ignoreAttribute(attr, to, updateType, ctx) {
|
|
||||||
if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* syncs a given node with another node, copying over all attributes and
|
|
||||||
* inner element state from the 'from' node to the 'to' node
|
|
||||||
*
|
|
||||||
* @param {Element} from the element to copy attributes & state from
|
|
||||||
* @param {Element} to the element to copy attributes & state to
|
|
||||||
* @param ctx the merge context
|
|
||||||
*/
|
|
||||||
function syncNodeFrom(from, to, ctx) {
|
|
||||||
let type = from.nodeType
|
|
||||||
|
|
||||||
// if is an element type, sync the attributes from the
|
|
||||||
// new node into the new node
|
|
||||||
if (type === 1 /* element type */) {
|
|
||||||
const fromAttributes = from.attributes;
|
|
||||||
const toAttributes = to.attributes;
|
|
||||||
for (const fromAttribute of fromAttributes) {
|
|
||||||
if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
|
|
||||||
to.setAttribute(fromAttribute.name, fromAttribute.value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// iterate backwards to avoid skipping over items when a delete occurs
|
|
||||||
for (let i = toAttributes.length - 1; 0 <= i; i--) {
|
|
||||||
const toAttribute = toAttributes[i];
|
|
||||||
if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!from.hasAttribute(toAttribute.name)) {
|
|
||||||
to.removeAttribute(toAttribute.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// sync text nodes
|
|
||||||
if (type === 8 /* comment */ || type === 3 /* text */) {
|
|
||||||
if (to.nodeValue !== from.nodeValue) {
|
|
||||||
to.nodeValue = from.nodeValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ignoreValueOfActiveElement(to, ctx)) {
|
|
||||||
// sync input values
|
|
||||||
syncInputValue(from, to, ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param from {Element} element to sync the value from
|
|
||||||
* @param to {Element} element to sync the value to
|
|
||||||
* @param attributeName {String} the attribute name
|
|
||||||
* @param ctx the merge context
|
|
||||||
*/
|
|
||||||
function syncBooleanAttribute(from, to, attributeName, ctx) {
|
|
||||||
if (from[attributeName] !== to[attributeName]) {
|
|
||||||
let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
|
|
||||||
if (!ignoreUpdate) {
|
|
||||||
to[attributeName] = from[attributeName];
|
|
||||||
}
|
|
||||||
if (from[attributeName]) {
|
|
||||||
if (!ignoreUpdate) {
|
|
||||||
to.setAttribute(attributeName, from[attributeName]);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
|
|
||||||
to.removeAttribute(attributeName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* NB: many bothans died to bring us information:
|
|
||||||
*
|
|
||||||
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
|
|
||||||
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
|
|
||||||
*
|
|
||||||
* @param from {Element} the element to sync the input value from
|
|
||||||
* @param to {Element} the element to sync the input value to
|
|
||||||
* @param ctx the merge context
|
|
||||||
*/
|
|
||||||
function syncInputValue(from, to, ctx) {
|
|
||||||
if (from instanceof HTMLInputElement &&
|
|
||||||
to instanceof HTMLInputElement &&
|
|
||||||
from.type !== 'file') {
|
|
||||||
|
|
||||||
let fromValue = from.value;
|
|
||||||
let toValue = to.value;
|
|
||||||
|
|
||||||
// sync boolean attributes
|
|
||||||
syncBooleanAttribute(from, to, 'checked', ctx);
|
|
||||||
syncBooleanAttribute(from, to, 'disabled', ctx);
|
|
||||||
|
|
||||||
if (!from.hasAttribute('value')) {
|
|
||||||
if (!ignoreAttribute('value', to, 'remove', ctx)) {
|
|
||||||
to.value = '';
|
|
||||||
to.removeAttribute('value');
|
|
||||||
}
|
|
||||||
} else if (fromValue !== toValue) {
|
|
||||||
if (!ignoreAttribute('value', to, 'update', ctx)) {
|
|
||||||
to.setAttribute('value', fromValue);
|
|
||||||
to.value = fromValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (from instanceof HTMLOptionElement) {
|
|
||||||
syncBooleanAttribute(from, to, 'selected', ctx)
|
|
||||||
} else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
|
|
||||||
let fromValue = from.value;
|
|
||||||
let toValue = to.value;
|
|
||||||
if (ignoreAttribute('value', to, 'update', ctx)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (fromValue !== toValue) {
|
|
||||||
to.value = fromValue;
|
|
||||||
}
|
|
||||||
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
|
|
||||||
to.firstChild.nodeValue = fromValue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
|
|
||||||
//=============================================================================
|
|
||||||
function handleHeadElement(newHeadTag, currentHead, ctx) {
|
|
||||||
|
|
||||||
let added = []
|
|
||||||
let removed = []
|
|
||||||
let preserved = []
|
|
||||||
let nodesToAppend = []
|
|
||||||
|
|
||||||
let headMergeStyle = ctx.head.style;
|
|
||||||
|
|
||||||
// put all new head elements into a Map, by their outerHTML
|
|
||||||
let srcToNewHeadNodes = new Map();
|
|
||||||
for (const newHeadChild of newHeadTag.children) {
|
|
||||||
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
|
|
||||||
}
|
|
||||||
|
|
||||||
// for each elt in the current head
|
|
||||||
for (const currentHeadElt of currentHead.children) {
|
|
||||||
|
|
||||||
// If the current head element is in the map
|
|
||||||
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
|
|
||||||
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
|
|
||||||
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
|
|
||||||
if (inNewContent || isPreserved) {
|
|
||||||
if (isReAppended) {
|
|
||||||
// remove the current version and let the new version replace it and re-execute
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
} else {
|
|
||||||
// this element already exists and should not be re-appended, so remove it from
|
|
||||||
// the new content map, preserving it in the DOM
|
|
||||||
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
|
|
||||||
preserved.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (headMergeStyle === "append") {
|
|
||||||
// we are appending and this existing element is not new content
|
|
||||||
// so if and only if it is marked for re-append do we do anything
|
|
||||||
if (isReAppended) {
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
nodesToAppend.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if this is a merge, we remove this content since it is not in the new head
|
|
||||||
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
|
|
||||||
removed.push(currentHeadElt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Push the remaining new head elements in the Map into the
|
|
||||||
// nodes to append to the head tag
|
|
||||||
nodesToAppend.push(...srcToNewHeadNodes.values());
|
|
||||||
log("to append: ", nodesToAppend);
|
|
||||||
|
|
||||||
let promises = [];
|
|
||||||
for (const newNode of nodesToAppend) {
|
|
||||||
log("adding: ", newNode);
|
|
||||||
let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
|
|
||||||
log(newElt);
|
|
||||||
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
|
|
||||||
if (newElt.href || newElt.src) {
|
|
||||||
let resolve = null;
|
|
||||||
let promise = new Promise(function (_resolve) {
|
|
||||||
resolve = _resolve;
|
|
||||||
});
|
|
||||||
newElt.addEventListener('load', function () {
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
promises.push(promise);
|
|
||||||
}
|
|
||||||
currentHead.appendChild(newElt);
|
|
||||||
ctx.callbacks.afterNodeAdded(newElt);
|
|
||||||
added.push(newElt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// remove all removed elements, after we have appended the new elements to avoid
|
|
||||||
// additional network requests for things like style sheets
|
|
||||||
for (const removedElement of removed) {
|
|
||||||
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
|
|
||||||
currentHead.removeChild(removedElement);
|
|
||||||
ctx.callbacks.afterNodeRemoved(removedElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
|
|
||||||
return promises;
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// Misc
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
function log() {
|
|
||||||
//console.log(arguments);
|
|
||||||
}
|
|
||||||
|
|
||||||
function noOp() {
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
Deep merges the config object and the Idiomoroph.defaults object to
|
|
||||||
produce a final configuration object
|
|
||||||
*/
|
|
||||||
function mergeDefaults(config) {
|
|
||||||
let finalConfig = {};
|
|
||||||
// copy top level stuff into final config
|
|
||||||
Object.assign(finalConfig, defaults);
|
|
||||||
Object.assign(finalConfig, config);
|
|
||||||
|
|
||||||
// copy callbacks into final config (do this to deep merge the callbacks)
|
|
||||||
finalConfig.callbacks = {};
|
|
||||||
Object.assign(finalConfig.callbacks, defaults.callbacks);
|
|
||||||
Object.assign(finalConfig.callbacks, config.callbacks);
|
|
||||||
|
|
||||||
// copy head config into final config (do this to deep merge the head)
|
|
||||||
finalConfig.head = {};
|
|
||||||
Object.assign(finalConfig.head, defaults.head);
|
|
||||||
Object.assign(finalConfig.head, config.head);
|
|
||||||
return finalConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMorphContext(oldNode, newContent, config) {
|
|
||||||
config = mergeDefaults(config);
|
|
||||||
return {
|
|
||||||
target: oldNode,
|
|
||||||
newContent: newContent,
|
|
||||||
config: config,
|
|
||||||
morphStyle: config.morphStyle,
|
|
||||||
ignoreActive: config.ignoreActive,
|
|
||||||
ignoreActiveValue: config.ignoreActiveValue,
|
|
||||||
idMap: createIdMap(oldNode, newContent),
|
|
||||||
deadIds: new Set(),
|
|
||||||
callbacks: config.callbacks,
|
|
||||||
head: config.head
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIdSetMatch(node1, node2, ctx) {
|
|
||||||
if (node1 == null || node2 == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
|
|
||||||
if (node1.id !== "" && node1.id === node2.id) {
|
|
||||||
return true;
|
|
||||||
} else {
|
|
||||||
return getIdIntersectionCount(ctx, node1, node2) > 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSoftMatch(node1, node2) {
|
|
||||||
if (node1 == null || node2 == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeNodesBetween(startInclusive, endExclusive, ctx) {
|
|
||||||
while (startInclusive !== endExclusive) {
|
|
||||||
let tempNode = startInclusive;
|
|
||||||
startInclusive = startInclusive.nextSibling;
|
|
||||||
removeNode(tempNode, ctx);
|
|
||||||
}
|
|
||||||
removeIdsFromConsideration(ctx, endExclusive);
|
|
||||||
return endExclusive.nextSibling;
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// Scans forward from the insertionPoint in the old parent looking for a potential id match
|
|
||||||
// for the newChild. We stop if we find a potential id match for the new child OR
|
|
||||||
// if the number of potential id matches we are discarding is greater than the
|
|
||||||
// potential id matches for the new child
|
|
||||||
//=============================================================================
|
|
||||||
function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
|
|
||||||
|
|
||||||
// max id matches we are willing to discard in our search
|
|
||||||
let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
|
|
||||||
|
|
||||||
let potentialMatch = null;
|
|
||||||
|
|
||||||
// only search forward if there is a possibility of an id match
|
|
||||||
if (newChildPotentialIdCount > 0) {
|
|
||||||
let potentialMatch = insertionPoint;
|
|
||||||
// if there is a possibility of an id match, scan forward
|
|
||||||
// keep track of the potential id match count we are discarding (the
|
|
||||||
// newChildPotentialIdCount must be greater than this to make it likely
|
|
||||||
// worth it)
|
|
||||||
let otherMatchCount = 0;
|
|
||||||
while (potentialMatch != null) {
|
|
||||||
|
|
||||||
// If we have an id match, return the current potential match
|
|
||||||
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
|
|
||||||
return potentialMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
// computer the other potential matches of this new content
|
|
||||||
otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
|
|
||||||
if (otherMatchCount > newChildPotentialIdCount) {
|
|
||||||
// if we have more potential id matches in _other_ content, we
|
|
||||||
// do not have a good candidate for an id match, so return null
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanced to the next old content child
|
|
||||||
potentialMatch = potentialMatch.nextSibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return potentialMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// Scans forward from the insertionPoint in the old parent looking for a potential soft match
|
|
||||||
// for the newChild. We stop if we find a potential soft match for the new child OR
|
|
||||||
// if we find a potential id match in the old parents children OR if we find two
|
|
||||||
// potential soft matches for the next two pieces of new content
|
|
||||||
//=============================================================================
|
|
||||||
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
|
|
||||||
|
|
||||||
let potentialSoftMatch = insertionPoint;
|
|
||||||
let nextSibling = newChild.nextSibling;
|
|
||||||
let siblingSoftMatchCount = 0;
|
|
||||||
|
|
||||||
while (potentialSoftMatch != null) {
|
|
||||||
|
|
||||||
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
|
|
||||||
// the current potential soft match has a potential id set match with the remaining new
|
|
||||||
// content so bail out of looking
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// if we have a soft match with the current node, return it
|
|
||||||
if (isSoftMatch(newChild, potentialSoftMatch)) {
|
|
||||||
return potentialSoftMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
|
|
||||||
// the next new node has a soft match with this node, so
|
|
||||||
// increment the count of future soft matches
|
|
||||||
siblingSoftMatchCount++;
|
|
||||||
nextSibling = nextSibling.nextSibling;
|
|
||||||
|
|
||||||
// If there are two future soft matches, bail to allow the siblings to soft match
|
|
||||||
// so that we don't consume future soft matches for the sake of the current node
|
|
||||||
if (siblingSoftMatchCount >= 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// advanced to the next old content child
|
|
||||||
potentialSoftMatch = potentialSoftMatch.nextSibling;
|
|
||||||
}
|
|
||||||
|
|
||||||
return potentialSoftMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseContent(newContent) {
|
|
||||||
let parser = new DOMParser();
|
|
||||||
|
|
||||||
// remove svgs to avoid false-positive matches on head, etc.
|
|
||||||
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
|
|
||||||
|
|
||||||
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
|
|
||||||
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
|
|
||||||
let content = parser.parseFromString(newContent, "text/html");
|
|
||||||
// if it is a full HTML document, return the document itself as the parent container
|
|
||||||
if (contentWithSvgsRemoved.match(/<\/html>/)) {
|
|
||||||
content.generatedByIdiomorph = true;
|
|
||||||
return content;
|
|
||||||
} else {
|
|
||||||
// otherwise return the html element as the parent container
|
|
||||||
let htmlElement = content.firstChild;
|
|
||||||
if (htmlElement) {
|
|
||||||
htmlElement.generatedByIdiomorph = true;
|
|
||||||
return htmlElement;
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
|
|
||||||
// deal with touchy tags like tr, tbody, etc.
|
|
||||||
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
|
|
||||||
let content = responseDoc.body.querySelector('template').content;
|
|
||||||
content.generatedByIdiomorph = true;
|
|
||||||
return content
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeContent(newContent) {
|
|
||||||
if (newContent == null) {
|
|
||||||
// noinspection UnnecessaryLocalVariableJS
|
|
||||||
const dummyParent = document.createElement('div');
|
|
||||||
return dummyParent;
|
|
||||||
} else if (newContent.generatedByIdiomorph) {
|
|
||||||
// the template tag created by idiomorph parsing can serve as a dummy parent
|
|
||||||
return newContent;
|
|
||||||
} else if (newContent instanceof Node) {
|
|
||||||
// a single node is added as a child to a dummy parent
|
|
||||||
const dummyParent = document.createElement('div');
|
|
||||||
dummyParent.append(newContent);
|
|
||||||
return dummyParent;
|
|
||||||
} else {
|
|
||||||
// all nodes in the array or HTMLElement collection are consolidated under
|
|
||||||
// a single dummy parent element
|
|
||||||
const dummyParent = document.createElement('div');
|
|
||||||
for (const elt of [...newContent]) {
|
|
||||||
dummyParent.append(elt);
|
|
||||||
}
|
|
||||||
return dummyParent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertSiblings(previousSibling, morphedNode, nextSibling) {
|
|
||||||
let stack = []
|
|
||||||
let added = []
|
|
||||||
while (previousSibling != null) {
|
|
||||||
stack.push(previousSibling);
|
|
||||||
previousSibling = previousSibling.previousSibling;
|
|
||||||
}
|
|
||||||
while (stack.length > 0) {
|
|
||||||
let node = stack.pop();
|
|
||||||
added.push(node); // push added preceding siblings on in order and insert
|
|
||||||
morphedNode.parentElement.insertBefore(node, morphedNode);
|
|
||||||
}
|
|
||||||
added.push(morphedNode);
|
|
||||||
while (nextSibling != null) {
|
|
||||||
stack.push(nextSibling);
|
|
||||||
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
|
|
||||||
nextSibling = nextSibling.nextSibling;
|
|
||||||
}
|
|
||||||
while (stack.length > 0) {
|
|
||||||
morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
|
|
||||||
}
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findBestNodeMatch(newContent, oldNode, ctx) {
|
|
||||||
let currentElement;
|
|
||||||
currentElement = newContent.firstChild;
|
|
||||||
let bestElement = currentElement;
|
|
||||||
let score = 0;
|
|
||||||
while (currentElement) {
|
|
||||||
let newScore = scoreElement(currentElement, oldNode, ctx);
|
|
||||||
if (newScore > score) {
|
|
||||||
bestElement = currentElement;
|
|
||||||
score = newScore;
|
|
||||||
}
|
|
||||||
currentElement = currentElement.nextSibling;
|
|
||||||
}
|
|
||||||
return bestElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function scoreElement(node1, node2, ctx) {
|
|
||||||
if (isSoftMatch(node1, node2)) {
|
|
||||||
return .5 + getIdIntersectionCount(ctx, node1, node2);
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeNode(tempNode, ctx) {
|
|
||||||
removeIdsFromConsideration(ctx, tempNode)
|
|
||||||
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
|
|
||||||
|
|
||||||
tempNode.remove();
|
|
||||||
ctx.callbacks.afterNodeRemoved(tempNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// ID Set Functions
|
|
||||||
//=============================================================================
|
|
||||||
|
|
||||||
function isIdInConsideration(ctx, id) {
|
|
||||||
return !ctx.deadIds.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function idIsWithinNode(ctx, id, targetNode) {
|
|
||||||
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
|
|
||||||
return idSet.has(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeIdsFromConsideration(ctx, node) {
|
|
||||||
let idSet = ctx.idMap.get(node) || EMPTY_SET;
|
|
||||||
for (const id of idSet) {
|
|
||||||
ctx.deadIds.add(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIdIntersectionCount(ctx, node1, node2) {
|
|
||||||
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
|
|
||||||
let matchCount = 0;
|
|
||||||
for (const id of sourceSet) {
|
|
||||||
// a potential match is an id in the source and potentialIdsSet, but
|
|
||||||
// that has not already been merged into the DOM
|
|
||||||
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
|
|
||||||
++matchCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return matchCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A bottom up algorithm that finds all elements with ids inside of the node
|
|
||||||
* argument and populates id sets for those nodes and all their parents, generating
|
|
||||||
* a set of ids contained within all nodes for the entire hierarchy in the DOM
|
|
||||||
*
|
|
||||||
* @param node {Element}
|
|
||||||
* @param {Map<Node, Set<String>>} idMap
|
|
||||||
*/
|
|
||||||
function populateIdMapForNode(node, idMap) {
|
|
||||||
let nodeParent = node.parentElement;
|
|
||||||
// find all elements with an id property
|
|
||||||
let idElements = node.querySelectorAll('[id]');
|
|
||||||
for (const elt of idElements) {
|
|
||||||
let current = elt;
|
|
||||||
// walk up the parent hierarchy of that element, adding the id
|
|
||||||
// of element to the parent's id set
|
|
||||||
while (current !== nodeParent && current != null) {
|
|
||||||
let idSet = idMap.get(current);
|
|
||||||
// if the id set doesn't exist, create it and insert it in the map
|
|
||||||
if (idSet == null) {
|
|
||||||
idSet = new Set();
|
|
||||||
idMap.set(current, idSet);
|
|
||||||
}
|
|
||||||
idSet.add(elt.id);
|
|
||||||
current = current.parentElement;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* This function computes a map of nodes to all ids contained within that node (inclusive of the
|
|
||||||
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
|
|
||||||
* for a looser definition of "matching" than tradition id matching, and allows child nodes
|
|
||||||
* to contribute to a parent nodes matching.
|
|
||||||
*
|
|
||||||
* @param {Element} oldContent the old content that will be morphed
|
|
||||||
* @param {Element} newContent the new content to morph to
|
|
||||||
* @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
|
|
||||||
*/
|
|
||||||
function createIdMap(oldContent, newContent) {
|
|
||||||
let idMap = new Map();
|
|
||||||
populateIdMapForNode(oldContent, idMap);
|
|
||||||
populateIdMapForNode(newContent, idMap);
|
|
||||||
return idMap;
|
|
||||||
}
|
|
||||||
|
|
||||||
//=============================================================================
|
|
||||||
// This is what ends up becoming the Idiomorph global object
|
|
||||||
//=============================================================================
|
|
||||||
return {
|
|
||||||
morph,
|
|
||||||
defaults
|
|
||||||
}
|
|
||||||
})();
|
|
12
app/assets/javascripts/lib/tweenjs.min.js
vendored
12
app/assets/javascripts/lib/tweenjs.min.js
vendored
File diff suppressed because one or more lines are too long
|
@ -1,221 +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);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tell the CSS our first frame has rendered, which we use for loading
|
|
||||||
// state transitions.
|
|
||||||
this.#internals.states.add("after-first-frame");
|
|
||||||
}
|
|
||||||
|
|
||||||
#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,14 +1,5 @@
|
||||||
var DEBUG = document.location.search.substr(0, 6) == "?debug";
|
var DEBUG = document.location.search.substr(0, 6) == "?debug";
|
||||||
|
|
||||||
function petThumbnailUrl(pet_name) {
|
|
||||||
// if first character is "@", use the hash url
|
|
||||||
if (pet_name[0] == "@") {
|
|
||||||
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Needed items form */
|
/* Needed items form */
|
||||||
(function () {
|
(function () {
|
||||||
var UI = {};
|
var UI = {};
|
||||||
|
@ -74,6 +65,10 @@ function petThumbnailUrl(pet_name) {
|
||||||
loadItems(data.query);
|
loadItems(data.query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function petThumbnailUrl(pet_name) {
|
||||||
|
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
|
||||||
|
}
|
||||||
|
|
||||||
/* Items */
|
/* Items */
|
||||||
|
|
||||||
function loadItems(query) {
|
function loadItems(query) {
|
||||||
|
@ -132,7 +127,7 @@ function petThumbnailUrl(pet_name) {
|
||||||
|
|
||||||
function Pet(name) {
|
function Pet(name) {
|
||||||
var el = $("#bulk-pets-submission-template")
|
var el = $("#bulk-pets-submission-template")
|
||||||
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
|
.tmpl({ pet_name: name })
|
||||||
.appendTo(queue_el);
|
.appendTo(queue_el);
|
||||||
|
|
||||||
this.load = function () {
|
this.load = function () {
|
||||||
|
|
|
@ -1,356 +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;
|
|
||||||
|
|
||||||
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.
|
|
||||||
//
|
|
||||||
// TODO: I guess the manifest has these too, so we could put them in preload
|
|
||||||
// meta tags to get them here faster?
|
|
||||||
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
|
|
||||||
const manifestImages = new Map(
|
|
||||||
library.properties.manifest.map(({ id, src }) => [
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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("resize", () => {
|
|
||||||
updateCanvasDimensions();
|
|
||||||
|
|
||||||
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
|
|
||||||
// to `false`, so that we don't advance by a frame. This keeps us
|
|
||||||
// really-paused if we're paused, and avoids skipping ahead by a frame if
|
|
||||||
// we're playing.
|
|
||||||
stage.tickOnUpdate = false;
|
|
||||||
updateStage();
|
|
||||||
stage.tickOnUpdate = true;
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("message", ({ data }) => {
|
|
||||||
// NOTE: For more sensitive messages, it's important for security to also
|
|
||||||
// check the `origin` property of the incoming event. But in this case, I'm
|
|
||||||
// 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)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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.");
|
|
||||||
});
|
|
|
@ -5,15 +5,9 @@ body.items-index, body.items-show, body.items-needed, body.item_trades
|
||||||
|
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
.item-search-form
|
input[type=text]
|
||||||
display: flex
|
font-size: 125%
|
||||||
gap: .5em
|
width: 15em
|
||||||
justify-content: center
|
|
||||||
|
|
||||||
input[type=text]
|
|
||||||
font-size: 125%
|
|
||||||
width: 15em
|
|
||||||
flex: 0 1 auto
|
|
||||||
|
|
||||||
h1
|
h1
|
||||||
margin-bottom: 1em
|
margin-bottom: 1em
|
||||||
|
|
|
@ -1,12 +0,0 @@
|
||||||
body.use-responsive-design
|
|
||||||
#container
|
|
||||||
max-width: 100%
|
|
||||||
padding-inline: 1rem
|
|
||||||
box-sizing: border-box
|
|
||||||
|
|
||||||
#home-link
|
|
||||||
margin-left: 1rem
|
|
||||||
padding-inline: 0
|
|
||||||
|
|
||||||
#userbar
|
|
||||||
margin-right: 1rem
|
|
|
@ -4,7 +4,6 @@
|
||||||
@import partials/clean/mixins
|
@import partials/clean/mixins
|
||||||
|
|
||||||
@import layout
|
@import layout
|
||||||
@import responsive
|
|
||||||
|
|
||||||
@import partials/jquery.jgrowl
|
@import partials/jquery.jgrowl
|
||||||
|
|
||||||
|
@ -21,4 +20,5 @@
|
||||||
@import outfits/index
|
@import outfits/index
|
||||||
@import outfits/new
|
@import outfits/new
|
||||||
@import pets/bulk
|
@import pets/bulk
|
||||||
|
@import swf_assets/links
|
||||||
@import users/top_contributors
|
@import users/top_contributors
|
||||||
|
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,9 +3,6 @@
|
||||||
@import "../partials/item_header"
|
@import "../partials/item_header"
|
||||||
|
|
||||||
body.items-show
|
body.items-show
|
||||||
#container
|
|
||||||
width: 900px // A bit more generous to the preview area!
|
|
||||||
|
|
||||||
.item-header
|
.item-header
|
||||||
+item-header
|
+item-header
|
||||||
|
|
||||||
|
@ -40,345 +37,3 @@ body.items-show
|
||||||
.nc-icon
|
.nc-icon
|
||||||
height: 16px
|
height: 16px
|
||||||
width: 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: .75em
|
|
||||||
border-radius: .375em
|
|
||||||
min-height: 2rem
|
|
||||||
min-width: 2rem
|
|
||||||
box-sizing: border-box
|
|
||||||
|
|
||||||
outfit-viewer
|
|
||||||
display: block
|
|
||||||
position: relative
|
|
||||||
width: 300px
|
|
||||||
height: 300px
|
|
||||||
border: 1px solid $module-border-color
|
|
||||||
border-radius: 1em
|
|
||||||
overflow: hidden
|
|
||||||
|
|
||||||
// There's no useful text in here, but double-clicking the play/pause
|
|
||||||
// button can cause a weird selection state. Disable text selection.
|
|
||||||
user-select: none
|
|
||||||
-webkit-user-select: none
|
|
||||||
|
|
||||||
outfit-layer
|
|
||||||
display: block
|
|
||||||
position: absolute
|
|
||||||
inset: 0
|
|
||||||
|
|
||||||
// We disable pointer-events most importantly for the iframes, which
|
|
||||||
// will ignore our `cursor: wait` and show a plain cursor for the
|
|
||||||
// inside of its own document. But also, the context menus for these
|
|
||||||
// elements are kinda actively misleading, too!
|
|
||||||
pointer-events: none
|
|
||||||
|
|
||||||
img, iframe
|
|
||||||
width: 100%
|
|
||||||
height: 100%
|
|
||||||
|
|
||||||
.loading-indicator
|
|
||||||
position: absolute
|
|
||||||
z-index: 1000
|
|
||||||
bottom: 0px
|
|
||||||
right: 4px
|
|
||||||
padding: 8px
|
|
||||||
background: radial-gradient(circle closest-side, white 45%, #ffffff00)
|
|
||||||
|
|
||||||
opacity: 0
|
|
||||||
transition: opacity .5s
|
|
||||||
|
|
||||||
.play-pause-button
|
|
||||||
position: absolute
|
|
||||||
z-index: 1001
|
|
||||||
left: 8px
|
|
||||||
bottom: 8px
|
|
||||||
display: none
|
|
||||||
align-items: center
|
|
||||||
justify-content: center
|
|
||||||
color: white
|
|
||||||
background: rgba(0, 0, 0, 0.64)
|
|
||||||
width: 2.5em
|
|
||||||
height: 2.5em
|
|
||||||
border-radius: 100%
|
|
||||||
border: 2px solid transparent
|
|
||||||
transition: all .25s
|
|
||||||
|
|
||||||
.playing-label, .paused-label
|
|
||||||
display: none
|
|
||||||
width: 1em
|
|
||||||
height: 1em
|
|
||||||
|
|
||||||
.play-pause-toggle
|
|
||||||
// Visually hidden
|
|
||||||
clip: rect(0 0 0 0)
|
|
||||||
clip-path: inset(50%)
|
|
||||||
height: 1px
|
|
||||||
overflow: hidden
|
|
||||||
position: absolute
|
|
||||||
white-space: nowrap
|
|
||||||
width: 1px
|
|
||||||
|
|
||||||
&:checked ~ .playing-label
|
|
||||||
display: block
|
|
||||||
|
|
||||||
&:not(:checked) ~ .paused-label
|
|
||||||
display: block
|
|
||||||
|
|
||||||
&:hover, &:has(.play-pause-toggle:focus)
|
|
||||||
border: 2px solid $module-border-color
|
|
||||||
background: $module-bg-color
|
|
||||||
color: $text-color
|
|
||||||
|
|
||||||
&:has(.play-pause-toggle:active)
|
|
||||||
transform: translateY(2px)
|
|
||||||
|
|
||||||
&:has(outfit-layer:state(has-animations))
|
|
||||||
.play-pause-button
|
|
||||||
display: flex
|
|
||||||
|
|
||||||
.error-indicator
|
|
||||||
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. We also wait for the outfit-viewer to
|
|
||||||
// execute a `setTimeout(0)`, to make sure we always *start* in the
|
|
||||||
// non-loading state. This is because it's sometimes possible for the page to
|
|
||||||
// start with the web component already in `state(loading)`, and we need to
|
|
||||||
// make sure we *start* in *non-loading* state for the transition delay to
|
|
||||||
// happen. (This can happen when you Turbo-navigate between multiple items.)
|
|
||||||
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
|
|
||||||
cursor: wait
|
|
||||||
|
|
||||||
&:state(after-first-frame)
|
|
||||||
.loading-indicator
|
|
||||||
opacity: 1
|
|
||||||
transition-delay: 2s
|
|
||||||
|
|
||||||
#item-preview:has(outfit-layer:state(error))
|
|
||||||
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
|
|
||||||
|
|
|
@ -7,8 +7,9 @@ body.outfits-new
|
||||||
#pet-not-found
|
#pet-not-found
|
||||||
display: none
|
display: none
|
||||||
|
|
||||||
.announcement
|
.neopass-announcement
|
||||||
border: 1px solid $module-border-color
|
border: 1px solid #cd8400
|
||||||
|
color: #764a00
|
||||||
padding: .5em
|
padding: .5em
|
||||||
display: grid
|
display: grid
|
||||||
grid-template-areas: "thumbnail content"
|
grid-template-areas: "thumbnail content"
|
||||||
|
@ -23,6 +24,9 @@ body.outfits-new
|
||||||
p:last-of-type
|
p:last-of-type
|
||||||
margin-bottom: 0
|
margin-bottom: 0
|
||||||
|
|
||||||
|
a
|
||||||
|
color: #be7a00
|
||||||
|
|
||||||
#outfit-forms
|
#outfit-forms
|
||||||
+clearfix
|
+clearfix
|
||||||
+module
|
+module
|
||||||
|
|
|
@ -35,7 +35,6 @@
|
||||||
text-align: left
|
text-align: left
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
flex-wrap: wrap
|
|
||||||
gap: 1em
|
gap: 1em
|
||||||
|
|
||||||
abbr
|
abbr
|
||||||
|
@ -128,7 +127,6 @@
|
||||||
.item-subpages-nav
|
.item-subpages-nav
|
||||||
display: flex
|
display: flex
|
||||||
align-items: flex-end
|
align-items: flex-end
|
||||||
gap: 1em
|
|
||||||
|
|
||||||
.preview-link
|
.preview-link
|
||||||
margin-right: auto
|
margin-right: auto
|
||||||
|
|
10
app/assets/stylesheets/swf_assets/_links.sass
Normal file
10
app/assets/stylesheets/swf_assets/_links.sass
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
@import "../partials/assets-list"
|
||||||
|
|
||||||
|
body.swf_assets-links
|
||||||
|
#swf-assets
|
||||||
|
+assets-list
|
||||||
|
|
||||||
|
li
|
||||||
|
span
|
||||||
|
font-size: 75%
|
||||||
|
word-wrap: break-word
|
|
@ -1,12 +0,0 @@
|
||||||
#asset-canvas,
|
|
||||||
#asset-image,
|
|
||||||
#fallback {
|
|
||||||
position: absolute;
|
|
||||||
left: 0;
|
|
||||||
top: 0;
|
|
||||||
|
|
||||||
/* HACK: `calc` isn't needed, but works around a bug in our asset pipeline,
|
|
||||||
* where libsass is trying to preprocess it. (We're not SASS tho?) */
|
|
||||||
width: calc(min(100vw, 100vh));
|
|
||||||
height: calc(min(100vw, 100vh));
|
|
||||||
}
|
|
|
@ -28,12 +28,6 @@ class ItemsController < ApplicationController
|
||||||
render json: {
|
render json: {
|
||||||
items: @items.as_json(
|
items: @items.as_json(
|
||||||
methods: [:nc?, :pb?, :owned?, :wanted?],
|
methods: [:nc?, :pb?, :owned?, :wanted?],
|
||||||
include: {
|
|
||||||
restricted_zones: {
|
|
||||||
only: [:id, :depth, :label],
|
|
||||||
methods: [:is_commonly_used_by_items],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
appearances: load_appearances.as_json(
|
appearances: load_appearances.as_json(
|
||||||
include: {
|
include: {
|
||||||
|
@ -88,21 +82,6 @@ class ItemsController < ApplicationController
|
||||||
group_by_owned
|
group_by_owned
|
||||||
@current_user_quantities = current_user.item_quantities_for(@item)
|
@current_user_quantities = current_user.item_quantities_for(@item)
|
||||||
end
|
end
|
||||||
|
|
||||||
@selected_preview_pet_type = load_selected_preview_pet_type
|
|
||||||
@preview_outfit = Outfit.new(
|
|
||||||
pet_state: load_preview_pet_type.canonical_pet_state,
|
|
||||||
worn_items: [@item],
|
|
||||||
)
|
|
||||||
@preview_error = validate_preview
|
|
||||||
|
|
||||||
@all_appearances = @item.appearances
|
|
||||||
@appearances_by_occupied_zone = @item.appearances_by_occupied_zone.
|
|
||||||
sort_by { |z, a| z.label }
|
|
||||||
@selected_item_appearance = @preview_outfit.item_appearances.first
|
|
||||||
|
|
||||||
@preview_pet_type_options = PetType.where(color: @preview_outfit.color).
|
|
||||||
includes(:species).merge(Species.alphabetical)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
format.gif do
|
format.gif do
|
||||||
|
@ -201,7 +180,7 @@ class ItemsController < ApplicationController
|
||||||
appearance_params[:color_id], appearance_params[:species_id])
|
appearance_params[:color_id], appearance_params[:species_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
target.appearances_for(@items, swf_asset_includes: [:zone]).
|
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]).
|
||||||
tap do |appearances|
|
tap do |appearances|
|
||||||
# Preload the manifests for these SWF assets concurrently, rather than
|
# Preload the manifests for these SWF assets concurrently, rather than
|
||||||
# loading them in sequence when we generate the JSON.
|
# loading them in sequence when we generate the JSON.
|
||||||
|
@ -210,43 +189,6 @@ class ItemsController < ApplicationController
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_selected_preview_pet_type
|
|
||||||
color_id = params.dig(:preview, :color_id)
|
|
||||||
species_id = params.dig(:preview, :species_id)
|
|
||||||
|
|
||||||
return load_default_preview_pet_type if color_id.nil? || species_id.nil?
|
|
||||||
|
|
||||||
PetType.find_or_initialize_by(color_id:, species_id:).tap do |pet_type|
|
|
||||||
if pet_type.persisted?
|
|
||||||
cookies["preferred-preview-color-id"] = color_id
|
|
||||||
cookies["preferred-preview-species-id"] = species_id
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_preview_pet_type
|
|
||||||
if @selected_preview_pet_type.persisted?
|
|
||||||
@selected_preview_pet_type
|
|
||||||
else
|
|
||||||
load_default_preview_pet_type
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def load_default_preview_pet_type
|
|
||||||
@item.compatible_pet_types.
|
|
||||||
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
|
|
||||||
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
|
|
||||||
preferring_simple.first
|
|
||||||
end
|
|
||||||
|
|
||||||
def validate_preview
|
|
||||||
if @selected_preview_pet_type.new_record?
|
|
||||||
:pet_type_does_not_exist
|
|
||||||
elsif @preview_outfit.item_appearances.any?(&:empty?)
|
|
||||||
:no_item_data
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def search_error(e)
|
def search_error(e)
|
||||||
@items = []
|
@items = []
|
||||||
@query = params[:q]
|
@query = params[:q]
|
||||||
|
|
|
@ -1,44 +0,0 @@
|
||||||
class SwfAssetsController < ApplicationController
|
|
||||||
# We're very careful with what content is allowed to load. This is because
|
|
||||||
# asset movies run arbitrary JS, and, while we generally trust content from
|
|
||||||
# Neopets.com, let's not be *allowing* movie JS to do whatever it wants! This
|
|
||||||
# is a good default security stance, even if we don't foresee an attack.
|
|
||||||
content_security_policy do |policy|
|
|
||||||
policy.sandbox "allow-scripts"
|
|
||||||
policy.default_src "none"
|
|
||||||
|
|
||||||
policy.img_src -> {
|
|
||||||
src_list(
|
|
||||||
helpers.image_url("favicon.png"),
|
|
||||||
@swf_asset.image_url,
|
|
||||||
*@swf_asset.canvas_movie_sprite_urls,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
policy.script_src -> {
|
|
||||||
src_list(
|
|
||||||
helpers.javascript_url("lib/easeljs.min"),
|
|
||||||
helpers.javascript_url("lib/tweenjs.min"),
|
|
||||||
helpers.javascript_url("swf_assets/show"),
|
|
||||||
@swf_asset.canvas_movie_library_url,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
policy.style_src -> {
|
|
||||||
src_list(
|
|
||||||
helpers.stylesheet_url("swf_assets/show"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def show
|
|
||||||
@swf_asset = SwfAsset.find params[:id]
|
|
||||||
render layout: nil
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
def src_list(*urls)
|
|
||||||
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -101,12 +101,6 @@ module ApplicationHelper
|
||||||
"matchu@openneo.net"
|
"matchu@openneo.net"
|
||||||
end
|
end
|
||||||
|
|
||||||
EDIT_ICON_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></g>'.html_safe
|
|
||||||
def edit_icon(alt: "Edit")
|
|
||||||
content_tag :svg, EDIT_ICON_SVG_SOURCE, alt:, class: "icon",
|
|
||||||
viewBox: "0 0 24 24", style: "width: 1em; height: 1em"
|
|
||||||
end
|
|
||||||
|
|
||||||
# SVG icon source from Chakra UI!
|
# SVG icon source from Chakra UI!
|
||||||
EXTERNAL_LINK_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></g>'.html_safe
|
EXTERNAL_LINK_SVG_SOURCE = '<g fill="none" stroke="currentColor" stroke-linecap="round" stroke-width="2"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14L21 3"></path></g>'.html_safe
|
||||||
def external_link_icon
|
def external_link_icon
|
||||||
|
@ -237,15 +231,6 @@ module ApplicationHelper
|
||||||
@hide_title_header = true
|
@hide_title_header = true
|
||||||
end
|
end
|
||||||
|
|
||||||
def use_responsive_design
|
|
||||||
@use_responsive_design = true
|
|
||||||
add_body_class "use-responsive-design"
|
|
||||||
end
|
|
||||||
|
|
||||||
def use_responsive_design?
|
|
||||||
@use_responsive_design || false
|
|
||||||
end
|
|
||||||
|
|
||||||
def signed_in_meta_tag
|
def signed_in_meta_tag
|
||||||
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
|
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
|
||||||
end
|
end
|
||||||
|
|
|
@ -31,14 +31,11 @@ module ItemsHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def standard_species_search_links
|
def standard_species_search_links
|
||||||
all_species = Species.alphabetical.map(&:id)
|
build_on_pet_types(Species.alphabetical) do |pet_type|
|
||||||
PetType.random_basic_per_species(all_species).map do |pet_type|
|
image = pet_type_image(pet_type, :happy, :zoom)
|
||||||
human_name = pet_type.species.human_name
|
|
||||||
image = pet_type_image pet_type, :happy, :zoom,
|
|
||||||
alt: human_name, title: human_name
|
|
||||||
query = "species:#{pet_type.species.name}"
|
query = "species:#{pet_type.species.name}"
|
||||||
link_to(image, items_path(:q => query))
|
link_to(image, items_path(:q => query))
|
||||||
end.join.html_safe
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def closet_list_verb(owned)
|
def closet_list_verb(owned)
|
||||||
|
@ -116,6 +113,13 @@ module ItemsHelper
|
||||||
SHOP_WIZARD_URL_TEMPLATE.expand(string: item_or_name).to_s
|
SHOP_WIZARD_URL_TEMPLATE.expand(string: item_or_name).to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
|
SUPER_SHOP_WIZARD_URL_TEMPLATE = Addressable::Template.new(
|
||||||
|
"https://www.neopets.com/portal/supershopwiz.phtml{?string}"
|
||||||
|
)
|
||||||
|
def super_shop_wizard_url_for(item)
|
||||||
|
SUPER_SHOP_WIZARD_URL_TEMPLATE.expand(string: item.name).to_s
|
||||||
|
end
|
||||||
|
|
||||||
TRADING_POST_URL_TEMPLATE = Addressable::Template.new(
|
TRADING_POST_URL_TEMPLATE = Addressable::Template.new(
|
||||||
"https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact{&search_string}"
|
"https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact{&search_string}"
|
||||||
)
|
)
|
||||||
|
@ -220,37 +224,21 @@ module ItemsHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def outfit_viewer_is_playing
|
private
|
||||||
cookies["DTIOutfitViewerIsPlaying"] == "true"
|
|
||||||
|
def build_on_pet_types(species, special_color=nil, &block)
|
||||||
|
species_ids = species.map(&:id)
|
||||||
|
pet_types = special_color ?
|
||||||
|
PetType.where(:color_id => special_color.id, :species_id => species_ids).
|
||||||
|
order(:species_id) :
|
||||||
|
PetType.random_basic_per_species(species.map(&:id))
|
||||||
|
pet_types.map(&block).join.html_safe
|
||||||
end
|
end
|
||||||
|
|
||||||
def item_fits?(item, pet_type)
|
def pet_type_image(pet_type, emotion, size)
|
||||||
item.appearances.any? { |a| a.fits? pet_type }
|
|
||||||
end
|
|
||||||
|
|
||||||
def species_face_tooltip(pet_type, item)
|
|
||||||
if item_fits?(item, pet_type)
|
|
||||||
"#{pet_type.species.human_name}"
|
|
||||||
else
|
|
||||||
"#{pet_type.species.human_name}: No data yet"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def item_zone_partial_fit?(appearances_in_zone, all_appearances)
|
|
||||||
appearances_in_zone.size < all_appearances.size
|
|
||||||
end
|
|
||||||
|
|
||||||
def item_zone_species_list(appearances_in_zone)
|
|
||||||
appearances_in_zone.map(&:species).uniq.map(&:human_name).sort.join(", ")
|
|
||||||
end
|
|
||||||
|
|
||||||
def pet_type_image(pet_type, emotion, size, **options)
|
|
||||||
src = pet_type_image_url(pet_type, emotion:, size:)
|
src = pet_type_image_url(pet_type, emotion:, size:)
|
||||||
srcset = if size == :face
|
human_name = pet_type.species.name.humanize
|
||||||
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
|
image_tag(src, :alt => human_name, :title => human_name)
|
||||||
end
|
|
||||||
|
|
||||||
image_tag(src, srcset:, **options)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def item_header_user_lists_form_state
|
def item_header_user_lists_form_state
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
module OutfitsHelper
|
module OutfitsHelper
|
||||||
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-09-13")
|
LAST_DAY_OF_NEOPASS_ANNOUNCEMENT = Date.parse("2024-05-05")
|
||||||
def show_announcement?
|
def show_neopass_announcement?
|
||||||
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
|
Date.today <= LAST_DAY_OF_NEOPASS_ANNOUNCEMENT
|
||||||
end
|
end
|
||||||
|
|
||||||
def destination_tag(value)
|
def destination_tag(value)
|
||||||
|
|
15
app/javascript/item-page.js
Normal file
15
app/javascript/item-page.js
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom";
|
||||||
|
|
||||||
|
import { AppProvider, ItemPageOutfitPreview } from "./wardrobe-2020";
|
||||||
|
|
||||||
|
const rootNode = document.querySelector("#outfit-preview-root");
|
||||||
|
const itemId = rootNode.getAttribute("data-item-id");
|
||||||
|
// TODO: Use the new React 18 APIs instead!
|
||||||
|
// eslint-disable-next-line react/no-deprecated
|
||||||
|
ReactDOM.render(
|
||||||
|
<AppProvider>
|
||||||
|
<ItemPageOutfitPreview itemId={itemId} />
|
||||||
|
</AppProvider>,
|
||||||
|
rootNode,
|
||||||
|
);
|
905
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal file
905
app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js
Normal file
|
@ -0,0 +1,905 @@
|
||||||
|
import React from "react";
|
||||||
|
import { ClassNames } from "@emotion/react";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Tooltip,
|
||||||
|
useColorModeValue,
|
||||||
|
useToken,
|
||||||
|
Wrap,
|
||||||
|
WrapItem,
|
||||||
|
Flex,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { WarningTwoIcon } from "@chakra-ui/icons";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
|
||||||
|
function SpeciesFacesPicker({
|
||||||
|
selectedSpeciesId,
|
||||||
|
selectedColorId,
|
||||||
|
compatibleBodies,
|
||||||
|
couldProbablyModelMoreData,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
}) {
|
||||||
|
// For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded
|
||||||
|
// data, which is part of the bundle and loads super-fast. For other colors,
|
||||||
|
// we load in all the faces of that color, falling back to basic colors when
|
||||||
|
// absent!
|
||||||
|
//
|
||||||
|
// TODO: Could we move this into our `build-cached-data` script, and just do
|
||||||
|
// the query all the time, and have Apollo happen to satisfy it fast?
|
||||||
|
// The semantics of returning our colorful random set could be weird…
|
||||||
|
const selectedColorIsBasic = colorIsBasic(selectedColorId);
|
||||||
|
const {
|
||||||
|
loading: loadingGQL,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
} = useQuery(
|
||||||
|
gql`
|
||||||
|
query SpeciesFacesPicker($selectedColorId: ID!) {
|
||||||
|
color(id: $selectedColorId) {
|
||||||
|
id
|
||||||
|
appliedToAllCompatibleSpecies {
|
||||||
|
id
|
||||||
|
neopetsImageHash
|
||||||
|
species {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: { selectedColorId },
|
||||||
|
skip: selectedColorId == null || selectedColorIsBasic,
|
||||||
|
onError: (e) => console.error(e),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const allBodiesAreCompatible = compatibleBodies.some(
|
||||||
|
(body) => body.id === "0",
|
||||||
|
);
|
||||||
|
const compatibleBodyIds = compatibleBodies.map((body) => body.id);
|
||||||
|
|
||||||
|
const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || [];
|
||||||
|
|
||||||
|
const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => {
|
||||||
|
const providedSpeciesFace = speciesFacesFromData.find(
|
||||||
|
(f) => f.species.id === defaultSpeciesFace.speciesId,
|
||||||
|
);
|
||||||
|
if (providedSpeciesFace) {
|
||||||
|
return {
|
||||||
|
...defaultSpeciesFace,
|
||||||
|
colorId: selectedColorId,
|
||||||
|
bodyId: providedSpeciesFace.body.id,
|
||||||
|
// If this species/color pair exists, but without an image hash, then
|
||||||
|
// we want to provide a face so that it's enabled, but use the fallback
|
||||||
|
// image even though it's wrong, so that it looks like _something_.
|
||||||
|
neopetsImageHash:
|
||||||
|
providedSpeciesFace.neopetsImageHash ||
|
||||||
|
defaultSpeciesFace.neopetsImageHash,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return defaultSpeciesFace;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Wrap spacing="0" justify="center">
|
||||||
|
{allSpeciesFaces.map((speciesFace) => (
|
||||||
|
<WrapItem key={speciesFace.speciesId}>
|
||||||
|
<SpeciesFaceOption
|
||||||
|
speciesId={speciesFace.speciesId}
|
||||||
|
speciesName={speciesFace.speciesName}
|
||||||
|
colorId={speciesFace.colorId}
|
||||||
|
neopetsImageHash={speciesFace.neopetsImageHash}
|
||||||
|
isSelected={speciesFace.speciesId === selectedSpeciesId}
|
||||||
|
// If the face color doesn't match the current color, this is a
|
||||||
|
// fallback face for an invalid species/color pair.
|
||||||
|
isValid={
|
||||||
|
speciesFace.colorId === selectedColorId || selectedColorIsBasic
|
||||||
|
}
|
||||||
|
bodyIsCompatible={
|
||||||
|
allBodiesAreCompatible ||
|
||||||
|
compatibleBodyIds.includes(speciesFace.bodyId)
|
||||||
|
}
|
||||||
|
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||||
|
onChange={onChange}
|
||||||
|
isLoading={isLoading || loadingGQL}
|
||||||
|
/>
|
||||||
|
</WrapItem>
|
||||||
|
))}
|
||||||
|
</Wrap>
|
||||||
|
{error && (
|
||||||
|
<Flex
|
||||||
|
color="yellow.500"
|
||||||
|
fontSize="xs"
|
||||||
|
marginTop="1"
|
||||||
|
textAlign="center"
|
||||||
|
width="100%"
|
||||||
|
align="flex-start"
|
||||||
|
justify="center"
|
||||||
|
>
|
||||||
|
<WarningTwoIcon marginTop="0.4em" marginRight="1" />
|
||||||
|
<Box>
|
||||||
|
Error loading this color's pet photos.
|
||||||
|
<br />
|
||||||
|
Check your connection and try again.
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const SpeciesFaceOption = React.memo(
|
||||||
|
({
|
||||||
|
speciesId,
|
||||||
|
speciesName,
|
||||||
|
colorId,
|
||||||
|
neopetsImageHash,
|
||||||
|
isSelected,
|
||||||
|
bodyIsCompatible,
|
||||||
|
isValid,
|
||||||
|
couldProbablyModelMoreData,
|
||||||
|
onChange,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
const selectedBorderColor = useColorModeValue("green.600", "green.400");
|
||||||
|
const selectedBackgroundColor = useColorModeValue("green.200", "green.600");
|
||||||
|
const focusBorderColor = "blue.400";
|
||||||
|
const focusBackgroundColor = "blue.100";
|
||||||
|
const [
|
||||||
|
selectedBorderColorValue,
|
||||||
|
selectedBackgroundColorValue,
|
||||||
|
focusBorderColorValue,
|
||||||
|
focusBackgroundColorValue,
|
||||||
|
] = useToken("colors", [
|
||||||
|
selectedBorderColor,
|
||||||
|
selectedBackgroundColor,
|
||||||
|
focusBorderColor,
|
||||||
|
focusBackgroundColor,
|
||||||
|
]);
|
||||||
|
const xlShadow = useToken("shadows", "xl");
|
||||||
|
|
||||||
|
const [labelIsHovered, setLabelIsHovered] = React.useState(false);
|
||||||
|
const [inputIsFocused, setInputIsFocused] = React.useState(false);
|
||||||
|
|
||||||
|
const isDisabled = isLoading || !isValid || !bodyIsCompatible;
|
||||||
|
const isHappy = isLoading || (isValid && bodyIsCompatible);
|
||||||
|
const emotionId = isHappy ? "1" : "2";
|
||||||
|
const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer";
|
||||||
|
|
||||||
|
let disabledExplanation = null;
|
||||||
|
if (isLoading) {
|
||||||
|
// If we're still loading, don't try to explain anything yet!
|
||||||
|
} else if (!isValid) {
|
||||||
|
disabledExplanation = "(Can't be this color)";
|
||||||
|
} else if (!bodyIsCompatible) {
|
||||||
|
disabledExplanation = couldProbablyModelMoreData
|
||||||
|
? "(Item needs models)"
|
||||||
|
: "(Not compatible)";
|
||||||
|
}
|
||||||
|
|
||||||
|
const tooltipLabel = (
|
||||||
|
<div style={{ textAlign: "center" }}>
|
||||||
|
{speciesName}
|
||||||
|
{disabledExplanation && (
|
||||||
|
<div style={{ fontStyle: "italic", fontSize: "0.75em" }}>
|
||||||
|
{disabledExplanation}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOTE: Because we render quite a few of these, avoiding using Chakra
|
||||||
|
// elements like Box helps with render performance!
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<DeferredTooltip
|
||||||
|
label={tooltipLabel}
|
||||||
|
placement="top"
|
||||||
|
gutter={-10}
|
||||||
|
// We track hover and focus state manually for the tooltip, so that
|
||||||
|
// keyboard nav to switch between options causes the tooltip to
|
||||||
|
// follow. (By default, the tooltip appears on the first tab focus,
|
||||||
|
// but not when you _change_ options!)
|
||||||
|
isOpen={labelIsHovered || inputIsFocused}
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
style={{ cursor }}
|
||||||
|
onMouseEnter={() => setLabelIsHovered(true)}
|
||||||
|
onMouseLeave={() => setLabelIsHovered(false)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
aria-label={speciesName}
|
||||||
|
name="species-faces-picker"
|
||||||
|
value={speciesId}
|
||||||
|
checked={isSelected}
|
||||||
|
// It's possible to get this selected via the SpeciesColorPicker,
|
||||||
|
// even if this would normally be disabled. If so, make this
|
||||||
|
// option enabled, so keyboard users can focus and change it.
|
||||||
|
disabled={isDisabled && !isSelected}
|
||||||
|
onChange={() => onChange({ speciesId, colorId })}
|
||||||
|
onFocus={() => setInputIsFocused(true)}
|
||||||
|
onBlur={() => setInputIsFocused(false)}
|
||||||
|
className={css`
|
||||||
|
/* Copied from Chakra's <VisuallyHidden /> */
|
||||||
|
border: 0px;
|
||||||
|
clip: rect(0px, 0px, 0px, 0px);
|
||||||
|
height: 1px;
|
||||||
|
width: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
position: absolute;
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
input:checked + & {
|
||||||
|
background: ${selectedBackgroundColorValue};
|
||||||
|
border-radius: 6px;
|
||||||
|
box-shadow:
|
||||||
|
${xlShadow},
|
||||||
|
${selectedBorderColorValue} 0 0 2px 2px;
|
||||||
|
transform: scale(1.2);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus + & {
|
||||||
|
background: ${focusBackgroundColorValue};
|
||||||
|
box-shadow:
|
||||||
|
${xlShadow},
|
||||||
|
${focusBorderColorValue} 0 0 0 3px;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<CrossFadeImage
|
||||||
|
src={`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png`}
|
||||||
|
srcSet={
|
||||||
|
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` +
|
||||||
|
`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/6.png 2x`
|
||||||
|
}
|
||||||
|
alt={speciesName}
|
||||||
|
width={55}
|
||||||
|
height={55}
|
||||||
|
data-is-loading={isLoading}
|
||||||
|
data-is-disabled={isDisabled}
|
||||||
|
className={css`
|
||||||
|
filter: saturate(90%);
|
||||||
|
opacity: 0.9;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&[data-is-disabled="true"] {
|
||||||
|
filter: saturate(0%);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-is-loading="true"] {
|
||||||
|
animation: 0.8s linear 0s infinite alternate none running
|
||||||
|
pulse;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="false"] {
|
||||||
|
opacity: 1;
|
||||||
|
filter: saturate(110%);
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + * &[data-body-is-disabled="true"] {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse {
|
||||||
|
from {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Alt text for when the image fails to load! We hide it
|
||||||
|
* while still loading though! */
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-align: center;
|
||||||
|
&:-moz-loading {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
&:-moz-broken {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</DeferredTooltip>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CrossFadeImage is like <img>, but listens for successful load events, and
|
||||||
|
* fades from the previous image to the new image once it loads.
|
||||||
|
*
|
||||||
|
* We treat `src` as a unique key representing the image's identity, but we
|
||||||
|
* also carry along the rest of the props during the fade, like `srcSet` and
|
||||||
|
* `className`.
|
||||||
|
*/
|
||||||
|
function CrossFadeImage(incomingImageProps) {
|
||||||
|
const [prevImageProps, setPrevImageProps] = React.useState(null);
|
||||||
|
const [currentImageProps, setCurrentImageProps] = React.useState(null);
|
||||||
|
|
||||||
|
const incomingImageIsCurrentImage =
|
||||||
|
incomingImageProps.src === currentImageProps?.src;
|
||||||
|
|
||||||
|
const onLoadNextImage = () => {
|
||||||
|
setPrevImageProps(currentImageProps);
|
||||||
|
setCurrentImageProps(incomingImageProps);
|
||||||
|
};
|
||||||
|
|
||||||
|
// The main trick to this component is using React's `key` feature! When
|
||||||
|
// diffing the rendered tree, if React sees two nodes with the same `key`, it
|
||||||
|
// treats them as the same node and makes the prop changes to match.
|
||||||
|
//
|
||||||
|
// We usually use this in `.map`, to make sure that adds/removes in a list
|
||||||
|
// don't cause our children to shift around and swap their React state or DOM
|
||||||
|
// nodes with each other.
|
||||||
|
//
|
||||||
|
// But here, we use `key` to get React to transition the same <img> DOM node
|
||||||
|
// between 3 different states!
|
||||||
|
//
|
||||||
|
// The image starts its life as the last in the list, from
|
||||||
|
// `incomingImageProps`: it's invisible, and still loading. We use its `src`
|
||||||
|
// as the `key`.
|
||||||
|
//
|
||||||
|
// When it loads, we update the state so that this `key` now belongs to the
|
||||||
|
// _second_ node, from `currentImageProps`. React will see this and make the
|
||||||
|
// correct transition for us: it sets opacity to 0, sets z-index to 2,
|
||||||
|
// removes aria-hidden, and removes the `onLoad` handler.
|
||||||
|
//
|
||||||
|
// Then, when another image is ready to show, we update the state so that
|
||||||
|
// this key now belongs to the _first_ node, from `prevImageProps` (and the
|
||||||
|
// second node is showing something new). React sees this, and makes the
|
||||||
|
// transition back to invisibility, but without the `onLoad` handler this
|
||||||
|
// time! (And transitions the current image into view, like it did for this
|
||||||
|
// one.)
|
||||||
|
//
|
||||||
|
// Finally, when yet _another_ image is ready to show, we stop rendering any
|
||||||
|
// images with this key anymore, and so React unmounts the image entirely.
|
||||||
|
//
|
||||||
|
// Thanks, React, for handling our multiple overlapping transitions through
|
||||||
|
// this little state machine! This could have been a LOT harder to write,
|
||||||
|
// whew!
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "shared-overlapping-area";
|
||||||
|
isolation: isolate; /* Avoid z-index conflicts with parent! */
|
||||||
|
|
||||||
|
> div {
|
||||||
|
grid-area: shared-overlapping-area;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{prevImageProps && (
|
||||||
|
<div
|
||||||
|
key={prevImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 3;
|
||||||
|
opacity: 0;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img {...prevImageProps} aria-hidden />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentImageProps && (
|
||||||
|
<div
|
||||||
|
key={currentImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
{...currentImageProps}
|
||||||
|
// If the current image _is_ the incoming image, we'll allow
|
||||||
|
// new props to come in and affect it. But if it's a new image
|
||||||
|
// incoming, we want to stick to the last props the current
|
||||||
|
// image had! (This matters for e.g. `bodyIsCompatible`
|
||||||
|
// becoming true in `SpeciesFaceOption` and restoring color,
|
||||||
|
// before the new color's image loads in.)
|
||||||
|
{...(incomingImageIsCurrentImage ? incomingImageProps : {})}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!incomingImageIsCurrentImage && (
|
||||||
|
<div
|
||||||
|
key={incomingImageProps.src}
|
||||||
|
className={css`
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* eslint-disable-next-line jsx-a11y/alt-text */}
|
||||||
|
<img
|
||||||
|
{...incomingImageProps}
|
||||||
|
aria-hidden
|
||||||
|
onLoad={onLoadNextImage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is
|
||||||
|
* true before mounting it, and unmounts it after closing.
|
||||||
|
*
|
||||||
|
* This can drastically improve render performance when there are lots of
|
||||||
|
* tooltip targets to re-render… but it comes with some limitations, like the
|
||||||
|
* extra requirement to control `isOpen`, and some additional DOM structure!
|
||||||
|
*/
|
||||||
|
function DeferredTooltip({ children, isOpen, ...props }) {
|
||||||
|
const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setShouldShowToolip(true);
|
||||||
|
} else {
|
||||||
|
const timeoutId = setTimeout(() => setShouldShowToolip(false), 500);
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css }) => (
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
position: relative;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
{shouldShowTooltip && (
|
||||||
|
<Tooltip isOpen={isOpen} {...props}>
|
||||||
|
<div
|
||||||
|
className={css`
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// HACK: I'm just hardcoding all this, rather than connecting up to the
|
||||||
|
// database and adding a loading state. Tbh I'm not sure it's a good idea
|
||||||
|
// to load this dynamically until we have SSR to make it come in fast!
|
||||||
|
// And it's not so bad if this gets out of sync with the database,
|
||||||
|
// because the SpeciesColorPicker will still be usable!
|
||||||
|
const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" };
|
||||||
|
|
||||||
|
export function colorIsBasic(colorId) {
|
||||||
|
return ["8", "34", "61", "84"].includes(colorId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SPECIES_FACES = [
|
||||||
|
{
|
||||||
|
speciesName: "Acara",
|
||||||
|
speciesId: "1",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "93",
|
||||||
|
neopetsImageHash: "obxdjm88",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Aisha",
|
||||||
|
speciesId: "2",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "106",
|
||||||
|
neopetsImageHash: "n9ozx4z5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Blumaroo",
|
||||||
|
speciesId: "3",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "47",
|
||||||
|
neopetsImageHash: "kfonqhdc",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Bori",
|
||||||
|
speciesId: "4",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "84",
|
||||||
|
neopetsImageHash: "sc2hhvhn",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Bruce",
|
||||||
|
speciesId: "5",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "146",
|
||||||
|
neopetsImageHash: "wqz8xn4t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Buzz",
|
||||||
|
speciesId: "6",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "250",
|
||||||
|
neopetsImageHash: "jc9klfxm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Chia",
|
||||||
|
speciesId: "7",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "212",
|
||||||
|
neopetsImageHash: "4lrb4n3f",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Chomby",
|
||||||
|
speciesId: "8",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "74",
|
||||||
|
neopetsImageHash: "bdml26md",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Cybunny",
|
||||||
|
speciesId: "9",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "94",
|
||||||
|
neopetsImageHash: "xl6msllv",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Draik",
|
||||||
|
speciesId: "10",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "132",
|
||||||
|
neopetsImageHash: "bob39shq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Elephante",
|
||||||
|
speciesId: "11",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "56",
|
||||||
|
neopetsImageHash: "jhhhbrww",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Eyrie",
|
||||||
|
speciesId: "12",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "90",
|
||||||
|
neopetsImageHash: "6kngmhvs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Flotsam",
|
||||||
|
speciesId: "13",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "136",
|
||||||
|
neopetsImageHash: "47vt32x2",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Gelert",
|
||||||
|
speciesId: "14",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "138",
|
||||||
|
neopetsImageHash: "5nrd2lvd",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Gnorbu",
|
||||||
|
speciesId: "15",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "166",
|
||||||
|
neopetsImageHash: "6c275jcg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Grarrl",
|
||||||
|
speciesId: "16",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "119",
|
||||||
|
neopetsImageHash: "j7q65fv4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Grundo",
|
||||||
|
speciesId: "17",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "126",
|
||||||
|
neopetsImageHash: "5xn4kjf8",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Hissi",
|
||||||
|
speciesId: "18",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "67",
|
||||||
|
neopetsImageHash: "jsfvcqwt",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ixi",
|
||||||
|
speciesId: "19",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "163",
|
||||||
|
neopetsImageHash: "w32r74vo",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Jetsam",
|
||||||
|
speciesId: "20",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "147",
|
||||||
|
neopetsImageHash: "kz43rnld",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Jubjub",
|
||||||
|
speciesId: "21",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "80",
|
||||||
|
neopetsImageHash: "m267j935",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kacheek",
|
||||||
|
speciesId: "22",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "117",
|
||||||
|
neopetsImageHash: "4gsrb59g",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kau",
|
||||||
|
speciesId: "23",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "201",
|
||||||
|
neopetsImageHash: "ktlxmrtr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kiko",
|
||||||
|
speciesId: "24",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "51",
|
||||||
|
neopetsImageHash: "42j5q3zx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Koi",
|
||||||
|
speciesId: "25",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "208",
|
||||||
|
neopetsImageHash: "ncfn87wk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Korbat",
|
||||||
|
speciesId: "26",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "196",
|
||||||
|
neopetsImageHash: "omx9c876",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kougra",
|
||||||
|
speciesId: "27",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "143",
|
||||||
|
neopetsImageHash: "rfsbh59t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Krawk",
|
||||||
|
speciesId: "28",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "150",
|
||||||
|
neopetsImageHash: "hxgsm5d4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Kyrii",
|
||||||
|
speciesId: "29",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "175",
|
||||||
|
neopetsImageHash: "blxmjgbk",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lenny",
|
||||||
|
speciesId: "30",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "173",
|
||||||
|
neopetsImageHash: "8r94jhfq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lupe",
|
||||||
|
speciesId: "31",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "199",
|
||||||
|
neopetsImageHash: "z42535zh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Lutari",
|
||||||
|
speciesId: "32",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "52",
|
||||||
|
neopetsImageHash: "qgg6z8s7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Meerca",
|
||||||
|
speciesId: "33",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "109",
|
||||||
|
neopetsImageHash: "kk2nn2jr",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Moehog",
|
||||||
|
speciesId: "34",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "134",
|
||||||
|
neopetsImageHash: "jgkoro5z",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Mynci",
|
||||||
|
speciesId: "35",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "95",
|
||||||
|
neopetsImageHash: "xwlo9657",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Nimmo",
|
||||||
|
speciesId: "36",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "96",
|
||||||
|
neopetsImageHash: "bx7fho8x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ogrin",
|
||||||
|
speciesId: "37",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "154",
|
||||||
|
neopetsImageHash: "rjzmx24v",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Peophin",
|
||||||
|
speciesId: "38",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "55",
|
||||||
|
neopetsImageHash: "kokc52kh",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Poogle",
|
||||||
|
speciesId: "39",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "76",
|
||||||
|
neopetsImageHash: "fw6lvf3c",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Pteri",
|
||||||
|
speciesId: "40",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "156",
|
||||||
|
neopetsImageHash: "tjhwbro3",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Quiggle",
|
||||||
|
speciesId: "41",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "78",
|
||||||
|
neopetsImageHash: "jdto7mj4",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Ruki",
|
||||||
|
speciesId: "42",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "191",
|
||||||
|
neopetsImageHash: "qsgbm5f6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Scorchio",
|
||||||
|
speciesId: "43",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "187",
|
||||||
|
neopetsImageHash: "hkjoncsx",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Shoyru",
|
||||||
|
speciesId: "44",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "46",
|
||||||
|
neopetsImageHash: "mmvn4tkg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Skeith",
|
||||||
|
speciesId: "45",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "178",
|
||||||
|
neopetsImageHash: "fc4cxk3t",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Techo",
|
||||||
|
speciesId: "46",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "100",
|
||||||
|
neopetsImageHash: "84gvowmj",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Tonu",
|
||||||
|
speciesId: "47",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "130",
|
||||||
|
neopetsImageHash: "jd433863",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Tuskaninny",
|
||||||
|
speciesId: "48",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "188",
|
||||||
|
neopetsImageHash: "q39wn6vq",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Uni",
|
||||||
|
speciesId: "49",
|
||||||
|
colorId: colors.GREEN,
|
||||||
|
bodyId: "257",
|
||||||
|
neopetsImageHash: "njzvoflw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Usul",
|
||||||
|
speciesId: "50",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "206",
|
||||||
|
neopetsImageHash: "rox4mgh5",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Vandagyre",
|
||||||
|
speciesId: "55",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "306",
|
||||||
|
neopetsImageHash: "xkntzsww",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Wocky",
|
||||||
|
speciesId: "51",
|
||||||
|
colorId: colors.YELLOW,
|
||||||
|
bodyId: "101",
|
||||||
|
neopetsImageHash: "dnr2kj4b",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Xweetok",
|
||||||
|
speciesId: "52",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "68",
|
||||||
|
neopetsImageHash: "tdkqr2b6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Yurble",
|
||||||
|
speciesId: "53",
|
||||||
|
colorId: colors.RED,
|
||||||
|
bodyId: "182",
|
||||||
|
neopetsImageHash: "h95cs547",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
speciesName: "Zafara",
|
||||||
|
speciesId: "54",
|
||||||
|
colorId: colors.BLUE,
|
||||||
|
bodyId: "180",
|
||||||
|
neopetsImageHash: "x8c57g2l",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default SpeciesFacesPicker;
|
691
app/javascript/wardrobe-2020/ItemPageOutfitPreview.js
Normal file
691
app/javascript/wardrobe-2020/ItemPageOutfitPreview.js
Normal file
|
@ -0,0 +1,691 @@
|
||||||
|
import React from "react";
|
||||||
|
import { useQuery } from "@apollo/client";
|
||||||
|
import gql from "graphql-tag";
|
||||||
|
import {
|
||||||
|
AspectRatio,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Flex,
|
||||||
|
Grid,
|
||||||
|
IconButton,
|
||||||
|
Tooltip,
|
||||||
|
useColorModeValue,
|
||||||
|
usePrefersReducedMotion,
|
||||||
|
} from "@chakra-ui/react";
|
||||||
|
import { EditIcon, WarningIcon } from "@chakra-ui/icons";
|
||||||
|
import { MdPause, MdPlayArrow } from "react-icons/md";
|
||||||
|
|
||||||
|
import HTML5Badge, { layerUsesHTML5 } from "./components/HTML5Badge";
|
||||||
|
import SpeciesColorPicker, {
|
||||||
|
useAllValidPetPoses,
|
||||||
|
getValidPoses,
|
||||||
|
getClosestPose,
|
||||||
|
} from "./components/SpeciesColorPicker";
|
||||||
|
import SpeciesFacesPicker, {
|
||||||
|
colorIsBasic,
|
||||||
|
} from "./ItemPage/SpeciesFacesPicker";
|
||||||
|
import {
|
||||||
|
itemAppearanceFragment,
|
||||||
|
petAppearanceFragment,
|
||||||
|
} from "./components/useOutfitAppearance";
|
||||||
|
import { useOutfitPreview } from "./components/OutfitPreview";
|
||||||
|
import { logAndCapture, useLocalStorage } from "./util";
|
||||||
|
import { useItemAppearances } from "./loaders/items";
|
||||||
|
|
||||||
|
function ItemPageOutfitPreview({ itemId }) {
|
||||||
|
const idealPose = React.useMemo(
|
||||||
|
() => (Math.random() > 0.5 ? "HAPPY_FEM" : "HAPPY_MASC"),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const [petState, setPetState] = React.useState({
|
||||||
|
// We'll fill these in once the canonical appearance data arrives.
|
||||||
|
speciesId: null,
|
||||||
|
colorId: null,
|
||||||
|
pose: null,
|
||||||
|
isValid: false,
|
||||||
|
|
||||||
|
// We use appearance ID, in addition to the above, to give the Apollo cache
|
||||||
|
// a really clear hint that the canonical pet appearance we preloaded is
|
||||||
|
// the exact right one to show! But switching species/color will null this
|
||||||
|
// out again, and that's okay. (We'll do an unnecessary reload if you
|
||||||
|
// switch back to it though... we could maybe do something clever there!)
|
||||||
|
appearanceId: null,
|
||||||
|
});
|
||||||
|
const [preferredSpeciesId, setPreferredSpeciesId] = useLocalStorage(
|
||||||
|
"DTIItemPreviewPreferredSpeciesId",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [preferredColorId, setPreferredColorId] = useLocalStorage(
|
||||||
|
"DTIItemPreviewPreferredColorId",
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const setPetStateFromUserAction = React.useCallback(
|
||||||
|
(newPetState) =>
|
||||||
|
setPetState((prevPetState) => {
|
||||||
|
// When the user _intentionally_ chooses a species or color, save it in
|
||||||
|
// local storage for next time. (This won't update when e.g. their
|
||||||
|
// preferred species or color isn't available for this item, so we update
|
||||||
|
// to the canonical species or color automatically.)
|
||||||
|
//
|
||||||
|
// Re the "ifs", I have no reason to expect null to come in here, but,
|
||||||
|
// since this is touching client-persisted data, I want it to be even more
|
||||||
|
// reliable than usual!
|
||||||
|
if (
|
||||||
|
newPetState.speciesId &&
|
||||||
|
newPetState.speciesId !== prevPetState.speciesId
|
||||||
|
) {
|
||||||
|
setPreferredSpeciesId(newPetState.speciesId);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
newPetState.colorId &&
|
||||||
|
newPetState.colorId !== prevPetState.colorId
|
||||||
|
) {
|
||||||
|
if (colorIsBasic(newPetState.colorId)) {
|
||||||
|
// When the user chooses a basic color, don't index on it specifically,
|
||||||
|
// and instead reset to use default colors.
|
||||||
|
setPreferredColorId(null);
|
||||||
|
} else {
|
||||||
|
setPreferredColorId(newPetState.colorId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return newPetState;
|
||||||
|
}),
|
||||||
|
[setPreferredColorId, setPreferredSpeciesId],
|
||||||
|
);
|
||||||
|
|
||||||
|
// We don't need to reload this query when preferred species/color change, so
|
||||||
|
// cache their initial values here to use as query arguments.
|
||||||
|
const [initialPreferredSpeciesId] = React.useState(preferredSpeciesId);
|
||||||
|
const [initialPreferredColorId] = React.useState(preferredColorId);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: itemAppearancesData,
|
||||||
|
loading: loadingAppearances,
|
||||||
|
error: errorAppearances,
|
||||||
|
} = useItemAppearances(itemId);
|
||||||
|
const itemName = itemAppearancesData?.name ?? "";
|
||||||
|
const itemAppearances = itemAppearancesData?.appearances ?? [];
|
||||||
|
const restrictedZones = itemAppearancesData?.restrictedZones ?? [];
|
||||||
|
|
||||||
|
// Start by loading the "canonical" pet and item appearance for the outfit
|
||||||
|
// preview. We'll use this to initialize both the preview and the picker.
|
||||||
|
//
|
||||||
|
// If the user has a preferred species saved from using the ItemPage in the
|
||||||
|
// past, we'll send that instead. This will return the appearance on that
|
||||||
|
// species if possible, or the default canonical species if not.
|
||||||
|
//
|
||||||
|
// TODO: If this is a non-standard pet color, like Mutant, we'll do an extra
|
||||||
|
// query after this loads, because our Apollo cache can't detect the
|
||||||
|
// shared item appearance. (For standard colors though, our logic to
|
||||||
|
// cover standard-color switches works for this preloading too.)
|
||||||
|
const {
|
||||||
|
loading: loadingGQL,
|
||||||
|
error: errorGQL,
|
||||||
|
data,
|
||||||
|
} = useQuery(
|
||||||
|
gql`
|
||||||
|
query ItemPageOutfitPreview(
|
||||||
|
$itemId: ID!
|
||||||
|
$preferredSpeciesId: ID
|
||||||
|
$preferredColorId: ID
|
||||||
|
) {
|
||||||
|
item(id: $itemId) {
|
||||||
|
id
|
||||||
|
canonicalAppearance(
|
||||||
|
preferredSpeciesId: $preferredSpeciesId
|
||||||
|
preferredColorId: $preferredColorId
|
||||||
|
) {
|
||||||
|
id
|
||||||
|
...ItemAppearanceForOutfitPreview
|
||||||
|
body {
|
||||||
|
id
|
||||||
|
canonicalAppearance(preferredColorId: $preferredColorId) {
|
||||||
|
id
|
||||||
|
species {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
|
color {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
pose
|
||||||
|
|
||||||
|
...PetAppearanceForOutfitPreview
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
${itemAppearanceFragment}
|
||||||
|
${petAppearanceFragment}
|
||||||
|
`,
|
||||||
|
{
|
||||||
|
variables: {
|
||||||
|
itemId,
|
||||||
|
preferredSpeciesId: initialPreferredSpeciesId,
|
||||||
|
preferredColorId: initialPreferredColorId,
|
||||||
|
},
|
||||||
|
onCompleted: (data) => {
|
||||||
|
const canonicalBody = data?.item?.canonicalAppearance?.body;
|
||||||
|
const canonicalPetAppearance = canonicalBody?.canonicalAppearance;
|
||||||
|
|
||||||
|
setPetState({
|
||||||
|
speciesId: canonicalPetAppearance?.species?.id,
|
||||||
|
colorId: canonicalPetAppearance?.color?.id,
|
||||||
|
pose: canonicalPetAppearance?.pose,
|
||||||
|
isValid: true,
|
||||||
|
appearanceId: canonicalPetAppearance?.id,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const compatibleBodies = itemAppearances?.map(({ body }) => body) || [];
|
||||||
|
|
||||||
|
// If there's only one compatible body, and the canonical species's name
|
||||||
|
// appears in the item name, then this is probably a species-specific item,
|
||||||
|
// and we should adjust the UI to avoid implying that other species could
|
||||||
|
// model it.
|
||||||
|
const speciesName =
|
||||||
|
data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name ??
|
||||||
|
"";
|
||||||
|
const isProbablySpeciesSpecific =
|
||||||
|
compatibleBodies.length === 1 &&
|
||||||
|
compatibleBodies[0] !== "all" &&
|
||||||
|
itemName.toLowerCase().includes(speciesName.toLowerCase());
|
||||||
|
const couldProbablyModelMoreData = !isProbablySpeciesSpecific;
|
||||||
|
|
||||||
|
// TODO: Does this double-trigger the HTTP request with SpeciesColorPicker?
|
||||||
|
const {
|
||||||
|
loading: loadingValids,
|
||||||
|
error: errorValids,
|
||||||
|
valids,
|
||||||
|
} = useAllValidPetPoses();
|
||||||
|
|
||||||
|
const [hasAnimations, setHasAnimations] = React.useState(false);
|
||||||
|
const [isPaused, setIsPaused] = useLocalStorage("DTIOutfitIsPaused", true);
|
||||||
|
|
||||||
|
// This is like <OutfitPreview />, but we can use the appearance data, too!
|
||||||
|
const { appearance, preview } = useOutfitPreview({
|
||||||
|
speciesId: petState.speciesId,
|
||||||
|
colorId: petState.colorId,
|
||||||
|
pose: petState.pose,
|
||||||
|
appearanceId: petState.appearanceId,
|
||||||
|
wornItemIds: [itemId],
|
||||||
|
isLoading: loadingGQL || loadingValids,
|
||||||
|
spinnerVariant: "corner",
|
||||||
|
engine: "canvas",
|
||||||
|
onChangeHasAnimations: setHasAnimations,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there's an appearance loaded for this item, but it's empty, then the
|
||||||
|
// item is incompatible. (There should only be one item appearance: this one!)
|
||||||
|
const itemAppearance = appearance?.itemAppearances?.[0];
|
||||||
|
const itemLayers = itemAppearance?.layers || [];
|
||||||
|
const isCompatible = itemLayers.length > 0;
|
||||||
|
const usesHTML5 = itemLayers.every(layerUsesHTML5);
|
||||||
|
|
||||||
|
const onChange = React.useCallback(
|
||||||
|
({ speciesId, colorId }) => {
|
||||||
|
const validPoses = getValidPoses(valids, speciesId, colorId);
|
||||||
|
const pose = getClosestPose(validPoses, idealPose);
|
||||||
|
setPetStateFromUserAction({
|
||||||
|
speciesId,
|
||||||
|
colorId,
|
||||||
|
pose,
|
||||||
|
isValid: true,
|
||||||
|
appearanceId: null,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[valids, idealPose, setPetStateFromUserAction],
|
||||||
|
);
|
||||||
|
|
||||||
|
const borderColor = useColorModeValue("green.700", "green.400");
|
||||||
|
const errorColor = useColorModeValue("red.600", "red.400");
|
||||||
|
|
||||||
|
const error = errorGQL || errorAppearances || errorValids;
|
||||||
|
if (error) {
|
||||||
|
return <Box color="red.400">{error.message}</Box>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid
|
||||||
|
templateAreas={{
|
||||||
|
base: `
|
||||||
|
"preview"
|
||||||
|
"speciesColorPicker"
|
||||||
|
"speciesFacesPicker"
|
||||||
|
"zones"
|
||||||
|
`,
|
||||||
|
md: `
|
||||||
|
"preview speciesFacesPicker"
|
||||||
|
"speciesColorPicker zones"
|
||||||
|
`,
|
||||||
|
}}
|
||||||
|
// HACK: Really I wanted 400px to match the natural height of the
|
||||||
|
// preview in md, but in Chromium that creates a scrollbar and
|
||||||
|
// 401px doesn't, not sure exactly why?
|
||||||
|
templateRows={{
|
||||||
|
base: "auto auto 200px auto",
|
||||||
|
md: "401px auto",
|
||||||
|
}}
|
||||||
|
templateColumns={{
|
||||||
|
base: "minmax(min-content, 400px)",
|
||||||
|
md: "minmax(min-content, 400px) fit-content(480px)",
|
||||||
|
}}
|
||||||
|
rowGap="4"
|
||||||
|
columnGap="6"
|
||||||
|
justifyContent="center"
|
||||||
|
width="100%"
|
||||||
|
>
|
||||||
|
<AspectRatio
|
||||||
|
gridArea="preview"
|
||||||
|
maxWidth="400px"
|
||||||
|
maxHeight="400px"
|
||||||
|
ratio="1"
|
||||||
|
border="1px"
|
||||||
|
borderColor={borderColor}
|
||||||
|
transition="border-color 0.2s"
|
||||||
|
borderRadius="lg"
|
||||||
|
boxShadow="lg"
|
||||||
|
overflow="hidden"
|
||||||
|
>
|
||||||
|
<Box>
|
||||||
|
{petState.isValid && preview}
|
||||||
|
<CustomizeMoreButton
|
||||||
|
speciesId={petState.speciesId}
|
||||||
|
colorId={petState.colorId}
|
||||||
|
pose={petState.pose}
|
||||||
|
itemId={itemId}
|
||||||
|
isDisabled={!petState.isValid}
|
||||||
|
/>
|
||||||
|
{hasAnimations && (
|
||||||
|
<PlayPauseButton
|
||||||
|
isPaused={isPaused}
|
||||||
|
onClick={() => setIsPaused(!isPaused)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</AspectRatio>
|
||||||
|
<Flex gridArea="speciesColorPicker" alignSelf="start" align="center">
|
||||||
|
<Box
|
||||||
|
// This box grows at the same rate as the box on the right, so the
|
||||||
|
// middle box will be centered, if there's space!
|
||||||
|
flex="1 0 0"
|
||||||
|
/>
|
||||||
|
<SpeciesColorPicker
|
||||||
|
speciesId={petState.speciesId}
|
||||||
|
colorId={petState.colorId}
|
||||||
|
pose={petState.pose}
|
||||||
|
idealPose={idealPose}
|
||||||
|
onChange={(species, color, isValid, closestPose) => {
|
||||||
|
setPetStateFromUserAction({
|
||||||
|
speciesId: species.id,
|
||||||
|
colorId: color.id,
|
||||||
|
pose: closestPose,
|
||||||
|
isValid,
|
||||||
|
appearanceId: null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
speciesIsDisabled={isProbablySpeciesSpecific}
|
||||||
|
size="sm"
|
||||||
|
showPlaceholders
|
||||||
|
/>
|
||||||
|
<Box flex="1 0 0" lineHeight="1" paddingLeft="1">
|
||||||
|
{
|
||||||
|
// Wait for us to start _requesting_ the appearance, and _then_
|
||||||
|
// for it to load, and _then_ check compatibility.
|
||||||
|
!loadingGQL &&
|
||||||
|
!loadingAppearances &&
|
||||||
|
!appearance.loading &&
|
||||||
|
petState.isValid &&
|
||||||
|
!isCompatible && (
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
couldProbablyModelMoreData
|
||||||
|
? "Item needs models"
|
||||||
|
: "Not compatible"
|
||||||
|
}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<WarningIcon
|
||||||
|
color={errorColor}
|
||||||
|
transition="color 0.2"
|
||||||
|
marginLeft="2"
|
||||||
|
borderRadius="full"
|
||||||
|
tabIndex="0"
|
||||||
|
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
<Box
|
||||||
|
gridArea="speciesFacesPicker"
|
||||||
|
paddingTop="2"
|
||||||
|
overflow="auto"
|
||||||
|
padding="8px"
|
||||||
|
>
|
||||||
|
<SpeciesFacesPicker
|
||||||
|
selectedSpeciesId={petState.speciesId}
|
||||||
|
selectedColorId={petState.colorId}
|
||||||
|
compatibleBodies={compatibleBodies}
|
||||||
|
couldProbablyModelMoreData={couldProbablyModelMoreData}
|
||||||
|
onChange={onChange}
|
||||||
|
isLoading={loadingGQL || loadingAppearances || loadingValids}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Flex gridArea="zones" justifySelf="center" align="center">
|
||||||
|
{itemAppearances.length > 0 && (
|
||||||
|
<ItemZonesInfo
|
||||||
|
itemAppearances={itemAppearances}
|
||||||
|
restrictedZones={restrictedZones}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Box width="6" />
|
||||||
|
<Flex
|
||||||
|
// Avoid layout shift while loading
|
||||||
|
minWidth="54px"
|
||||||
|
>
|
||||||
|
<HTML5Badge
|
||||||
|
usesHTML5={usesHTML5}
|
||||||
|
// If we're not compatible, act the same as if we're loading:
|
||||||
|
// don't change the badge, but don't show one yet if we don't
|
||||||
|
// have one yet.
|
||||||
|
isLoading={appearance.loading || !isCompatible}
|
||||||
|
/>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CustomizeMoreButton({ speciesId, colorId, pose, itemId, isDisabled }) {
|
||||||
|
const url =
|
||||||
|
`/outfits/new?species=${speciesId}&color=${colorId}&pose=${pose}&` +
|
||||||
|
`objects[]=${itemId}`;
|
||||||
|
|
||||||
|
// The default background is good in light mode, but in dark mode it's a
|
||||||
|
// very subtle transparent white... make it a semi-transparent black, for
|
||||||
|
// better contrast against light-colored background items!
|
||||||
|
const backgroundColor = useColorModeValue(undefined, "blackAlpha.700");
|
||||||
|
const backgroundColorHover = useColorModeValue(undefined, "blackAlpha.900");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkOrButton
|
||||||
|
href={isDisabled ? null : url}
|
||||||
|
role="group"
|
||||||
|
position="absolute"
|
||||||
|
top="2"
|
||||||
|
right="2"
|
||||||
|
size="sm"
|
||||||
|
background={backgroundColor}
|
||||||
|
_hover={{ backgroundColor: backgroundColorHover }}
|
||||||
|
_focus={{ backgroundColor: backgroundColorHover, boxShadow: "outline" }}
|
||||||
|
boxShadow="sm"
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
>
|
||||||
|
<ExpandOnGroupHover paddingRight="2">Customize more</ExpandOnGroupHover>
|
||||||
|
<EditIcon />
|
||||||
|
</LinkOrButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LinkOrButton({ href, ...props }) {
|
||||||
|
if (href != null) {
|
||||||
|
return <Button as="a" href={href} {...props} />;
|
||||||
|
} else {
|
||||||
|
return <Button {...props} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ExpandOnGroupHover starts at width=0, and expands to full width when a
|
||||||
|
* parent with role="group" gains hover or focus state.
|
||||||
|
*/
|
||||||
|
function ExpandOnGroupHover({ children, ...props }) {
|
||||||
|
const [measuredWidth, setMeasuredWidth] = React.useState(null);
|
||||||
|
const measurerRef = React.useRef(null);
|
||||||
|
const prefersReducedMotion = usePrefersReducedMotion();
|
||||||
|
|
||||||
|
React.useLayoutEffect(() => {
|
||||||
|
if (!measurerRef) {
|
||||||
|
// I don't think this is possible, but I'd like to know if it happens!
|
||||||
|
logAndCapture(
|
||||||
|
new Error(
|
||||||
|
`Measurer node not ready during effect. Transition won't be smooth.`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (measuredWidth != null) {
|
||||||
|
// Skip re-measuring when we already have a measured width. This is
|
||||||
|
// mainly defensive, to prevent the possibility of loops, even though
|
||||||
|
// this algorithm should be stable!
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newMeasuredWidth = measurerRef.current.offsetWidth;
|
||||||
|
setMeasuredWidth(newMeasuredWidth);
|
||||||
|
}, [measuredWidth]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
// In block layout, the overflowing children would _also_ be constrained
|
||||||
|
// to width 0. But in flex layout, overflowing children _keep_ their
|
||||||
|
// natural size, so we can measure it even when not visible.
|
||||||
|
width="0"
|
||||||
|
overflow="hidden"
|
||||||
|
// Right-align the children, to keep the text feeling right-aligned when
|
||||||
|
// we expand. (To support left-side expansion, make this a prop!)
|
||||||
|
justify="flex-end"
|
||||||
|
// If the width somehow isn't measured yet, expand to width `auto`, which
|
||||||
|
// won't transition smoothly but at least will work!
|
||||||
|
_groupHover={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
||||||
|
_groupFocus={{ width: measuredWidth ? measuredWidth + "px" : "auto" }}
|
||||||
|
transition={!prefersReducedMotion && "width 0.2s"}
|
||||||
|
>
|
||||||
|
<Box ref={measurerRef} {...props}>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlayPauseButton({ isPaused, onClick }) {
|
||||||
|
return (
|
||||||
|
<IconButton
|
||||||
|
icon={isPaused ? <MdPlayArrow /> : <MdPause />}
|
||||||
|
aria-label={isPaused ? "Play" : "Pause"}
|
||||||
|
onClick={onClick}
|
||||||
|
borderRadius="full"
|
||||||
|
boxShadow="md"
|
||||||
|
color="gray.50"
|
||||||
|
backgroundColor="blackAlpha.700"
|
||||||
|
position="absolute"
|
||||||
|
bottom="2"
|
||||||
|
left="2"
|
||||||
|
_hover={{ backgroundColor: "blackAlpha.900" }}
|
||||||
|
_focus={{ backgroundColor: "blackAlpha.900" }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemZonesInfo({ itemAppearances, restrictedZones }) {
|
||||||
|
// Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're
|
||||||
|
// merging zones with the same label, because that's how user-facing zone UI
|
||||||
|
// generally works!
|
||||||
|
const zoneLabelsAndTheirBodiesMap = {};
|
||||||
|
for (const { body, swfAssets } of itemAppearances) {
|
||||||
|
for (const { zone } of swfAssets) {
|
||||||
|
if (!zoneLabelsAndTheirBodiesMap[zone.label]) {
|
||||||
|
zoneLabelsAndTheirBodiesMap[zone.label] = {
|
||||||
|
zoneLabel: zone.label,
|
||||||
|
bodies: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap);
|
||||||
|
|
||||||
|
const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) =>
|
||||||
|
buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare(
|
||||||
|
buildSortKeyForZoneLabelsAndTheirBodies(b),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const restrictedZoneLabels = [
|
||||||
|
...new Set(restrictedZones.map((z) => z.label)),
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
// We only show body info if there's more than one group of bodies to talk
|
||||||
|
// about. If they all have the same zones, it's clear from context that any
|
||||||
|
// preview available in the list has the zones listed here.
|
||||||
|
const bodyGroups = new Set(
|
||||||
|
zoneLabelsAndTheirBodies.map(({ bodies }) =>
|
||||||
|
bodies.map((b) => b.id).join(","),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const showBodyInfo = bodyGroups.size > 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
fontSize="sm"
|
||||||
|
textAlign="center"
|
||||||
|
// If the text gets too long, wrap Restricts onto another line, and center
|
||||||
|
// them relative to each other.
|
||||||
|
wrap="wrap"
|
||||||
|
justify="center"
|
||||||
|
data-test-id="item-zones-info"
|
||||||
|
>
|
||||||
|
<Box flex="0 0 auto" maxWidth="100%">
|
||||||
|
<Box as="header" fontWeight="bold" display="inline">
|
||||||
|
Occupies:
|
||||||
|
</Box>{" "}
|
||||||
|
<Box as="ul" listStyleType="none" display="inline">
|
||||||
|
{sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => (
|
||||||
|
<Box
|
||||||
|
key={zoneLabel}
|
||||||
|
as="li"
|
||||||
|
display="inline"
|
||||||
|
_notLast={{ _after: { content: '", "' } }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
// Don't wrap any of the list item content. But, by putting
|
||||||
|
// this in an extra container element, we _do_ allow wrapping
|
||||||
|
// _between_ list items.
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
<ItemZonesInfoListItem
|
||||||
|
zoneLabel={zoneLabel}
|
||||||
|
bodies={bodies}
|
||||||
|
showBodyInfo={showBodyInfo}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<Box width="4" flex="0 0 auto" />
|
||||||
|
<Box flex="0 0 auto" maxWidth="100%">
|
||||||
|
<Box as="header" fontWeight="bold" display="inline">
|
||||||
|
Restricts:
|
||||||
|
</Box>{" "}
|
||||||
|
{restrictedZoneLabels.length > 0 ? (
|
||||||
|
<Box as="ul" listStyleType="none" display="inline">
|
||||||
|
{restrictedZoneLabels.map((zoneLabel) => (
|
||||||
|
<Box
|
||||||
|
key={zoneLabel}
|
||||||
|
as="li"
|
||||||
|
display="inline"
|
||||||
|
_notLast={{ _after: { content: '", "' } }}
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
// Don't wrap any of the list item content. But, by putting
|
||||||
|
// this in an extra container element, we _do_ allow wrapping
|
||||||
|
// _between_ list items.
|
||||||
|
whiteSpace="nowrap"
|
||||||
|
>
|
||||||
|
{zoneLabel}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box as="span" fontStyle="italic" opacity="0.8">
|
||||||
|
N/A
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) {
|
||||||
|
let content = zoneLabel;
|
||||||
|
|
||||||
|
if (showBodyInfo) {
|
||||||
|
if (bodies.some((b) => b.representsAllBodies)) {
|
||||||
|
content = <>{content} (all species)</>;
|
||||||
|
} else {
|
||||||
|
// TODO: This is a bit reductive, if it's different for like special
|
||||||
|
// colors, e.g. Blue Acara vs Mutant Acara, this will just show
|
||||||
|
// "Acara" in either case! (We are at least gonna be defensive here
|
||||||
|
// and remove duplicates, though, in case both the Blue Acara and
|
||||||
|
// Mutant Acara body end up in the same list.)
|
||||||
|
const speciesNames = new Set(bodies.map((b) => b.species.humanName));
|
||||||
|
const speciesListString = [...speciesNames].sort().join(", ");
|
||||||
|
|
||||||
|
content = (
|
||||||
|
<>
|
||||||
|
{content}{" "}
|
||||||
|
<Tooltip
|
||||||
|
label={speciesListString}
|
||||||
|
textAlign="center"
|
||||||
|
placement="bottom"
|
||||||
|
>
|
||||||
|
<Box
|
||||||
|
as="span"
|
||||||
|
tabIndex="0"
|
||||||
|
_focus={{ outline: "none", boxShadow: "outline" }}
|
||||||
|
fontStyle="italic"
|
||||||
|
textDecoration="underline"
|
||||||
|
style={{ textDecorationStyle: "dotted" }}
|
||||||
|
opacity="0.8"
|
||||||
|
>
|
||||||
|
{/* Show the speciesNames count, even though it's less info,
|
||||||
|
* because it's more important that the tooltip content matches
|
||||||
|
* the count we show! */}
|
||||||
|
({speciesNames.size} species)
|
||||||
|
</Box>
|
||||||
|
</Tooltip>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSortKeyForZoneLabelsAndTheirBodies({ zoneLabel, bodies }) {
|
||||||
|
// Sort by "represents all bodies", then by body count descending, then
|
||||||
|
// alphabetically.
|
||||||
|
const representsAllBodies = bodies.some((body) => body.representsAllBodies);
|
||||||
|
|
||||||
|
// To sort by body count _descending_, we subtract it from a large number.
|
||||||
|
// Then, to make it work in string comparison, we pad it with leading zeroes.
|
||||||
|
// Hacky but solid!
|
||||||
|
const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0");
|
||||||
|
|
||||||
|
return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ItemPageOutfitPreview;
|
|
@ -1,4 +1,5 @@
|
||||||
import AppProvider from "./AppProvider";
|
import AppProvider from "./AppProvider";
|
||||||
|
import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
|
||||||
import WardrobePage from "./WardrobePage";
|
import WardrobePage from "./WardrobePage";
|
||||||
|
|
||||||
export { AppProvider, WardrobePage };
|
export { AppProvider, ItemPageOutfitPreview, WardrobePage };
|
||||||
|
|
|
@ -201,10 +201,10 @@ function normalizeItemSearchAppearance(data, item) {
|
||||||
__typename: "ItemAppearance",
|
__typename: "ItemAppearance",
|
||||||
id: `item-${item.id}-body-${data.body.id}`,
|
id: `item-${item.id}-body-${data.body.id}`,
|
||||||
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
|
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
|
||||||
restrictedZones: [
|
restrictedZones: data.swf_assets
|
||||||
...item.restricted_zones,
|
.map((a) => a.restricted_zones)
|
||||||
...data.swf_assets.map((a) => a.restricted_zones).flat(),
|
.flat()
|
||||||
].map(normalizeZone),
|
.map(normalizeZone),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord
|
||||||
swf_asset.image_url
|
swf_asset.image_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of items, return how they look on this alt style.
|
# Given a list of item IDs, return how they look on this alt style.
|
||||||
def appearances_for(items, ...)
|
def appearances_for(item_ids, ...)
|
||||||
Item.appearances_for(items, self, ...)
|
Item.appearances_for(item_ids, self, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
def biology=(biology)
|
def biology=(biology)
|
||||||
|
|
|
@ -35,16 +35,6 @@ class Color < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def default_gender_presentation
|
|
||||||
if name.downcase.ends_with? "boy"
|
|
||||||
:masc
|
|
||||||
elsif name.downcase.ends_with? "girl"
|
|
||||||
:fem
|
|
||||||
else
|
|
||||||
nil
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def self.pranks_funny?
|
def self.pranks_funny?
|
||||||
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
|
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
|
||||||
now.month == 4 && now.day == 1
|
now.month == 4 && now.day == 1
|
||||||
|
|
|
@ -25,6 +25,8 @@ class Item < ApplicationRecord
|
||||||
|
|
||||||
NCRarities = [0, 500]
|
NCRarities = [0, 500]
|
||||||
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
PAINTBRUSH_SET_DESCRIPTION = 'This item is part of a deluxe paint brush set!'
|
||||||
|
SPECIAL_COLOR_DESCRIPTION_REGEX =
|
||||||
|
/This item is only wearable by [a-zA-Z]+ painted ([a-zA-Z]+)\.|WARNING: This [a-zA-Z]+ can be worn by ([a-zA-Z]+) [a-zA-Z]+ ONLY!|If your Neopet is not painted ([a-zA-Z]+), it will not be able to wear this item\./
|
||||||
|
|
||||||
scope :newest, -> {
|
scope :newest, -> {
|
||||||
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
order(arel_table[:created_at].desc) if arel_table[:created_at]
|
||||||
|
@ -281,15 +283,78 @@ class Item < ApplicationRecord
|
||||||
occupied_zones.map(&:id)
|
occupied_zones.map(&:id)
|
||||||
end
|
end
|
||||||
|
|
||||||
def occupied_zones
|
def occupied_zones(options={})
|
||||||
zone_ids = swf_assets.map(&:zone_id).uniq
|
options[:scope] ||= Zone.all
|
||||||
Zone.find(zone_ids)
|
all_body_ids = []
|
||||||
|
zone_body_ids = {}
|
||||||
|
selected_assets = swf_assets.select('body_id, zone_id').each do |swf_asset|
|
||||||
|
zone_body_ids[swf_asset.zone_id] ||= []
|
||||||
|
body_ids = zone_body_ids[swf_asset.zone_id]
|
||||||
|
body_ids << swf_asset.body_id unless body_ids.include?(swf_asset.body_id)
|
||||||
|
all_body_ids << swf_asset.body_id unless all_body_ids.include?(swf_asset.body_id)
|
||||||
|
end
|
||||||
|
zones = options[:scope].find(zone_body_ids.keys)
|
||||||
|
zones_by_id = zones.inject({}) { |h, z| h[z.id] = z; h }
|
||||||
|
total_body_ids = all_body_ids.size
|
||||||
|
zone_body_ids.each do |zone_id, body_ids|
|
||||||
|
zones_by_id[zone_id].sometimes = true if body_ids.size < total_body_ids
|
||||||
|
end
|
||||||
|
zones
|
||||||
end
|
end
|
||||||
|
|
||||||
def affected_zones
|
def affected_zones
|
||||||
restricted_zones + occupied_zones
|
restricted_zones + occupied_zones
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def special_color
|
||||||
|
@special_color ||= determine_special_color
|
||||||
|
end
|
||||||
|
|
||||||
|
def special_color_id
|
||||||
|
special_color.try(:id)
|
||||||
|
end
|
||||||
|
|
||||||
|
protected
|
||||||
|
def determine_special_color
|
||||||
|
I18n.with_locale(I18n.default_locale) do
|
||||||
|
# Rather than go find the special description in all locales, let's just
|
||||||
|
# run this logic in English.
|
||||||
|
if description.include?(PAINTBRUSH_SET_DESCRIPTION)
|
||||||
|
name_words = name.downcase.split
|
||||||
|
Color.nonstandard.each do |color|
|
||||||
|
return color if name_words.include?(color.name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
match = description.match(SPECIAL_COLOR_DESCRIPTION_REGEX)
|
||||||
|
if match
|
||||||
|
# Since there are multiple formats in the one regex, there are multiple
|
||||||
|
# possible color name captures. So, take the first non-nil capture.
|
||||||
|
color = match.captures.detect(&:present?)
|
||||||
|
return Color.find_by_name(color.downcase)
|
||||||
|
end
|
||||||
|
|
||||||
|
# HACK: this should probably be a flag on the record instead of
|
||||||
|
# being hardcoded :P
|
||||||
|
if [71893, 76192, 76202, 77367, 77368, 77369, 77370].include?(id)
|
||||||
|
return Color.find_by_name('baby')
|
||||||
|
end
|
||||||
|
|
||||||
|
if [76198].include?(id)
|
||||||
|
return Color.find_by_name('mutant')
|
||||||
|
end
|
||||||
|
|
||||||
|
if [75372].include?(id)
|
||||||
|
return Color.find_by_name('maraquan')
|
||||||
|
end
|
||||||
|
|
||||||
|
if manual_special_color_id?
|
||||||
|
return Color.find(manual_special_color_id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
public
|
||||||
|
|
||||||
def species_support_ids
|
def species_support_ids
|
||||||
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
|
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
|
||||||
end
|
end
|
||||||
|
@ -424,15 +489,6 @@ class Item < ApplicationRecord
|
||||||
}.merge(options))
|
}.merge(options))
|
||||||
end
|
end
|
||||||
|
|
||||||
def compatible_body_ids
|
|
||||||
swf_assets.map(&:body_id).uniq
|
|
||||||
end
|
|
||||||
|
|
||||||
def compatible_pet_types
|
|
||||||
return PetType.all if compatible_body_ids.include?(0)
|
|
||||||
PetType.where(body_id: compatible_body_ids)
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_assets!
|
def handle_assets!
|
||||||
if @parent_swf_asset_relationships_to_update && @current_body_id
|
if @parent_swf_asset_relationships_to_update && @current_body_id
|
||||||
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
|
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
|
||||||
|
@ -499,48 +555,20 @@ class Item < ApplicationRecord
|
||||||
# instead of like a hash, so you can target its children with things like
|
# instead of like a hash, so you can target its children with things like
|
||||||
# the `include` option. This feels clunky though, I wish I had something a
|
# the `include` option. This feels clunky though, I wish I had something a
|
||||||
# bit more suited to it!
|
# bit more suited to it!
|
||||||
Appearance = Struct.new(:item, :body, :swf_assets) do
|
Appearance = Struct.new(:body, :swf_assets) do
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
delegate :present?, :empty?, to: :swf_assets
|
|
||||||
delegate :species, :fits?, :fits_all?, to: :body
|
|
||||||
|
|
||||||
def attributes
|
def attributes
|
||||||
{item:, body:, swf_assets:}
|
{body: body, swf_assets: swf_assets}
|
||||||
end
|
|
||||||
|
|
||||||
def html5?
|
|
||||||
swf_assets.all?(&:html5?)
|
|
||||||
end
|
|
||||||
|
|
||||||
def occupied_zone_ids
|
|
||||||
swf_assets.map(&:zone_id).uniq.sort
|
|
||||||
end
|
|
||||||
|
|
||||||
def restricted_zone_ids
|
|
||||||
return [] if empty?
|
|
||||||
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Appearance::Body = Struct.new(:id, :species) do
|
Appearance::Body = Struct.new(:id, :species) do
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
def attributes
|
def attributes
|
||||||
{id:, species:}
|
{id: id, species: species}
|
||||||
end
|
|
||||||
|
|
||||||
def fits_all?
|
|
||||||
id == 0
|
|
||||||
end
|
|
||||||
|
|
||||||
def fits?(target)
|
|
||||||
fits_all? || target.body_id == id
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def appearances
|
def appearances
|
||||||
@appearances ||= build_appearances
|
|
||||||
end
|
|
||||||
|
|
||||||
def build_appearances
|
|
||||||
all_swf_assets = swf_assets.to_a
|
all_swf_assets = swf_assets.to_a
|
||||||
|
|
||||||
# If there are no assets yet, there are no appearances.
|
# If there are no assets yet, there are no appearances.
|
||||||
|
@ -553,48 +581,28 @@ class Item < ApplicationRecord
|
||||||
# If there are no body-specific assets, return one appearance for them all.
|
# If there are no body-specific assets, return one appearance for them all.
|
||||||
if swf_assets_by_body_id.empty?
|
if swf_assets_by_body_id.empty?
|
||||||
body = Appearance::Body.new(0, nil)
|
body = Appearance::Body.new(0, nil)
|
||||||
return [Appearance.new(self, body, swf_assets_for_all_bodies)]
|
return [Appearance.new(body, swf_assets_for_all_bodies)]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Otherwise, create an appearance for each real (nonzero) body ID. We don't
|
# Otherwise, create an appearance for each real (nonzero) body ID. We don't
|
||||||
# generally expect body_id = 0 and body_id != 0 to mix, but if they do,
|
# generally expect body_id = 0 and body_id != 0 to mix, but if they do,
|
||||||
# uhh, let's merge the body_id = 0 ones in?
|
# uhh, let's merge the body_id = 0 ones in?
|
||||||
species_by_body_id = Species.with_body_ids(swf_assets_by_body_id.keys)
|
|
||||||
swf_assets_by_body_id.map do |body_id, body_specific_assets|
|
swf_assets_by_body_id.map do |body_id, body_specific_assets|
|
||||||
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
|
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
|
||||||
body = Appearance::Body.new(body_id, species_by_body_id[body_id])
|
species = Species.with_body_id(body_id).first!
|
||||||
Appearance.new(self, body, swf_assets_for_body)
|
body = Appearance::Body.new(body_id, species)
|
||||||
|
Appearance.new(body, swf_assets_for_body)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def appearance_for(target, ...)
|
# Given a list of item IDs, return how they look on the given target (either
|
||||||
Item.appearances_for([self], target, ...)[id]
|
# a pet type or an alt style).
|
||||||
end
|
def self.appearances_for(item_ids, target, swf_asset_includes: [])
|
||||||
|
|
||||||
def appearances_by_occupied_zone_id
|
|
||||||
{}.tap do |h|
|
|
||||||
appearances.each do |appearance|
|
|
||||||
appearance.occupied_zone_ids.each do |zone_id|
|
|
||||||
h[zone_id] ||= []
|
|
||||||
h[zone_id] << appearance
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def appearances_by_occupied_zone
|
|
||||||
zones_by_id = occupied_zones.to_h { |z| [z.id, z] }
|
|
||||||
appearances_by_occupied_zone_id.transform_keys { |zid| zones_by_id[zid] }
|
|
||||||
end
|
|
||||||
|
|
||||||
# Given a list of items, return how they look on the given target (either a
|
|
||||||
# pet type or an alt style).
|
|
||||||
def self.appearances_for(items, target, swf_asset_includes: [])
|
|
||||||
# First, load all the relationships for these items that also fit this
|
# First, load all the relationships for these items that also fit this
|
||||||
# body.
|
# body.
|
||||||
relationships = ParentSwfAssetRelationship.
|
relationships = ParentSwfAssetRelationship.
|
||||||
includes(swf_asset: swf_asset_includes).
|
includes(swf_asset: swf_asset_includes).
|
||||||
where(parent_type: "Item", parent_id: items.map(&:id)).
|
where(parent_type: "Item", parent_id: item_ids).
|
||||||
where(swf_asset: {body_id: [target.body_id, 0]})
|
where(swf_asset: {body_id: [target.body_id, 0]})
|
||||||
|
|
||||||
pet_type_body = Appearance::Body.new(target.body_id, target.species)
|
pet_type_body = Appearance::Body.new(target.body_id, target.species)
|
||||||
|
@ -605,13 +613,13 @@ class Item < ApplicationRecord
|
||||||
transform_values { |rels| rels.map(&:swf_asset) }
|
transform_values { |rels| rels.map(&:swf_asset) }
|
||||||
|
|
||||||
# Finally, for each item, return an appearance—even if it's empty!
|
# Finally, for each item, return an appearance—even if it's empty!
|
||||||
items.to_h do |item|
|
item_ids.to_h do |item_id|
|
||||||
assets = assets_by_item_id.fetch(item.id, [])
|
assets = assets_by_item_id.fetch(item_id, [])
|
||||||
|
|
||||||
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
|
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
|
||||||
body = fits_all_pets ? all_pets_body : pet_type_body
|
body = fits_all_pets ? all_pets_body : pet_type_body
|
||||||
|
|
||||||
[item.id, Appearance.new(item, body, assets)]
|
[item_id, Appearance.new(body, assets)]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,15 +1,9 @@
|
||||||
class Outfit < ApplicationRecord
|
class Outfit < ApplicationRecord
|
||||||
has_many :item_outfit_relationships, :dependent => :destroy
|
has_many :item_outfit_relationships, :dependent => :destroy
|
||||||
|
|
||||||
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
|
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
|
||||||
class_name: 'ItemOutfitRelationship'
|
class_name: 'ItemOutfitRelationship'
|
||||||
has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
|
has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
|
||||||
|
|
||||||
has_many :closeted_item_outfit_relationships, -> { where(is_worn: false) },
|
|
||||||
class_name: 'ItemOutfitRelationship'
|
|
||||||
has_many :closeted_items, through: :closeted_item_outfit_relationships,
|
|
||||||
source: :item
|
|
||||||
|
|
||||||
belongs_to :alt_style, optional: true
|
belongs_to :alt_style, optional: true
|
||||||
belongs_to :pet_state, optional: true # We validate presence below!
|
belongs_to :pet_state, optional: true # We validate presence below!
|
||||||
belongs_to :user, optional: true
|
belongs_to :user, optional: true
|
||||||
|
@ -31,12 +25,7 @@ class Outfit < ApplicationRecord
|
||||||
before_validation :ensure_unique_name, if: :user_id?
|
before_validation :ensure_unique_name, if: :user_id?
|
||||||
|
|
||||||
attr_reader :biology
|
attr_reader :biology
|
||||||
delegate :pose, to: :pet_state
|
delegate :color, to: :pet_state
|
||||||
delegate :pet_type, to: :pet_state
|
|
||||||
delegate :color, to: :pet_type
|
|
||||||
delegate :color_id, to: :pet_type
|
|
||||||
delegate :species, to: :pet_type
|
|
||||||
delegate :species_id, to: :pet_type
|
|
||||||
|
|
||||||
scope :wardrobe_order, -> { order('starred DESC', :name) }
|
scope :wardrobe_order, -> { order('starred DESC', :name) }
|
||||||
|
|
||||||
|
@ -118,6 +107,18 @@ class Outfit < ApplicationRecord
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def color_id
|
||||||
|
pet_state.pet_type.color_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def species_id
|
||||||
|
pet_state.pet_type.species_id
|
||||||
|
end
|
||||||
|
|
||||||
|
def pose
|
||||||
|
pet_state.pose
|
||||||
|
end
|
||||||
|
|
||||||
def biology=(biology)
|
def biology=(biology)
|
||||||
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
|
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
|
||||||
|
|
||||||
|
@ -165,90 +166,6 @@ class Outfit < ApplicationRecord
|
||||||
self.item_outfit_relationships = new_relationships
|
self.item_outfit_relationships = new_relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
def item_appearances(...)
|
|
||||||
Item.appearances_for(worn_items, pet_type, ...).values
|
|
||||||
end
|
|
||||||
|
|
||||||
def visible_layers
|
|
||||||
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
|
||||||
|
|
||||||
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
|
||||||
item_layers = item_appearances.map(&:swf_assets).flatten
|
|
||||||
|
|
||||||
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
|
||||||
flatten.to_set
|
|
||||||
item_restricted_zone_ids = item_appearances.
|
|
||||||
map(&:restricted_zone_ids).flatten.to_set
|
|
||||||
|
|
||||||
# When an item restricts a zone, it hides pet layers of the same zone.
|
|
||||||
# We use this to e.g. make a hat hide a hair ruff.
|
|
||||||
#
|
|
||||||
# NOTE: Items' restricted layers also affect what items you can wear at
|
|
||||||
# the same time. We don't enforce anything about that here, and
|
|
||||||
# instead assume that the input by this point is valid!
|
|
||||||
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
|
||||||
|
|
||||||
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
|
||||||
# it makes body-specific items incompatible. We use this to disallow UCs
|
|
||||||
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
|
||||||
# still allowing non-body-specific items in those zones! (I think this
|
|
||||||
# happens for some Invisible pet stuff, too?)
|
|
||||||
#
|
|
||||||
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
|
||||||
# should be doing this way earlier, to prevent the item from even
|
|
||||||
# showing up even in search results!
|
|
||||||
#
|
|
||||||
# NOTE: This can result in both pet layers and items occupying the same
|
|
||||||
# zone, like Static, so long as the item isn't body-specific! That's
|
|
||||||
# correct, and the item layer should be on top! (Here, we implement
|
|
||||||
# it by placing item layers second in the list, and rely on JS sort
|
|
||||||
# stability, and *then* rely on the UI to respect that ordering when
|
|
||||||
# rendering them by depth. Not great! 😅)
|
|
||||||
#
|
|
||||||
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
|
||||||
# this condition, not just the restricted zones, as a sensible
|
|
||||||
# defensive default, even though we weren't aware of any relevant
|
|
||||||
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
|
||||||
# occupies the real Mouth zone, and still should be visible and
|
|
||||||
# above pet layers! So, we now only check *restricted* zones.
|
|
||||||
#
|
|
||||||
# NOTE: UCs used to implement their restrictions by listing specific
|
|
||||||
# zones, but it seems that the logic has changed to just be about
|
|
||||||
# UC-ness and body-specific-ness, and not necessarily involve the
|
|
||||||
# set of restricted zones at all. (This matters because e.g. UCs
|
|
||||||
# shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
|
||||||
# don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
|
||||||
# zone restriction case running too, because I don't think it
|
|
||||||
# _hurts_ anything, and I'm not confident enough in this conclusion.
|
|
||||||
#
|
|
||||||
# TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
|
||||||
# use zone restrictions?
|
|
||||||
if pet_state.pose === "UNCONVERTED"
|
|
||||||
item_layers.reject! { |sa| sa.body_specific? }
|
|
||||||
else
|
|
||||||
item_layers.reject! { |sa| sa.body_specific? &&
|
|
||||||
pet_restricted_zone_ids.include?(sa.zone_id) }
|
|
||||||
end
|
|
||||||
|
|
||||||
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
|
||||||
# interesting example: it has a horn, but its zone restrictions hide it!
|
|
||||||
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
|
||||||
|
|
||||||
(pet_layers + item_layers).sort_by(&:depth)
|
|
||||||
end
|
|
||||||
|
|
||||||
def wardrobe_params
|
|
||||||
{
|
|
||||||
name: name,
|
|
||||||
color: color_id,
|
|
||||||
species: species_id,
|
|
||||||
pose: pose,
|
|
||||||
state: pet_state_id,
|
|
||||||
objects: worn_item_ids,
|
|
||||||
closet: closeted_item_ids,
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
def ensure_unique_name
|
def ensure_unique_name
|
||||||
# If no name was provided, start with "Untitled outfit".
|
# If no name was provided, start with "Untitled outfit".
|
||||||
self.name = "Untitled outfit" if name.blank?
|
self.name = "Untitled outfit" if name.blank?
|
||||||
|
|
|
@ -11,29 +11,23 @@ class PetType < ApplicationRecord
|
||||||
|
|
||||||
BasicHashes = YAML::load_file(Rails.root.join('config', 'basic_type_hashes.yml'))
|
BasicHashes = YAML::load_file(Rails.root.join('config', 'basic_type_hashes.yml'))
|
||||||
|
|
||||||
scope :basic, -> { joins(:color).merge(Color.basic) }
|
|
||||||
scope :matching_name, ->(color_name, species_name) {
|
scope :matching_name, ->(color_name, species_name) {
|
||||||
color = Color.find_by_name!(color_name)
|
color = Color.find_by_name!(color_name)
|
||||||
species = Species.find_by_name!(species_name)
|
species = Species.find_by_name!(species_name)
|
||||||
where(color_id: color.id, species_id: species.id)
|
where(color_id: color.id, species_id: species.id)
|
||||||
}
|
}
|
||||||
scope :preferring_species, ->(species_id) {
|
|
||||||
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
|
def self.special_color_or_basic(special_color)
|
||||||
}
|
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)
|
||||||
scope :preferring_color, ->(color_id) {
|
where(color_id: color_ids)
|
||||||
joins(:color).order([Arel.sql("color_id = ? DESC"), color_id])
|
end
|
||||||
}
|
|
||||||
scope :preferring_simple, -> {
|
|
||||||
joins(:species, :color).
|
|
||||||
merge(Species.order(name: :asc)).
|
|
||||||
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
|
|
||||||
}
|
|
||||||
|
|
||||||
def self.random_basic_per_species(species_ids)
|
def self.random_basic_per_species(species_ids)
|
||||||
random_pet_types = []
|
random_pet_types = []
|
||||||
basics_by_species_id = basic.group_by(&:species_id)
|
# TODO: omg so lame :P
|
||||||
|
standards = special_color_or_basic(nil).group_by(&:species_id)
|
||||||
species_ids.each do |species_id|
|
species_ids.each do |species_id|
|
||||||
pet_types = basics_by_species_id[species_id]
|
pet_types = standards[species_id]
|
||||||
random_pet_types << pet_types[rand(pet_types.size)] if pet_types
|
random_pet_types << pet_types[rand(pet_types.size)] if pet_types
|
||||||
end
|
end
|
||||||
random_pet_types
|
random_pet_types
|
||||||
|
@ -107,10 +101,9 @@ class PetType < ApplicationRecord
|
||||||
|
|
||||||
def canonical_pet_state
|
def canonical_pet_state
|
||||||
# For consistency (randomness is always scary!), we use the PetType ID to
|
# For consistency (randomness is always scary!), we use the PetType ID to
|
||||||
# determine which gender to prefer, if it's not built into the color. That
|
# determine which gender to prefer. That way, it'll be stable, but we'll
|
||||||
# way, it'll be stable, but we'll still get the *vibes* of randomness.
|
# still get the *vibes* of uniform randomness.
|
||||||
preferred_gender = color.default_gender_presentation ||
|
preferred_gender = id % 2 == 0 ? :fem : :masc
|
||||||
(id % 2 == 0 ? :fem : :masc)
|
|
||||||
|
|
||||||
# NOTE: If this were only being called on one pet type at a time, it would
|
# NOTE: If this were only being called on one pet type at a time, it would
|
||||||
# be more efficient to send this as a single query with an `order` part and
|
# be more efficient to send this as a single query with an `order` part and
|
||||||
|
@ -122,23 +115,18 @@ class PetType < ApplicationRecord
|
||||||
pet_states.sort_by { |pet_state|
|
pet_states.sort_by { |pet_state|
|
||||||
gender = pet_state.female? ? :fem : :masc
|
gender = pet_state.female? ? :fem : :masc
|
||||||
[
|
[
|
||||||
# We prefer labeled pet states first, because states no one has seen or
|
|
||||||
# validated are such a wildcard! Then we prefer unglitched, then we
|
|
||||||
# prefer maximally happy. (Correct sad is better than glitched happy!)
|
|
||||||
# Then we pick our arbitrary-ish gender, then we pick the latest if all
|
|
||||||
# else failed and it's an unlabeled free-for-all.
|
|
||||||
pet_state.mood_id.present? ? -1 : 1, # Prefer mood is labeled
|
pet_state.mood_id.present? ? -1 : 1, # Prefer mood is labeled
|
||||||
!pet_state.glitched? ? -1 : 1, # Prefer is not glitched
|
|
||||||
pet_state.mood_id, # Prefer mood is happy, then sad, then sick
|
pet_state.mood_id, # Prefer mood is happy, then sad, then sick
|
||||||
gender == preferred_gender ? -1 : 1, # Prefer our "random" gender
|
gender == preferred_gender ? -1 : 1, # Prefer our "random" gender
|
||||||
-pet_state.id, # Prefer newer pet states
|
-pet_state.id, # Prefer newer pet states
|
||||||
|
!pet_state.glitched? ? -1 : 1, # Prefer is not glitched
|
||||||
]
|
]
|
||||||
}.first
|
}.first
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of items, return how they look on this pet type.
|
# Given a list of item IDs, return how they look on this pet type.
|
||||||
def appearances_for(item, ...)
|
def appearances_for(item_ids, ...)
|
||||||
Item.appearances_for(item, self, ...)
|
Item.appearances_for(item_ids, self, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.all_by_ids_or_children(ids, pet_states)
|
def self.all_by_ids_or_children(ids, pet_states)
|
||||||
|
|
|
@ -4,6 +4,11 @@ class Species < ApplicationRecord
|
||||||
|
|
||||||
scope :alphabetical, -> { order(:name) }
|
scope :alphabetical, -> { order(:name) }
|
||||||
|
|
||||||
|
scope :with_body_id, -> body_id {
|
||||||
|
pt = PetType.arel_table
|
||||||
|
joins(:pet_types).where(pt[:body_id].eq(body_id)).limit(1)
|
||||||
|
}
|
||||||
|
|
||||||
def as_json(options={})
|
def as_json(options={})
|
||||||
super({only: [:id, :name], methods: [:human_name]}.merge(options))
|
super({only: [:id, :name], methods: [:human_name]}.merge(options))
|
||||||
end
|
end
|
||||||
|
@ -15,15 +20,4 @@ class Species < ApplicationRecord
|
||||||
I18n.translate('species.default_human_name')
|
I18n.translate('species.default_human_name')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of body IDs, return a hash from body ID to Species.
|
|
||||||
# (We assume that each body ID belongs to just one species; if not, which
|
|
||||||
# species we return for that body ID is undefined.)
|
|
||||||
def self.with_body_ids(body_ids)
|
|
||||||
species_ids_by_body_id = PetType.where(body_id: body_ids).distinct.
|
|
||||||
pluck(:body_id, :species_id).to_h
|
|
||||||
species_by_id = Species.where(id: species_ids_by_body_id.values).
|
|
||||||
to_h { |s| [s.id, s] }
|
|
||||||
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
@ -15,6 +15,7 @@ class SwfAsset < ApplicationRecord
|
||||||
belongs_to :zone
|
belongs_to :zone
|
||||||
has_many :parent_swf_asset_relationships
|
has_many :parent_swf_asset_relationships
|
||||||
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
||||||
|
has_many :parent_swf_asset_relationships
|
||||||
|
|
||||||
before_validation :normalize_manifest_url, if: :manifest_url?
|
before_validation :normalize_manifest_url, if: :manifest_url?
|
||||||
|
|
||||||
|
@ -140,10 +141,7 @@ class SwfAsset < ApplicationRecord
|
||||||
# assets in the same manifest, and earlier ones are broken and later
|
# assets in the same manifest, and earlier ones are broken and later
|
||||||
# ones are fixed. I don't know the logic exactly, but that's what we've
|
# ones are fixed. I don't know the logic exactly, but that's what we've
|
||||||
# seen!
|
# seen!
|
||||||
{
|
{ js: assets_by_ext[:js].last }
|
||||||
js: assets_by_ext[:js].last,
|
|
||||||
sprites: assets_by_ext.fetch(:png, []),
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
# Otherwise, return the first PNG and the first SVG. (Unlike the JS
|
# Otherwise, return the first PNG and the first SVG. (Unlike the JS
|
||||||
# case, it's important to choose the *first* PNG, because sometimes
|
# case, it's important to choose the *first* PNG, because sometimes
|
||||||
|
@ -188,21 +186,8 @@ class SwfAsset < ApplicationRecord
|
||||||
nil
|
nil
|
||||||
end
|
end
|
||||||
|
|
||||||
def canvas_movie?
|
|
||||||
canvas_movie_library_url.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def canvas_movie_library_url
|
|
||||||
manifest_asset_urls[:js]
|
|
||||||
end
|
|
||||||
|
|
||||||
def canvas_movie_sprite_urls
|
|
||||||
return [] unless canvas_movie?
|
|
||||||
manifest_asset_urls[:sprites]
|
|
||||||
end
|
|
||||||
|
|
||||||
def canvas_movie_image_url
|
def canvas_movie_image_url
|
||||||
return nil unless canvas_movie?
|
return nil unless manifest_asset_urls[:js]
|
||||||
|
|
||||||
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
|
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
|
||||||
libraryUrl: manifest_asset_urls[:js],
|
libraryUrl: manifest_asset_urls[:js],
|
||||||
|
@ -236,17 +221,6 @@ class SwfAsset < ApplicationRecord
|
||||||
self[:known_glitches] = new_known_glitches
|
self[:known_glitches] = new_known_glitches
|
||||||
end
|
end
|
||||||
|
|
||||||
def html5?
|
|
||||||
# NOTE: This is slightly different than how Impress 2020 reasons about
|
|
||||||
# this; it checks for an SVG or canvas movie. I *think* we did
|
|
||||||
# this just to keep the API simpler, and this check is more
|
|
||||||
# correct? But I do wonder if any assets have a manifest but are
|
|
||||||
# arguably "not converted" because the manifest is just so bad.
|
|
||||||
# NOTE: Just checking `manifest_url` isn't enough, because there *are*
|
|
||||||
# assets with a `manifest_url` saved but it 404s.
|
|
||||||
manifest.present?
|
|
||||||
end
|
|
||||||
|
|
||||||
def restricted_zone_ids
|
def restricted_zone_ids
|
||||||
[].tap do |ids|
|
[].tap do |ids|
|
||||||
zones_restrict.chars.each_with_index do |bit, index|
|
zones_restrict.chars.each_with_index do |bit, index|
|
||||||
|
|
|
@ -1,4 +1,8 @@
|
||||||
class Zone < ActiveRecord::Base
|
class Zone < ActiveRecord::Base
|
||||||
|
# When selecting zones that an asset occupies, we allow the zone to set
|
||||||
|
# whether or not the zone is "sometimes" occupied. This is false by default.
|
||||||
|
attr_writer :sometimes
|
||||||
|
|
||||||
scope :alphabetical, -> { order(:label) }
|
scope :alphabetical, -> { order(:label) }
|
||||||
scope :matching_label, ->(label) {
|
scope :matching_label, ->(label) {
|
||||||
where(plain_label: Zone.plainify_label(label))
|
where(plain_label: Zone.plainify_label(label))
|
||||||
|
@ -9,6 +13,10 @@ class Zone < ActiveRecord::Base
|
||||||
super({only: [:id, :depth, :label]}.merge(options))
|
super({only: [:id, :depth, :label]}.merge(options))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def uncertain_label
|
||||||
|
@sometimes ? "#{label} sometimes" : label
|
||||||
|
end
|
||||||
|
|
||||||
def is_commonly_used_by_items
|
def is_commonly_used_by_items
|
||||||
# Zone metadata marks item zones with types 2, 3, and 4. But also, in
|
# Zone metadata marks item zones with types 2, 3, and 4. But also, in
|
||||||
# practice, the Biology Effects zone (type 1, ID 4) has been used for a few
|
# practice, the Biology Effects zone (type 1, ID 4) has been used for a few
|
||||||
|
|
|
@ -25,40 +25,39 @@ module NCMall
|
||||||
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
|
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
|
||||||
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
|
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
|
||||||
def self.load_page_links
|
def self.load_page_links
|
||||||
html = Sync do
|
Sync do
|
||||||
INTERNET.get(ROOT_DOCUMENT_URL, [
|
response = INTERNET.get(ROOT_DOCUMENT_URL, [
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
]) do |response|
|
])
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{url})"
|
|
||||||
end
|
|
||||||
|
|
||||||
response.read
|
if response.status != 200
|
||||||
|
raise ResponseNotOK.new(response.status),
|
||||||
|
"expected status 200 but got #{response.status} (#{url})"
|
||||||
end
|
end
|
||||||
end
|
|
||||||
|
|
||||||
# Extract `load_items_pane` calls from the root document's HTML. (We use
|
# Extract `load_items_pane` calls from the root document's HTML. (We use
|
||||||
# a very simplified regex, rather than actually parsing the full HTML!)
|
# a very simplified regex, rather than actually parsing the full HTML!)
|
||||||
html.scan(PAGE_LINK_PATTERN).
|
html = response.read
|
||||||
map { |type, cat, label| {type:, cat:, label:} }.
|
html.scan(PAGE_LINK_PATTERN).
|
||||||
uniq
|
map { |type, cat, label| {type:, cat:, label:} }.
|
||||||
|
uniq
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def self.load_page_by_url(url)
|
def self.load_page_by_url(url)
|
||||||
Sync do
|
Sync do
|
||||||
INTERNET.get(url, [
|
response = INTERNET.get(url, [
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
]) do |response|
|
])
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{url})"
|
|
||||||
end
|
|
||||||
|
|
||||||
parse_nc_page response.read
|
if response.status != 200
|
||||||
|
raise ResponseNotOK.new(response.status),
|
||||||
|
"expected status 200 but got #{response.status} (#{url})"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
parse_nc_page response.read
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -31,20 +31,20 @@ module NeoPass
|
||||||
|
|
||||||
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
|
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
|
||||||
def self.load_linkages(access_token)
|
def self.load_linkages(access_token)
|
||||||
linkages_str = Sync do
|
response = Sync do
|
||||||
INTERNET.get(LINKAGE_URL, [
|
response = INTERNET.get(LINKAGE_URL, [
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
["Authorization", "Bearer #{access_token}"],
|
["Authorization", "Bearer #{access_token}"],
|
||||||
]) do |response|
|
])
|
||||||
if response.status != 200
|
|
||||||
raise ResponseNotOK.new(response.status),
|
|
||||||
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
|
|
||||||
end
|
|
||||||
|
|
||||||
response.read
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
||||||
|
if response.status != 200
|
||||||
|
raise ResponseNotOK.new(response.status),
|
||||||
|
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
|
||||||
|
end
|
||||||
|
|
||||||
|
linkages_str = response.body.read
|
||||||
|
|
||||||
begin
|
begin
|
||||||
linkages = JSON.parse(linkages_str)
|
linkages = JSON.parse(linkages_str)
|
||||||
rescue JSON::ParserError
|
rescue JSON::ParserError
|
||||||
|
|
|
@ -72,15 +72,14 @@ module NeopetsMediaArchive
|
||||||
# We use this in the `swf_assets:manifests:load` task to perform many
|
# We use this in the `swf_assets:manifests:load` task to perform many
|
||||||
# requests in parallel!
|
# requests in parallel!
|
||||||
Sync do
|
Sync do
|
||||||
INTERNET.get(uri, [
|
response = INTERNET.get(uri, [
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
||||||
]) do |response|
|
])
|
||||||
if response.status != 200
|
if response.status != 200
|
||||||
raise ResponseNotOK.new(response.status),
|
raise ResponseNotOK.new(response.status),
|
||||||
"expected status 200 but got #{response.status} (#{uri})"
|
"expected status 200 but got #{response.status} (#{uri})"
|
||||||
end
|
|
||||||
response.read
|
|
||||||
end
|
end
|
||||||
|
response.body.read
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
<%= javascript_include_tag 'https://analytics.openneo.net/js/script.js',
|
<%= javascript_include_tag 'https://analytics.openneo.net/js/script.js',
|
||||||
async: true, 'data-domain': 'impress.openneo.net' %>
|
defer: true, 'data-domain': 'impress.openneo.net' %>
|
|
@ -1,6 +0,0 @@
|
||||||
<svg class="hanger-spinner" viewBox="0 0 473 473">
|
|
||||||
<path
|
|
||||||
fill="currentColor"
|
|
||||||
d="M451.426,315.003c-0.517-0.344-1.855-0.641-2.41-0.889l-201.09-88.884v-28.879c38.25-4.6,57.136-29.835,57.136-62.28c0-35.926-25.283-63.026-59.345-63.026c-35.763,0-65.771,29.481-65.771,64.384c0,6.005,4.973,10.882,10.978,10.882c1.788,0,3.452-0.535,4.934-1.291c3.519-1.808,6.024-5.365,6.024-9.591c0-22.702,20.674-42.62,44.217-42.62c22.003,0,37.982,17.356,37.982,41.262c0,23.523-19.011,41.262-44.925,41.262c-6.005,0-10.356,4.877-10.356,10.882v21.267v21.353c0,0.21-0.421,0.383-0.401,0.593L35.61,320.55C7.181,330.792-2.554,354.095,0.554,371.881c3.194,18.293,18.704,30.074,38.795,30.074H422.26c23.782,0,42.438-12.307,48.683-32.942C477.11,348.683,469.078,326.766,451.426,315.003z M450.115,364.031c-3.452,11.427-13.607,18.8-27.846,18.8H39.349c-9.725,0-16.104-5.394-17.5-13.368c-1.587-9.104,4.265-22.032,21.831-28.42l199.531-94.583l196.844,87.65C449.303,340.717,453.434,353.072,450.115,364.031z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
Before Width: | Height: | Size: 979 B |
|
@ -38,6 +38,7 @@
|
||||||
title: nc_trade_value_updated_at_text(item.nc_trade_value)
|
title: nc_trade_value_updated_at_text(item.nc_trade_value)
|
||||||
- unless item.nc?
|
- unless item.nc?
|
||||||
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
= link_to t('items.show.resources.shop_wizard'), shop_wizard_url_for(item)
|
||||||
|
= link_to t('items.show.resources.super_shop_wizard'), super_shop_wizard_url_for(item)
|
||||||
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
= link_to t('items.show.resources.trading_post'), trading_post_url_for(item)
|
||||||
= link_to t('items.show.resources.auction_genie'), auction_genie_url_for(item)
|
= link_to t('items.show.resources.auction_genie'), auction_genie_url_for(item)
|
||||||
|
|
||||||
|
|
|
@ -1,26 +0,0 @@
|
||||||
%outfit-viewer
|
|
||||||
.loading-indicator= render partial: "hanger_spinner"
|
|
||||||
|
|
||||||
%label.play-pause-button{title: "Pause/play animations"}
|
|
||||||
%input.play-pause-toggle{
|
|
||||||
type: "checkbox",
|
|
||||||
checked: outfit_viewer_is_playing,
|
|
||||||
}
|
|
||||||
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
|
|
||||||
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
|
|
||||||
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
|
|
||||||
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
|
|
||||||
|
|
||||||
- outfit.visible_layers.each do |swf_asset|
|
|
||||||
%outfit-layer{
|
|
||||||
data: {
|
|
||||||
"asset-id": swf_asset.id,
|
|
||||||
"zone": swf_asset.zone.label,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
- if swf_asset.canvas_movie?
|
|
||||||
%iframe{src: swf_asset_path(swf_asset, playing: outfit_viewer_is_playing ? true : nil)}
|
|
||||||
- elsif swf_asset.image_url.present?
|
|
||||||
= image_tag swf_asset.image_url, alt: ""
|
|
||||||
- else
|
|
||||||
/ No movie or image available for SWF asset: #{swf_asset.url}
|
|
|
@ -1,6 +1,5 @@
|
||||||
- title @item.name
|
- title @item.name
|
||||||
- canonical_path @item
|
- canonical_path @item
|
||||||
- use_responsive_design
|
|
||||||
|
|
||||||
= render partial: "item_header",
|
= render partial: "item_header",
|
||||||
locals: {item: @item, trades: @trades, current_subpage: "preview",
|
locals: {item: @item, trades: @trades, current_subpage: "preview",
|
||||||
|
@ -14,101 +13,7 @@
|
||||||
how we handle zones. Until then, these items will be <em>very</em> buggy,
|
how we handle zones. Until then, these items will be <em>very</em> buggy,
|
||||||
sorry!
|
sorry!
|
||||||
|
|
||||||
= turbo_frame_tag "item-preview" do
|
#outfit-preview-root{'data-item-id': @item.id}
|
||||||
.preview-area
|
|
||||||
= render partial: "outfit_viewer", locals: {outfit: @preview_outfit}
|
|
||||||
.error-indicator
|
|
||||||
💥 We couldn't load all of this outfit. Try again?
|
|
||||||
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
|
|
||||||
class: "customize-more", target: "_blank",
|
|
||||||
title: "Customize more", "aria-label": "Customize more" do
|
|
||||||
= edit_icon
|
|
||||||
|
|
||||||
%species-color-picker
|
|
||||||
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
|
||||||
- if @preview_error == :pet_type_does_not_exist
|
|
||||||
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
|
||||||
- elsif @preview_error == :no_item_data
|
|
||||||
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
|
|
||||||
|
|
||||||
= select_tag "preview[color_id]",
|
|
||||||
options_from_collection_for_select(Color.funny.alphabetical,
|
|
||||||
"id", "human_name", @selected_preview_pet_type.color_id)
|
|
||||||
= select_tag "preview[species_id]",
|
|
||||||
options_from_collection_for_select(Species.alphabetical,
|
|
||||||
"id", "human_name", @selected_preview_pet_type.species_id)
|
|
||||||
= submit_tag "Go", name: nil
|
|
||||||
|
|
||||||
%species-face-picker
|
|
||||||
%noscript
|
|
||||||
This fancy species picker requires Javascript, but you can still use the
|
|
||||||
dropdowns instead!
|
|
||||||
%species-face-picker-options{
|
|
||||||
inert: true, # waits for JS to remove
|
|
||||||
"aria-hidden": true, # waits for JS to remove
|
|
||||||
}
|
|
||||||
- @preview_pet_type_options.each do |pet_type|
|
|
||||||
%label{
|
|
||||||
title: species_face_tooltip(pet_type, @item),
|
|
||||||
}
|
|
||||||
= radio_button_tag "species_face_id", pet_type.species_id,
|
|
||||||
checked: pet_type == @preview_outfit.pet_type,
|
|
||||||
disabled: !item_fits?(@item, pet_type)
|
|
||||||
= pet_type_image pet_type,
|
|
||||||
item_fits?(@item, pet_type) ? :happy : :sad,
|
|
||||||
:face
|
|
||||||
|
|
||||||
.item-preview-meta-info
|
|
||||||
.item-zones-info
|
|
||||||
%section
|
|
||||||
%h3 Occupies
|
|
||||||
- if @appearances_by_occupied_zone.present?
|
|
||||||
%ul
|
|
||||||
- @appearances_by_occupied_zone.each do |zone, appearances_in_zone|
|
|
||||||
%li
|
|
||||||
= zone.label
|
|
||||||
- if item_zone_partial_fit? appearances_in_zone, @all_appearances
|
|
||||||
%span.zone-species-info{
|
|
||||||
title: item_zone_species_list(appearances_in_zone)
|
|
||||||
}
|
|
||||||
(#{appearances_in_zone.size} species)
|
|
||||||
- else
|
|
||||||
%span.no-zones (None)
|
|
||||||
|
|
||||||
%section
|
|
||||||
%h3 Restricts
|
|
||||||
- if @item.restricted_zones.present?
|
|
||||||
%ul
|
|
||||||
- @item.restricted_zones.sort_by(&:label).each do |zone|
|
|
||||||
%li= zone.label
|
|
||||||
- else
|
|
||||||
%span.no-zones (None)
|
|
||||||
|
|
||||||
%div
|
|
||||||
- if @selected_item_appearance.html5?
|
|
||||||
.item-html5-info{
|
|
||||||
"data-status": "converted",
|
|
||||||
"aria-label": "HTML5 supported!",
|
|
||||||
title: "This item is converted to HTML5, and ready to use on Neopets.com!",
|
|
||||||
}
|
|
||||||
%svg{viewBox: "0 0 24 24"}
|
|
||||||
%path{fill: "currentColor", d: "M12,0A12,12,0,1,0,24,12,12.014,12.014,0,0,0,12,0Zm6.927,8.2-6.845,9.289a1.011,1.011,0,0,1-1.43.188L5.764,13.769a1,1,0,1,1,1.25-1.562l4.076,3.261,6.227-8.451A1,1,0,1,1,18.927,8.2Z"}
|
|
||||||
%svg{viewBox: "0 0 36 36"}
|
|
||||||
%path{fill: "currentColor", d: "M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"}
|
|
||||||
- else
|
|
||||||
.item-html5-info{
|
|
||||||
"data-status": "unconverted",
|
|
||||||
"aria-label": "HTML5 not supported",
|
|
||||||
title: "This item isn't converted to HTML5 yet, so it might not appear in " +
|
|
||||||
"Neopets.com customization yet. Once it's ready, it could look a " +
|
|
||||||
"bit different than our temporary preview here. It might even be " +
|
|
||||||
"animated!"
|
|
||||||
}
|
|
||||||
%svg{viewBox: "0 0 24 24"}
|
|
||||||
%path{fill: "currentColor", d: "M23.119,20,13.772,2.15h0a2,2,0,0,0-3.543,0L.881,20a2,2,0,0,0,1.772,2.928H21.347A2,2,0,0,0,23.119,20ZM11,8.423a1,1,0,0,1,2,0v6a1,1,0,1,1-2,0Zm1.05,11.51h-.028a1.528,1.528,0,0,1-1.522-1.47,1.476,1.476,0,0,1,1.448-1.53h.028A1.527,1.527,0,0,1,13.5,18.4,1.475,1.475,0,0,1,12.05,19.933Z"}
|
|
||||||
%svg{viewBox: "0 0 36 36"}
|
|
||||||
%path{fill: "currentColor", d: "M16.389 14.489c.744-.155 1.551-.31 2.326-.31 3.752 0 6.418 2.977 6.418 6.604 0 5.178-2.851 8.589-8.216 8.589-2.201 0-6.821-1.427-6.821-4.155 0-1.147.961-2.107 2.108-2.107 1.24 0 2.729 1.984 4.806 1.984 2.17 0 3.288-2.109 3.288-4.062 0-1.86-1.055-3.131-2.977-3.131-1.799 0-2.078 1.023-3.659 1.023-1.209 0-1.829-.93-1.829-1.457 0-.403.062-.713.093-1.054l.774-6.544c.341-2.418.93-2.945 2.418-2.945h7.472c1.428 0 2.264.837 2.264 1.953 0 2.14-1.611 2.326-2.17 2.326h-5.829l-.466 3.286z"}
|
|
||||||
%path{fill: "#DD2E44", opacity: "0.75", d: "M18 0C8.059 0 0 8.059 0 18s8.059 18 18 18 18-8.059 18-18S27.941 0 18 0zm13 18c0 2.565-.753 4.95-2.035 6.965L11.036 7.036C13.05 5.753 15.435 5 18 5c7.18 0 13 5.821 13 13zM5 18c0-2.565.753-4.95 2.036-6.964l17.929 17.929C22.95 30.247 20.565 31 18 31c-7.179 0-13-5.82-13-13z"}
|
|
||||||
|
|
||||||
- unless @contributors_with_counts.empty?
|
- unless @contributors_with_counts.empty?
|
||||||
#item-contributors
|
#item-contributors
|
||||||
|
@ -118,10 +23,6 @@
|
||||||
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
|
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
|
||||||
%footer= t '.contributors.footer'
|
%footer= t '.contributors.footer'
|
||||||
|
|
||||||
- content_for :stylesheets do
|
- content_for :javascripts_body do
|
||||||
= stylesheet_link_tag "application/hanger-spinner"
|
= javascript_include_tag 'item-page', defer: true
|
||||||
|
|
||||||
- content_for :javascripts do
|
|
||||||
= javascript_include_tag "lib/idiomorph", async: true
|
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
|
||||||
= javascript_include_tag "items/show", async: true
|
|
||||||
|
|
|
@ -13,8 +13,6 @@
|
||||||
%link{href: image_path('favicon.png'), rel: 'icon'}
|
%link{href: image_path('favicon.png'), rel: 'icon'}
|
||||||
= yield :stylesheets
|
= yield :stylesheets
|
||||||
= stylesheet_link_tag "application"
|
= stylesheet_link_tag "application"
|
||||||
- if use_responsive_design?
|
|
||||||
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
|
|
||||||
= yield :meta
|
= yield :meta
|
||||||
= open_graph_tags
|
= open_graph_tags
|
||||||
= csrf_meta_tag
|
= csrf_meta_tag
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
= image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif'
|
= image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif'
|
||||||
%span= t 'infinite_closet'
|
%span= t 'infinite_closet'
|
||||||
- content_for :content do
|
- content_for :content do
|
||||||
= form_tag items_path, method: :get, class: "item-search-form" do
|
= form_tag items_path, :method => :get do
|
||||||
= text_field_tag :q, @query.to_s
|
= text_field_tag :q, @query.to_s
|
||||||
= submit_tag t('.search'), :name => nil
|
= submit_tag t('.search'), :name => nil
|
||||||
= yield
|
= yield
|
||||||
|
|
|
@ -4,27 +4,26 @@
|
||||||
|
|
||||||
%p#pet-not-found.alert= t 'pets.load.not_found'
|
%p#pet-not-found.alert= t 'pets.load.not_found'
|
||||||
|
|
||||||
- if show_announcement?
|
- if show_neopass_announcement?
|
||||||
%section.announcement
|
%section.neopass-announcement
|
||||||
= image_tag "about/announcement.png", width: 70, height: 70,
|
= image_tag "about/neopass-survey.png", width: 70, height: 70,
|
||||||
srcset: {"about/announcement@2x.png": "2x"},
|
srcset: {"about/neopass-survey@2x.png": "2x"},
|
||||||
class: "neopass-thumbnail"
|
class: "neopass-thumbnail"
|
||||||
.content
|
.neopass-content
|
||||||
%p
|
%p
|
||||||
%strong
|
%strong
|
||||||
= link_to "We've updated the item page!",
|
💭 Thank you for sending us your NeoPass feedback!
|
||||||
item_path("37002-Floating-Negg-Faerie-Doll")
|
|
||||||
It should load faster, work better on phones, and be more reliable—no
|
|
||||||
more "failed to fetch"! Please try it out and let us know if it does
|
|
||||||
anything weird!!
|
|
||||||
|
|
||||||
%p{style: "font-style: italic; opacity: .85; font-size: 85%"}
|
%p
|
||||||
By the way, our integration work with TNT is on pause while they focus
|
We're working with TNT now to build new integrations, based on what you
|
||||||
on the
|
told us! We're glad
|
||||||
= link_to "~Void Within plot~!", "https://www.neopets.com/tvw/",
|
= link_to "log in with NeoPass", about_neopass_path
|
||||||
target: "_blank", style: "color: purple; font-weight: bold"
|
is working well, and we're excited to do the next part! More info soon!
|
||||||
|
|
||||||
|
%p
|
||||||
|
Thanks again to everyone for helping us out! We're grateful, as always 💖
|
||||||
%br
|
%br
|
||||||
We'll start it back up closer to the new year.
|
%em —Matchu
|
||||||
|
|
||||||
#outfit-forms
|
#outfit-forms
|
||||||
- localized_cache :action_suffix => 'outfit_forms_intro' do
|
- localized_cache :action_suffix => 'outfit_forms_intro' do
|
||||||
|
|
|
@ -68,7 +68,7 @@
|
||||||
|
|
||||||
%script#bulk-pets-submission-template{:type => 'text/x-jquery/tmpl'}
|
%script#bulk-pets-submission-template{:type => 'text/x-jquery/tmpl'}
|
||||||
%li.waiting
|
%li.waiting
|
||||||
%img{:src => '${pet_thumbnail}'}
|
%img{:src => 'https://pets.neopets.com/cpn/${pet_name}/1/1.png'}
|
||||||
%span.name ${pet_name}
|
%span.name ${pet_name}
|
||||||
%span.waiting-message= t '.bulk_pets.waiting'
|
%span.waiting-message= t '.bulk_pets.waiting'
|
||||||
%span.loading-message= t '.bulk_pets.loading'
|
%span.loading-message= t '.bulk_pets.loading'
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
!!! 5
|
|
||||||
%html
|
|
||||||
%head
|
|
||||||
%meta{charset: "utf-8"}
|
|
||||||
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
|
|
||||||
%title
|
|
||||||
Embed for Asset ##{@swf_asset.id} | #{t "app_name"}
|
|
||||||
%link{href: image_path("favicon.png"), rel: "icon"}
|
|
||||||
|
|
||||||
-# Load the stylesheet first, because displaying things correctly is the
|
|
||||||
-# actual most essential thing.
|
|
||||||
= stylesheet_link_tag "swf_assets/show", debug: false
|
|
||||||
|
|
||||||
-# NOTE: For all these assets, the Content-Security-Policy doesn't account
|
|
||||||
-# for asset debug mode, so let's just opt out of it with `debug: false`!
|
|
||||||
- if @swf_asset.canvas_movie?
|
|
||||||
-# This is optional, but preloading the sprites can help us from having
|
|
||||||
-# to wait on all the other JS to load and set up before we start!
|
|
||||||
- @swf_asset.canvas_movie_sprite_urls.each do |sprite_url|
|
|
||||||
%link{rel: "preload", href: sprite_url, as: "image", crossorigin: "anonymous"}
|
|
||||||
|
|
||||||
-# Load the scripts: EaselJS libs first, then the asset's "library" file,
|
|
||||||
-# then our page script that starts the movie.
|
|
||||||
= javascript_include_tag "lib/easeljs.min", defer: true, debug: false
|
|
||||||
= javascript_include_tag "lib/tweenjs.min", defer: true, debug: false
|
|
||||||
= javascript_include_tag @swf_asset.canvas_movie_library_url, defer: true,
|
|
||||||
id: "canvas-movie-library"
|
|
||||||
= javascript_include_tag "swf_assets/show", defer: true, debug: false
|
|
||||||
%body
|
|
||||||
- if @swf_asset.canvas_movie?
|
|
||||||
%canvas#asset-canvas
|
|
||||||
-# Show a fallback image, for users with JS disabled. Lazy-load it, so
|
|
||||||
-# the browser won't bother to load it if it's not used.
|
|
||||||
= image_tag @swf_asset.image_url, id: "fallback", alt: "", loading: "lazy"
|
|
||||||
- else
|
|
||||||
= image_tag @swf_asset.image_url, alt: "", id: "asset-image"
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Deploy a new version to production, using the Ansible playbook in `deploy/deploy.yml`.
|
# Deploy a new version to production, using the Ansible playbook in `deploy/deploy.yml`.
|
||||||
# This skips the build step that normally runs when you just call `bin/deploy`.
|
# This skips the build step that normally runs when you just call `bin/deploy`.
|
||||||
cd $(dirname $0)/../deploy && ansible-playbook deploy.yml
|
ansible-playbook -i deploy/inventory.cfg deploy/deploy.yml
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Set up the deployment environment, using the Ansible playbook in `deploy/setup.yml`.
|
# Set up the deployment environment, using the Ansible playbook in `deploy/setup.yml`.
|
||||||
echo $'Setup requires you to become the root user. You\'ll need to enter the password for your account on the remote web server below, and you must be part of the `sudo` user group.'
|
echo $'Setup requires you to become the root user. You\'ll need to enter the password for your account on the remote web server below, and you must be part of the `sudo` user group.'
|
||||||
cd $(dirname $0)/../deploy && ansible-playbook -K setup.yml
|
ansible-playbook -K -i deploy/inventory.cfg deploy/setup.yml
|
||||||
|
|
|
@ -267,6 +267,7 @@ en-MEEP:
|
||||||
resources:
|
resources:
|
||||||
jn_items: JN Meepits
|
jn_items: JN Meepits
|
||||||
shop_wizard: Meep Wizard
|
shop_wizard: Meep Wizard
|
||||||
|
super_shop_wizard: Meeper Wizard
|
||||||
trading_post: Treeps
|
trading_post: Treeps
|
||||||
auction_genie: Aucteeps
|
auction_genie: Aucteeps
|
||||||
closet_hangers:
|
closet_hangers:
|
||||||
|
|
|
@ -311,6 +311,7 @@ en:
|
||||||
impress_2020: DTI 2020
|
impress_2020: DTI 2020
|
||||||
owls: "Owls: %{value}"
|
owls: "Owls: %{value}"
|
||||||
shop_wizard: Shop Wizard
|
shop_wizard: Shop Wizard
|
||||||
|
super_shop_wizard: Super Wizard
|
||||||
trading_post: Trades
|
trading_post: Trades
|
||||||
auction_genie: Auctions
|
auction_genie: Auctions
|
||||||
closet_hangers:
|
closet_hangers:
|
||||||
|
|
|
@ -211,6 +211,7 @@ es:
|
||||||
resources:
|
resources:
|
||||||
jn_items: Objetos de JN
|
jn_items: Objetos de JN
|
||||||
shop_wizard: Asistente de Tiendas
|
shop_wizard: Asistente de Tiendas
|
||||||
|
super_shop_wizard: Super Asistente de Tiendas
|
||||||
trading_post: Quiosco del trueque
|
trading_post: Quiosco del trueque
|
||||||
auction_genie: Subastas
|
auction_genie: Subastas
|
||||||
closet_hangers:
|
closet_hangers:
|
||||||
|
|
|
@ -209,6 +209,7 @@ pt:
|
||||||
resources:
|
resources:
|
||||||
jn_items: JN Itens
|
jn_items: JN Itens
|
||||||
shop_wizard: Mágico Pecincheiro
|
shop_wizard: Mágico Pecincheiro
|
||||||
|
super_shop_wizard: Super Mágico Pecincheiro
|
||||||
trading_post: Trocas
|
trading_post: Trocas
|
||||||
auction_genie: Leilões
|
auction_genie: Leilões
|
||||||
closet_hangers:
|
closet_hangers:
|
||||||
|
|
|
@ -37,7 +37,6 @@ OpenneoImpressItems::Application.routes.draw do
|
||||||
resources :alt_styles, path: 'alt-styles', only: [:index]
|
resources :alt_styles, path: 'alt-styles', only: [:index]
|
||||||
end
|
end
|
||||||
resources :alt_styles, path: 'alt-styles', only: [:index]
|
resources :alt_styles, path: 'alt-styles', only: [:index]
|
||||||
resources :swf_assets, path: 'swf-assets', only: [:show]
|
|
||||||
|
|
||||||
# Loading and modeling pets!
|
# Loading and modeling pets!
|
||||||
post '/pets/load' => 'pets#load', :as => :load_pet
|
post '/pets/load' => 'pets#load', :as => :load_pet
|
||||||
|
|
|
@ -1,7 +0,0 @@
|
||||||
[defaults]
|
|
||||||
inventory = inventory.cfg
|
|
||||||
|
|
||||||
[ssh_connection]
|
|
||||||
# Pipelining is an optimization that's off by default for compatibility, but runs
|
|
||||||
# playbooks much faster when it's available. It works in our case, so use it!
|
|
||||||
pipelining = True
|
|
|
@ -60,7 +60,7 @@
|
||||||
- name: Configure Bundler to run in deployment mode
|
- name: Configure Bundler to run in deployment mode
|
||||||
command:
|
command:
|
||||||
chdir: "{{ remote_app_root }}"
|
chdir: "{{ remote_app_root }}"
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle config set --local deployment true
|
cmd: /opt/ruby-3.3.0/bin/bundle config set --local deployment true
|
||||||
|
|
||||||
# This ensures that, while attempting our current deploy, we don't
|
# This ensures that, while attempting our current deploy, we don't
|
||||||
# accidentally delete gems out from under the currently-running version.
|
# accidentally delete gems out from under the currently-running version.
|
||||||
|
@ -70,7 +70,7 @@
|
||||||
- name: Configure Bundler to *not* clean up old gems when installing
|
- name: Configure Bundler to *not* clean up old gems when installing
|
||||||
command:
|
command:
|
||||||
chdir: "{{ remote_app_root }}"
|
chdir: "{{ remote_app_root }}"
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle config set --local clean false
|
cmd: /opt/ruby-3.3.0/bin/bundle config set --local clean false
|
||||||
|
|
||||||
# NOTE: Bundler recommends this, and they're pretty smart about it: if the
|
# NOTE: Bundler recommends this, and they're pretty smart about it: if the
|
||||||
# Gemfile changes, this shouldn't disrupt the currently-running version,
|
# Gemfile changes, this shouldn't disrupt the currently-running version,
|
||||||
|
@ -79,7 +79,7 @@
|
||||||
- name: Configure Bundler to use the bundle folder shared by all app versions
|
- name: Configure Bundler to use the bundle folder shared by all app versions
|
||||||
command:
|
command:
|
||||||
chdir: "{{ remote_app_root }}"
|
chdir: "{{ remote_app_root }}"
|
||||||
cmd: "/opt/ruby-3.3.4/bin/bundle config set --local path {{ remote_project_root}}/shared/bundle"
|
cmd: "/opt/ruby-3.3.0/bin/bundle config set --local path {{ remote_project_root}}/shared/bundle"
|
||||||
|
|
||||||
- name: Run `bundle install` to install dependencies in remote folder
|
- name: Run `bundle install` to install dependencies in remote folder
|
||||||
command:
|
command:
|
||||||
|
@ -87,7 +87,7 @@
|
||||||
# The `--local` flag instructs Bundler to use the cached dependencies
|
# The `--local` flag instructs Bundler to use the cached dependencies
|
||||||
# in `vendor/cache`, instead of reading from the web, which is much
|
# in `vendor/cache`, instead of reading from the web, which is much
|
||||||
# faster and more reliable!
|
# faster and more reliable!
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle install --local
|
cmd: /opt/ruby-3.3.0/bin/bundle install --local
|
||||||
|
|
||||||
- name: Update the `current` folder to point to the new version
|
- name: Update the `current` folder to point to the new version
|
||||||
file:
|
file:
|
||||||
|
@ -111,7 +111,7 @@
|
||||||
- name: Clean up gems no longer used in the current app version
|
- name: Clean up gems no longer used in the current app version
|
||||||
command:
|
command:
|
||||||
chdir: "{{ remote_app_root }}"
|
chdir: "{{ remote_app_root }}"
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle clean
|
cmd: /opt/ruby-3.3.0/bin/bundle clean
|
||||||
when: not skip_set_as_current
|
when: not skip_set_as_current
|
||||||
|
|
||||||
- name: Find older app versions to clean up
|
- name: Find older app versions to clean up
|
||||||
|
|
|
@ -5,7 +5,7 @@ Description=Dress to Impress webapp
|
||||||
User=impress
|
User=impress
|
||||||
Restart=always
|
Restart=always
|
||||||
WorkingDirectory=/srv/impress/current
|
WorkingDirectory=/srv/impress/current
|
||||||
ExecStart=/opt/ruby-3.3.4/bin/bundle exec falcon host
|
ExecStart=/opt/ruby-3.3.0/bin/bundle exec falcon host
|
||||||
Environment="RAILS_ENV=production"
|
Environment="RAILS_ENV=production"
|
||||||
; Set EXECJS_RUNTIME to save us from needing to install Node
|
; Set EXECJS_RUNTIME to save us from needing to install Node
|
||||||
Environment="EXECJS_RUNTIME=Disabled"
|
Environment="EXECJS_RUNTIME=Disabled"
|
||||||
|
|
|
@ -170,21 +170,21 @@
|
||||||
git:
|
git:
|
||||||
repo: https://github.com/rbenv/ruby-build.git
|
repo: https://github.com/rbenv/ruby-build.git
|
||||||
dest: /opt/ruby-build
|
dest: /opt/ruby-build
|
||||||
version: d22fa95a6e4c77945304c16ebe0d9513fec98cfb
|
version: e1b36a32fb87d61955ac38f1889b7e3cb3b2f407
|
||||||
|
|
||||||
- name: Check if Ruby 3.3.4 is already installed
|
- name: Check if Ruby 3.3.0 is already installed
|
||||||
stat:
|
stat:
|
||||||
path: /opt/ruby-3.3.4
|
path: /opt/ruby-3.3.0
|
||||||
register: ruby_dir
|
register: ruby_dir
|
||||||
|
|
||||||
- name: Install Ruby 3.3.4
|
- name: Install Ruby 3.3.0
|
||||||
command: "/opt/ruby-build/bin/ruby-build 3.3.4 /opt/ruby-3.3.4"
|
command: "/opt/ruby-build/bin/ruby-build 3.3.0 /opt/ruby-3.3.0"
|
||||||
when: not ruby_dir.stat.exists
|
when: not ruby_dir.stat.exists
|
||||||
|
|
||||||
- name: Add Ruby 3.3.4 to the global PATH, for developer convenience
|
- name: Add Ruby 3.3.0 to the global PATH, for developer convenience
|
||||||
copy:
|
copy:
|
||||||
dest: /etc/profile.d/ruby_path.sh
|
dest: /etc/profile.d/ruby_path.sh
|
||||||
content: PATH="/opt/ruby-3.3.4/bin:$PATH"
|
content: PATH="/opt/ruby-3.3.0/bin:$PATH"
|
||||||
|
|
||||||
- name: Install system dependencies for impress's Ruby gems
|
- name: Install system dependencies for impress's Ruby gems
|
||||||
apt:
|
apt:
|
||||||
|
@ -248,14 +248,14 @@
|
||||||
become_user: impress
|
become_user: impress
|
||||||
command:
|
command:
|
||||||
chdir: /srv/impress/versions/initial-placeholder
|
chdir: /srv/impress/versions/initial-placeholder
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle config set --local deployment true
|
cmd: /opt/ruby-3.3.0/bin/bundle config set --local deployment true
|
||||||
when: not current_app_version.stat.exists
|
when: not current_app_version.stat.exists
|
||||||
|
|
||||||
- name: Install the placeholder app's dependencies
|
- name: Install the placeholder app's dependencies
|
||||||
become_user: impress
|
become_user: impress
|
||||||
command:
|
command:
|
||||||
chdir: /srv/impress/versions/initial-placeholder
|
chdir: /srv/impress/versions/initial-placeholder
|
||||||
cmd: /opt/ruby-3.3.4/bin/bundle install
|
cmd: /opt/ruby-3.3.0/bin/bundle install
|
||||||
when: not current_app_version.stat.exists
|
when: not current_app_version.stat.exists
|
||||||
|
|
||||||
- name: Set the placeholder app as the current version
|
- name: Set the placeholder app as the current version
|
||||||
|
|
|
@ -1,102 +0,0 @@
|
||||||
require "addressable/template"
|
|
||||||
require "async/http/internet/instance"
|
|
||||||
|
|
||||||
namespace :rainbow_pool do
|
|
||||||
desc "Import all basic image hashes from the Rainbow Pool, onto PetTypes"
|
|
||||||
task :import => :environment do
|
|
||||||
neologin = STDIN.getpass("Neologin cookie: ")
|
|
||||||
|
|
||||||
all_pet_types = PetType.all.to_a
|
|
||||||
all_pet_types_by_species_id_and_color_id = all_pet_types.
|
|
||||||
to_h { |pt| [[pt.species_id, pt.color_id], pt] }
|
|
||||||
all_colors_by_name = Color.all.to_h { |c| [c.human_name.downcase, c] }
|
|
||||||
|
|
||||||
# TODO: Do these in parallel? I set up the HTTP requests to be able to
|
|
||||||
# handle it, and just didn't set up the rest of the code for it, lol
|
|
||||||
Species.order(:name).each do |species|
|
|
||||||
begin
|
|
||||||
hashes_by_color_name = RainbowPool.load_hashes_for_species(
|
|
||||||
species.id, neologin)
|
|
||||||
rescue => error
|
|
||||||
puts "Failed to load #{species.name} page, skipping: #{error.message}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
changed_pet_types = []
|
|
||||||
|
|
||||||
hashes_by_color_name.each do |color_name, image_hash|
|
|
||||||
color = all_colors_by_name[color_name.downcase]
|
|
||||||
if color.nil?
|
|
||||||
puts "Skipping unrecognized color name: #{color_name}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
pet_type = all_pet_types_by_species_id_and_color_id[
|
|
||||||
[species.id, color.id]]
|
|
||||||
if pet_type.nil?
|
|
||||||
puts "Skipping unrecognized pet type: " +
|
|
||||||
"#{color_name} #{species.human_name}"
|
|
||||||
next
|
|
||||||
end
|
|
||||||
|
|
||||||
if pet_type.basic_image_hash.nil?
|
|
||||||
puts "Found new image hash: #{image_hash} (#{pet_type.human_name})"
|
|
||||||
pet_type.basic_image_hash = image_hash
|
|
||||||
changed_pet_types << pet_type
|
|
||||||
elsif pet_type.basic_image_hash != image_hash
|
|
||||||
puts "Updating image hash: #{image_hash} ({#{pet_type.human_name})"
|
|
||||||
pet_type.basic_image_hash = image_hash
|
|
||||||
changed_pet_types << pet_type
|
|
||||||
else
|
|
||||||
# No need to do anything with image hashes that match!
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
PetType.transaction { changed_pet_types.each(&:save!) }
|
|
||||||
puts "Saved #{changed_pet_types.size} image hashes for " +
|
|
||||||
"#{species.human_name}"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
module RainbowPool
|
|
||||||
# Share a pool of persistent connections, rather than reconnecting on
|
|
||||||
# each request. (This library does that automatically!)
|
|
||||||
INTERNET = Async::HTTP::Internet.instance
|
|
||||||
|
|
||||||
class << self
|
|
||||||
SPECIES_PAGE_URL_TEMPLATE = Addressable::Template.new(
|
|
||||||
"https://www.neopets.com/pool/all_pb.phtml{?f_species_id}"
|
|
||||||
)
|
|
||||||
def load_hashes_for_species(species_id, neologin)
|
|
||||||
Sync do
|
|
||||||
url = SPECIES_PAGE_URL_TEMPLATE.expand(f_species_id: species_id)
|
|
||||||
INTERNET.get(url, [
|
|
||||||
["User-Agent", Rails.configuration.user_agent_for_neopets],
|
|
||||||
["Cookie", "neologin=#{neologin}"],
|
|
||||||
]) do |response|
|
|
||||||
if response.status != 200
|
|
||||||
raise "expected status 200 but got #{response.status} (#{url})"
|
|
||||||
end
|
|
||||||
|
|
||||||
parse_hashes_from_page response.read
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
private
|
|
||||||
|
|
||||||
IMAGE_HASH_PATTERN = %r{
|
|
||||||
set_pet_img\(
|
|
||||||
'https?://pets\.neopets\.com/cp/(?<hash>[0-9a-z]+)/[0-9]+/[0-9]+\.png',
|
|
||||||
\s*
|
|
||||||
'(?<color_name>.+?)'
|
|
||||||
\)
|
|
||||||
}x
|
|
||||||
def parse_hashes_from_page(html)
|
|
||||||
html.scan(IMAGE_HASH_PATTERN).to_h do |(image_hash, color_name)|
|
|
||||||
[color_name, image_hash]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -49,5 +49,5 @@
|
||||||
"lint": "eslint app/javascript",
|
"lint": "eslint app/javascript",
|
||||||
"prepare": "husky install"
|
"prepare": "husky install"
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@4.4.1"
|
"packageManager": "yarn@4.2.1"
|
||||||
}
|
}
|
||||||
|
|
BIN
vendor/cache/async-2.16.1.gem
vendored
Normal file
BIN
vendor/cache/async-2.16.1.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-2.17.0.gem
vendored
BIN
vendor/cache/async-2.17.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-container-0.16.13.gem
vendored
Normal file
BIN
vendor/cache/async-container-0.16.13.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-container-0.18.3.gem
vendored
BIN
vendor/cache/async-container-0.18.3.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-http-0.61.0.gem
vendored
Normal file
BIN
vendor/cache/async-http-0.61.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-http-0.75.0.gem
vendored
BIN
vendor/cache/async-http-0.75.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/async-io-1.43.2.gem
vendored
Normal file
BIN
vendor/cache/async-io-1.43.2.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/async-service-0.12.0.gem
vendored
BIN
vendor/cache/async-service-0.12.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/build-environment-1.13.0.gem
vendored
Normal file
BIN
vendor/cache/build-environment-1.13.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/falcon-0.43.0.gem
vendored
Normal file
BIN
vendor/cache/falcon-0.43.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/falcon-0.48.0.gem
vendored
BIN
vendor/cache/falcon-0.48.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/ffi-1.17.0-x86_64-linux-gnu.gem
vendored
BIN
vendor/cache/ffi-1.17.0-x86_64-linux-gnu.gem
vendored
Binary file not shown.
BIN
vendor/cache/io-endpoint-0.13.1.gem
vendored
BIN
vendor/cache/io-endpoint-0.13.1.gem
vendored
Binary file not shown.
BIN
vendor/cache/io-stream-0.4.0.gem
vendored
BIN
vendor/cache/io-stream-0.4.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/nokogiri-1.16.7-x86_64-linux.gem
vendored
BIN
vendor/cache/nokogiri-1.16.7-x86_64-linux.gem
vendored
Binary file not shown.
BIN
vendor/cache/process-metrics-0.2.1.gem
vendored
Normal file
BIN
vendor/cache/process-metrics-0.2.1.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/process-metrics-0.3.0.gem
vendored
BIN
vendor/cache/process-metrics-0.3.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/protocol-http-0.25.0.gem
vendored
Normal file
BIN
vendor/cache/protocol-http-0.25.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/protocol-http-0.33.0.gem
vendored
BIN
vendor/cache/protocol-http-0.33.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/protocol-http1-0.16.1.gem
vendored
Normal file
BIN
vendor/cache/protocol-http1-0.16.1.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/protocol-http1-0.22.0.gem
vendored
BIN
vendor/cache/protocol-http1-0.22.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/protocol-http2-0.15.1.gem
vendored
Normal file
BIN
vendor/cache/protocol-http2-0.15.1.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/protocol-http2-0.18.0.gem
vendored
BIN
vendor/cache/protocol-http2-0.18.0.gem
vendored
Binary file not shown.
BIN
vendor/cache/protocol-rack-0.6.0.gem
vendored
Normal file
BIN
vendor/cache/protocol-rack-0.6.0.gem
vendored
Normal file
Binary file not shown.
BIN
vendor/cache/protocol-rack-0.7.0.gem
vendored
BIN
vendor/cache/protocol-rack-0.7.0.gem
vendored
Binary file not shown.
Loading…
Reference in a new issue