1
0
Fork 0
forked from OpenNeo/impress

Compare commits

..

No commits in common. "2ffad8120e241f65d818cf8d501b98a349e6c5f4" and "acade360a862660e6a303227b8c3e84f996e86f2" have entirely different histories.

91 changed files with 1918 additions and 2808 deletions

View file

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

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View file

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

File diff suppressed because one or more lines are too long

View file

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

File diff suppressed because one or more lines are too long

View file

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

View file

@ -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 () {

View file

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

View file

@ -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
display: flex
gap: .5em
justify-content: center
input[type=text] input[type=text]
font-size: 125% font-size: 125%
width: 15em width: 15em
flex: 0 1 auto
h1 h1
margin-bottom: 1em margin-bottom: 1em

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
);

View 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;

View 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;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,33 +25,33 @@ 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 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})" "expected status 200 but got #{response.status} (#{url})"
end end
response.read
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 = response.read
html.scan(PAGE_LINK_PATTERN). html.scan(PAGE_LINK_PATTERN).
map { |type, cat, label| {type:, cat:, label:} }. map { |type, cat, label| {type:, cat:, label:} }.
uniq 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 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})" "expected status 200 but got #{response.status} (#{url})"
@ -60,7 +60,6 @@ module NCMall
parse_nc_page response.read parse_nc_page response.read
end end
end end
end
# Given a string of NC page data, parse the useful data out of it! # Given a string of NC page data, parse the useful data out of it!
def self.parse_nc_page(nc_page_str) def self.parse_nc_page(nc_page_str)

View file

@ -31,19 +31,19 @@ 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| ])
end
if response.status != 200 if response.status != 200
raise ResponseNotOK.new(response.status), raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{LINKAGE_URL})" "expected status 200 but got #{response.status} (#{LINKAGE_URL})"
end end
response.read linkages_str = response.body.read
end
end
begin begin
linkages = JSON.parse(linkages_str) linkages = JSON.parse(linkages_str)

View file

@ -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 end
response.read response.body.read
end
end end
end end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-container-0.16.13.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-http-0.61.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-io-1.43.2.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/falcon-0.43.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/process-metrics-0.2.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/protocol-http-0.25.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/protocol-http1-0.16.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/protocol-http2-0.15.1.gem vendored Normal file

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/protocol-rack-0.6.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.