1
0
Fork 0
forked from OpenNeo/impress

Compare commits

...

88 commits

Author SHA1 Message Date
2ffad8120e Oops, one more item preview area size tweak 2024-09-08 19:08:27 -07:00
3f4e864a17 Tweak styles for item preview area sizes
I noticed the last row of the species faces required a scroll, I forget
when that happened! But I made some tweaks, most notably widened the
container from the normal 800px, so that on bigger screens everything
lays out and aligns nice, without requiring any scrolling of the face
container!
2024-09-08 18:54:11 -07:00
9f62d7cdbe Oops, fix bug with restricted zones from item search in wardrobe
Oh oops, I forgot one of the kinds of restricted zones when refactoring
how we load search data in wardrobe-2020! This made most items with
restricted zones (like Be Gone items) not work correctly when you
search for them to add them to the item—though it *does* work correctly
when you reload the page or change the species, to get to load a
different way.
2024-09-08 18:15:27 -07:00
27774d908f Better error handling for item page preview HTTP error
If something goes wrong, like the site goes down or has an intermittent
error, try a full pageload. That way, we're both retrying, and in a way
that gives the user more control and visibility into what's going on,
and what they can potentially do about it. (e.g. if there's a useful
error message, they will see it!)
2024-09-08 13:36:30 -07:00
09572b5c05 Improve noscript species face picker styles
Fix z-index conflicts, and not always covering the whole option set
2024-09-08 13:29:34 -07:00
5f2c454423 Actually, not-glitched is more important in item previews than pose
I took this ordering from a specific place on Impress 2020, but I think
that was in a context where the pose mattered more? Here though, I'm
realizing that I'd rather show any known-unglitched pose than the happy
masc or whatever we semi-randomly chose.
2024-09-08 12:08:15 -07:00
0b4d6dc7e6 Oops, remove stray logging 2024-09-08 12:01:16 -07:00
d470dde135 Default to masc/fem for colors like "elderlyboy", in item previews
instead of doing the random choice we do for most colors.

This is especially noticeable in cases where like, I'm looking at the
Elderlyboy Ogrin and like, it has *work* put into the masc eyes, and
them fem eyes are just the standard ones.
2024-09-07 16:12:05 -07:00
620e59f3ed Add rails rainbow_pool:import task, to get clean image hashes for pets
Used to have something like this long ago, now here's the latest
version!

This task can't run autonomously, it needs the human user to provide a
neologin cookie value. So, no cron for us! But we're cleaning up *years*
of lil guys in one swoop now :3
2024-09-07 12:51:59 -07:00
be560e4595 Upgrade async and related gems, and fix async-http response handling
When playing with a Rainbow Pool syncing task, I noticed that error
handling wasn't working correctly for requests using `async-http`: if
the block raised an error, the `Sync` block would never return.

My suspicion is that this is because we were never reading or releasing
the request body.

In this change, I upgrade all the relevant gems for good measure, and
switch to using the response object yielded by the _block_, so we can
know it's being resource-managed correctly. Now, failures raise errors
as expected!

(I tested all these relevant service calls, too!)
2024-09-07 12:14:12 -07:00
c9f2d660bc Handle crash on new item page when SWF asset has no image available 2024-09-06 17:57:18 -07:00
052c02f841 Prefer non-glitched over newer for canonical pet state on item page
Huh, this is a bit odd, I think we took this from Impress 2020's
`canonicalPetStateForBodyLoader` SQL query… but actually, it doesn't
really make sense? and `petStatesForPetTypeLoader` has a more sensible
ordering, and is the one the app uses in more ways. Maybe that's a
mistake we made back then, or a bug we fixed only in one place?

Anyway, this fixes why the item previews were like. using a LOT of
glitched pet states and I was like "dang did a lot of them break
recently?"

Nah we were just. not pulling the right ones lol
2024-09-06 17:29:12 -07:00
96215c037a Add Customize More button back to item pages
Oh right, forgot about this lol!

The specific effect on Impress 2020 where the button label expands is,
kinda hard to implement in normal CSS/JS, and so I'm not in the mood
and I'm settling for the `title` attribute lol
2024-09-06 17:12:11 -07:00
3a18820d05 Oops, fix layout for error when item preview fails to load
Oh right, I need the error indicator to be part of a container that
also contains the outfit viewer, to appear below it!

I was motivated because I realized I forgot the Customize More button
so now I'm building it lol
2024-09-06 16:24:58 -07:00
5131ba40d8 Remove some now-unused ItemPage JS files
now that we're not using the React version of this page anymore!
2024-09-06 16:11:05 -07:00
65eaa031dd Speed up deploys with Ansible's pipelining option
The main bottleneck for us is still just uploading the full source code,
there might be some clever option I'm not using for that yet of like,
compression or something? But this change did take the process down
from like 5 minutes to 3 minutes, so, works for me!
2024-09-06 12:22:28 -07:00
2e7bdc47d7 Move some Ansible config out of scripts and into ansible.cfg
My immediate motivation is that I'm going to try turning on the
pipelining setting, to improve performance, and I'd like to have the
consistent place to put it! But also, I like standardizing our setup a
bit more, too
2024-09-06 12:16:26 -07:00
d69c37089e Fix bug where item preview loading indicator sometimes doesn't delay
The loading indicator *should* fade in after two seconds, to avoid a
flash of a loading indicator when the page loads quickly - but in some
circumstances it wouldn't delay:

1. Visit an item page. (It delays correctly the first time!)
2. Click "Infinite Closet", then click a link to another item page.
3. The loading indicator appears immediately, because this time the
   web component JS is already loaded, so the `outfit-layer` elements
   enter `:state(loading)` *immediately*. The element starts at
   `opacity: 1`, and the delay doesn't matter, because it was never at
   anything else.

In this change, we have the `outfit-viewer` web component take on a
`:state(after-first-frame)`, after a `setTimeout(0)` resolves. That
enables the loading state CSS to *never* apply on the first frame, but
then sometimes kick in on the *second* frame, so that the element is
correctly perceived as "transitioning" from hidden to visible, and the
two-second delay will apply.
2024-09-06 12:13:10 -07:00
5001a50a60 Add announcement about new item page, replacing the hidden Neopass one 2024-09-06 11:47:17 -07:00
c60dceb0ae Merge branch 'simpler-item-previews' 2024-09-06 11:09:16 -07:00
2dbbc4bdd8 Upgrade Yarn version to 4.4.1 2024-09-06 11:01:10 -07:00
30eced448d Fix bug precompiling a CSS file that contains a min() expression
When I run `bin/deploy:precompile` on the previous version, I get an
error from libsass that `vw` and `vh` are incompatible units. I don't
get this error in development, only when compiling for production.

My inference is:
1. For the production build, Sass is trying to preprocess even non-SASS
   files, maybe to help minify them?
2. In Sass, their `min()` existed before CSS's `min()`, so it's
   treating it Like That, and returning a reasonable-in-some-cases-but-
   not-here error that `min(100vw, 100vh)` can't be *precomputed*.

Anyway, wrapping it in `calc()` isn't a *problem*, and helps the Sass
compiler not try to precompute it, so. Okay!
https://github.com/sass/node-sass/issues/2815#issuecomment-575926329
2024-09-06 11:00:49 -07:00
6f08abc3aa Add html5 badge to new item previews 2024-09-05 18:48:41 -07:00
edcb21558a Drastically reduce queries for item page preview
Oh right okay, I made a sloppy perf hack long ago, and now let's
actually clean it up!
2024-09-05 17:52:35 -07:00
176ab20fd1 Cache the Item#appearances field
We call it enough times on this page, and it *does* have a SQL query,
that I want to cache it! (Also I want to make it fewer species queries
if I can tbh…)
2024-09-05 17:41:04 -07:00
0305817cec Use fewer SQL queries to get species for species face picker 2024-09-05 17:39:47 -07:00
4d5b583432 Remove some unnecessary console messages for new outfit viewer
For static image layers, this was *always* logging that we failed to
send the frame a "pause" message. Which, like, of course!

It makes sense to log the notable circumstance where we send a message
we *expect* to arrive, but the frame isn't loaded yet. But if there's
just no frame, ignore it and don't bother to say so.
2024-09-05 17:37:16 -07:00
2e48376c5a Auto-submit the species color picker on change, for new item previews 2024-09-05 17:34:54 -07:00
2ea8f16e43 Style the face picker on item page nicely for desktop! 2024-09-05 16:51:06 -07:00
de99e0236b Style the face picker on item page nicely for mobile
The desktop view isn't built yet, but this is nice!
2024-09-05 16:28:17 -07:00
6dd8e585a3 Add responsive layout for item page
We add a new `use_responsive_design` helper, for pages to opt into this
new CSS—mostly just because like… it's *worse* to apply these styles
for pages that don't expect it 😅

And then, I fix up a couple things on the item page (including in the
general items layout) to match!

I'm doing this because the species face picker layout is going to want
some responsive awareness, and I want to be doing that from the start!
2024-09-05 16:18:48 -07:00
77ff55353c Copy selected/focus face picker styles from Impress 2020 2024-09-03 17:54:56 -07:00
a88fc14bd7 Use hi-res pet images in face picker for new item previews 2024-09-03 17:34:31 -07:00
9f44fd47e4 Add "No data yet" to species face tooltip when needed, in item previews 2024-09-03 17:27:43 -07:00
4c44f8d6a4 Fix species face picker going inert again after Turbo frame load
Here, I remember the trick I learned when building the outfit viewer:
web components are great for making sure stuff stays initialized well
in a Turbo environment!

The problem was, after submitting the form and getting a new preview
loaded via Turbo, the part where we remove `inert` would get undone.
Additionally, this script only loads *once* per session, so if you
Turbo-nav to a different item then that part of the page never ran.

Instead, we use web components to remove the attributes on mount, then
again if they're ever reapplied by Idiomorph.
2024-09-03 17:07:53 -07:00
2b2bffd9da Disable pet faces that the item doesn't fit, in new item previews 2024-09-03 16:42:04 -07:00
a184c75575 Handle noscript for the new species face picker
We mark the options as `inert` and `aria-hidden` while the JS is still
loading—and if the `noscript` tag tells us it's never coming, it covers
up the picker with a brief explainer!
2024-09-03 13:46:55 -07:00
c06c297174 Extremely lo-fi new species face picker for simplified item previews
The basics are working great! There's a few known missing things though:
- Add reasonable noscript behavior
- Disable options where there's no valid appearance
- Lay it out actually _good_, instead of just images dumped there
2024-09-03 13:30:12 -07:00
36f8efadbf Add more detailed zone occupy info to simplified item preview page
Adapting what the Impress 2020 UI does, but in Ruby instead!

I feel like this is case is really starting to show the power of doing
this stuff in Rails instead of via an API… we can *really* take
advantage of our models and our handy idioms at all points. This is
just so much less *code* than this feature takes in Node + GraphQL +
React.
2024-09-03 12:55:10 -07:00
e0f9a27adc Merge branch 'main' into simpler-item-previews 2024-08-31 13:43:10 -07:00
1c36276865 Remove unused special_color logic from Item
We used to use this to determine what color to show by default on the
item page preview for, like, Maraquan-specific items. Now, we infer it
from our actual customization data, rather than these heuristics!

There's still a database field for `Item#manual_special_color_id`. We
can still read and write this from the support UI, and Impress 2020
still slightly uses it from the homepage, so I'm not removing from the
database right now.
2024-08-31 13:42:25 -07:00
6fdeffebf1 Simplify PetType.random_basic_per_species
I'm mostly just going around looking for `special_color`, a concept I
think the app doesn't use anymore, and removing it where I see it!
2024-08-31 13:37:12 -07:00
2f341cfd39 Merge branch 'main' into simpler-item-previews 2024-08-31 13:31:46 -07:00
6312253b82 Simplify standard_species_search_links helper
The `build_on_pet_types` helper used to be reused on the items page, to
generate the list of species to display. We don't use it anymore, so
simplify and remove!
2024-08-31 13:31:24 -07:00
8ab5af1aca Remove unused logic for whether a zone is "sometimes" occupied
I'm about to reimplement the more-robust version of what this used to
be: how the item page used to say "sometimes" after certain zones in
the occupied list.

Now, we're going to do parity with 2020, and list the actual species!

I like that this takes away the weird `#sometimes` method on the `Zone`
class, which was always an odd hack for just this small thing.
2024-08-31 13:16:47 -07:00
bd62476722 Add basic zone info to simpler item previews
We're missing the feature where we enumerate the exact species in
ambiguous cases, though!
2024-08-31 13:11:50 -07:00
5cbab5a766 Merge branch 'main' into simpler-item-previews 2024-08-31 12:50:56 -07:00
e67830642c Upgrade to Ruby 3.3.4 in production
Oh right, I did a dev-side version of this, and forgot prod needs it
too! (Maybe a bit silly to bother for a patch-level but whatever!)
2024-08-31 12:49:50 -07:00
1972ecf043 Use async instead of defer for analytics script
We had this issue on Impress 2020 and I fixed it over there too. I guess
it went less noticed here on Classic, because it's a more
progressively-enhanced site in general (and this failure case is an
interesting argument for that architecture! lol).

On Impress 2020, I wasn't sure if the "waits for the document to load"
behavior of the `defer` attribute was necessary to the script, so I
chose to keep `defer` but move it _after_ the other scripts.

This time, I dug in a bit more, and found a Plausible author saying
that the choice was kinda arbitrary; and another person who had the
same issue as me, who said they switched to `async` and it worked well.
So, that's what we're doing now, too!

https://github.com/plausible/analytics/discussions/1907#discussioncomment-2754499
2024-08-31 12:15:27 -07:00
30e757b050 Add x86_64 versions of some of the cached gems
Two workstations with different chipsets, wowie!
2024-08-31 12:08:14 -07:00
af8dd42830 Add better support for hashed pet names
Closes #2, after making some tweaks to the PR to fit how JS templating
works here. Thanks @dice!!

I had to move `petThumbnailUrl` out of the closure, because this script
does a cute thing of having separate variable scopes for the separate
areas of the page—but this is used by two of them. Arguably it could
make sense to like, put this all in one larger shared IIFE closure that
wraps both of them, to preserve some of this code's intention of
avoiding adding to the global namespace on this page, but like.
*It's fine.*

Co-Authored-By: Steve C <diceroll123@gmail.com>
2024-08-31 12:07:52 -07:00
8ad0025e32 A few more comments and code style refactors for item previews 2024-08-30 17:09:39 -07:00
04ffc30e92 Remove SSW link, does not work on neopets anymore 2024-08-30 01:46:59 -04:00
b2b16a2edc Merge branch 'main' into simpler-item-previews 2024-07-09 14:26:47 -07:00
f0b1d2281e Slight improvement for double-clicking play/pause in new item preview 2024-07-08 17:29:22 -07:00
16bd966a7d Slight improvement for play/pause active style in new item page preview
Oh right, if the *label* is `:active`, that only applies if we're
clicking it with our mouse. But if the *toggle* is actively, that
applies both to mouse events on the label, and keyboard events on the
checkbox.
2024-07-08 17:25:55 -07:00
bf30ca0252 Fix loading cursor bug for new item page preview
Specifically, if a movie layer was the top layer, the `cursor: wait` on
the preview wouldn't show, because the iframe's *contents* would take
priority, and they were using the default cursor.
2024-07-08 17:24:18 -07:00
26954a3bf2 Restyle play/pause button for new item page preview 2024-07-08 17:23:38 -07:00
20202b5cd9 Potentially improve loading state in new item page preview
I thiiiink I've seen the status of a movie `<outfit-layer>` sometimes
be `loading` even when it's clearly already loaded and running. I
haven't been able to track down where and how that happens exactly, so
this is me acting on a hunch: that maybe the
I-would-have-thought-very-unlikely event that the iframe finishes
loading before the `<outfit-layer>` connects with its children maybe
happens more often than one might think!

In this change, we set up the iframe to receive `requestStatus`
messages, which it responds to with the status immediately. And we send
one of these when the `<outfit-layer>` first discovers the iframe.

Fingers crossed!
2024-07-08 16:44:22 -07:00
b03fee8c7e Save playing state to a cookie for the new item page preview 2024-07-08 16:27:38 -07:00
1aba4f405e Add hacky play/pause toggle to new item page preview
This doesn't do a good job maintaining state across morphs, but hey
it's Working At All in terms of wiring, and that's good!!

Also need to style up the toggle as a cute button instead of a visible
checkbox and the words "Play/pause"!
2024-07-08 13:43:28 -07:00
7688caebe1 Handle iframe outright failing to load in new item page preview
This is a nice extra error handler to have, but note that it *won't*
catch the case where the iframe successfully loads but the page returns
a bad status code. In this case, we'll just show the loading state
forever.
2024-07-08 10:44:01 -07:00
fcad7e2bc9 Add a clearer error indicator to new item page previews 2024-07-08 10:36:16 -07:00
d18f43d769 Fix a bug transitioning between two loading states in item page preview 2024-07-07 21:52:38 -07:00
059644f847 Improve the loading indicator for the new item page previews
Add some unobtrusive white background for contrast, show it when the
Turbo frame is loading too, add a spinner cursor, and fix a silliness
of how we put the `position: absolute` stuff into the component-y part
of the hanger spinner instead of this specific use case lol oops!
2024-07-07 21:32:41 -07:00
83eec2db60 Merge branch 'main' into simpler-item-previews 2024-07-07 21:11:32 -07:00
6bc0c55000 Use our hanger loading spinner for the new item page previews
It's back, wowie! Also written up in a bit of a component-y way. Cute!
2024-07-07 19:05:48 -07:00
62d9f2d24a Merge branch 'main' into simpler-item-previews 2024-07-07 17:23:08 -07:00
c8c4facb60 Fix styling for embed page for image-only SWF assets
This doesn't actually really matter, because this doesn't actually get
used in the app right now? But I figure, hey it's not hard to maintain,
let's just do it for consistency!
2024-07-06 12:52:00 -07:00
7ec900b6b6 Use {script,style}_src instead of _elem, for better compatibility
Oh, I didn't realize the `_elem` variant of these parts of the
`Content-Security-Policy` is newer, and so doesn't even work on my
current version of Safari on my Mac.

My rationale at the time was: `script_src_elem` is stricter against
things like imports, and I figured, ok let's do the strictest policy
that works. But since it's not fully compatible with browsers even
*I'm* using right now, and I'm not aware of an actual problem it would
prevent, let's back off that a bit! This should have the same effective
security properties for our case.

Note that the effect of this compatibility issue wasn't *weakening* the
policy; it was being *too* strict, by blocking the scripts and the
stylesheets. This is because `script_src_elem` was ignored, and
`script_src` was absent, so it fell back to `default_src none`.
2024-07-06 12:52:00 -07:00
57c08b5646 Use "morphing" for smoother item page preview changes
The most notable thing here is that we keep the movie iframes running!
So if you're trying different pets for an animated item, the animation
keeps going while the new pet layers load alongside it.

This is also nice for like, the species/color picker form, so we're not
taking away input elements from people who depend on e.g. keyboard
focus.
2024-07-03 21:52:43 -07:00
97e6c39402 Load movies in iframe for new item page preview
Hey hey, it's working! Still stuff to add like pause/play, but yeah!
2024-07-03 20:29:04 -07:00
5b2062754d swf_assets/show action to embed a canvas movie in a sandboxed iframe
Not using this on the item page preview yet, but we will!

I like this approach over e.g. a web component specifically for the
sandboxing: while I don't exactly *distrust* JS that we're loading from
Neopets.com, I don't like the idea of *any* part of the site that
executes arbitrary JS unsafely at runtime, even if we theoretically
trust where it theoretically came from. I don't want any failure
upstream to have effects on us!

I copied basically all of the JS from a related project
`impress-media-server` that I had spun up at one point, to investigate
similar embed techniques. Easy peasy drop-in-squeezy!
2024-07-03 19:50:41 -07:00
5ad320fa18 Remove unused swf_assets/links stylesheet
This must be for a page we got rid of! Ok bye!
2024-07-03 19:17:08 -07:00
81a58f8656 Extract outfit-viewer to a separate template
Just cuz this is gonna get more complex down the line!
2024-07-02 22:43:36 -07:00
76d2cc6c21 Add some data attributes to outfit-layer elements
This is mostly for debugging, the zone label especially, to just
eyeball what we're looking at!
2024-07-02 22:38:18 -07:00
e9145251a9 Refactor outfit-layer component to use internal state API, not attrs
Oh right okay, attributes like `status="loading"` are more of an API
for the caller, whereas the internal state API is where you wanna put
things that are meant to be used in CSS selectors and stuff.
2024-07-02 22:34:51 -07:00
3c415e9cd3 Refactor item page outfit-layer to use Web Components
Instead of doing all this listening to Turbo events etc to know when
outfit layers might have changed, making it a custom element and wiring
in the behavior to its actual lifecycle makes it always Just Work!
2024-07-02 22:24:26 -07:00
857812610a Refactor outfit_viewer_layers helper to just be inlined into template
I forget what complexity was in here previously that made this make
sense before, but now it's just a loop, whatever!
2024-07-02 22:03:43 -07:00
0a9193aed7 Add basic loading tracking to new item page preview
The UI for it is just basic for my own testing rn: it sets the preview
background to gray while loading, then back to white when done!

This uses the new CSS `:has()` selector: we have JS manage the loading
state on each layer, then the container just restyles itself based on
whether any currently-loading layers are present.
2024-07-01 17:59:07 -07:00
ac002f3151 Track preferred color/species for new item page previews
Also adapted from the Impress 2020 logic!

Note that I refactored `compatible_pet_type` to a series of scopes on
`PetType`. I think this is a simpler, clearer, and more flexible API!
2024-07-01 17:38:31 -07:00
fe6035d438 Default to compatible pet types in new item page preview
Just adapted from Impress 2020 logic again, easy peasy!
2024-07-01 17:20:38 -07:00
21a8a49f50 Remove redundant SwfAsset relation
Huh, this is just in here twice? Weird. Goodbye!
2024-07-01 17:20:05 -07:00
3f38fbd1b0 Support zone restriction in new item preview
Adapted from wardrobe-2020's `getVisibleLayers`! Thanks past-Matchu for
all the comments lol!
2024-07-01 16:54:39 -07:00
74748acaaf Refactor more of item outfit preview into the Outfit class
This is a cute thing that I think sets us up for other stuff down the
line: move more of the outfit appearance logic into the `Outfit` class!
Now, we set up the item page with a temporary instance of `Outfit`,
then ask for its `visible_layers`.

Still missing restricted-zones logic and such, that's next!
2024-07-01 16:07:25 -07:00
054c809052 Put the new item preview in a Turbo frame
Nice, gotta say, this is a pretty neat way of making things feel more
app-y! There's some missing pieces here about like, loading state etc,
but the vibes are pretty good, and the implementation was dead-easy!
2024-07-01 15:35:58 -07:00
1b000189c4 [WIP] Add species/color picker for simplified item page preview
Still a lot missing here, like choosing the right default for Baby etc
items, and saving the user's preferences. But it's a start!
2024-07-01 15:35:58 -07:00
ea17e76c39 [WIP] Start replacing item page preview with simpler HTML-based version
Just stripping out the big React component, and having Rails output it!

There's a lot of work rn in extracting the Impress 2020 dependency from
the `wardrobe-2020` React app, and I'm just curious to see if we can
simplify it at all by pulling this stuff *way* back to basics, and
deleting the item page part of `wardrobe-2020` altogether.

In this draft, we regress a lot of functionality: it just shows the
item on a Blue Acara, with no ability to change it! I'm gonna play with
putting more of that back in.

I also haven't actually removed any of the item page React code; I just
stopped calling it. That can be a cleanup for another time, once we're
confident in this experiment!
2024-07-01 15:35:58 -07:00
91 changed files with 2807 additions and 1917 deletions

View file

@ -4,7 +4,7 @@ ruby '3.3.4'
gem 'rails', '~> 7.1', '>= 7.1.3.4'
# The HTTP server running the Rails instance.
gem 'falcon', '~> 0.43.0'
gem 'falcon', '~> 0.48.0'
# Our database is MySQL, in both development and production.
gem 'mysql2', '~> 0.5.5'
@ -61,8 +61,8 @@ gem "httparty", "~> 0.22.0"
gem "addressable", "~> 2.8"
# For advanced batching of many HTTP requests.
gem "async", "~> 2.6", require: false
gem "async-http", "~> 0.61.0", require: false
gem "async", "~> 2.17", require: false
gem "async-http", "~> 0.75.0", require: false
gem "thread-local", "~> 1.1", require: false
# For debugging.

View file

@ -81,29 +81,30 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.2)
async (2.16.1)
async (2.17.0)
console (~> 1.26)
fiber-annotation
io-event (~> 1.6, >= 1.6.5)
async-container (0.16.13)
async
async-io
async-http (0.61.0)
async (>= 1.25)
async-io (>= 1.28)
async-pool (>= 0.2)
protocol-http (~> 0.25.0)
protocol-http1 (~> 0.16.0)
protocol-http2 (~> 0.15.0)
traces (>= 0.10.0)
async-container (0.18.3)
async (~> 2.10)
async-http (0.75.0)
async (>= 2.10.2)
async-pool (~> 0.7)
io-endpoint (~> 0.11)
io-stream (~> 0.4)
protocol-http (~> 0.30)
protocol-http1 (~> 0.20)
protocol-http2 (~> 0.18)
traces (>= 0.10)
async-http-cache (0.4.4)
async-http (~> 0.56)
async-io (1.43.2)
async
async-pool (0.8.1)
async (>= 1.25)
metrics
traces
async-service (0.12.0)
async
async-container (~> 0.16)
attr_required (1.0.2)
babel-source (5.8.35)
babel-transpiler (0.7.0)
@ -118,7 +119,6 @@ GEM
bindex (0.8.1)
bootsnap (1.18.4)
msgpack (~> 1.2)
build-environment (1.13.0)
builder (3.3.0)
childprocess (5.1.0)
logger (~> 1.5)
@ -150,19 +150,19 @@ GEM
activemodel
erubi (1.13.0)
execjs (2.9.1)
falcon (0.43.0)
falcon (0.48.0)
async
async-container (~> 0.16.0)
async-http (~> 0.57)
async-http-cache (~> 0.4.0)
async-io (~> 1.22)
build-environment (~> 1.13)
async-container (~> 0.18)
async-http (~> 0.75)
async-http-cache (~> 0.4)
async-service (~> 0.10)
bundler
localhost (~> 1.1)
openssl (~> 3.0)
process-metrics (~> 0.2.0)
protocol-rack (~> 0.1)
samovar (~> 2.1)
process-metrics (~> 0.2)
protocol-http (~> 0.31)
protocol-rack (~> 0.7)
samovar (~> 2.3)
faraday (2.11.0)
faraday-net_http (>= 2.0, < 3.4)
logger
@ -190,7 +190,9 @@ GEM
i18n (1.14.5)
concurrent-ruby (~> 1.0)
io-console (0.7.2)
io-endpoint (0.13.1)
io-event (1.6.5)
io-stream (0.4.0)
irb (1.14.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
@ -280,18 +282,19 @@ GEM
parser (3.3.4.2)
ast (~> 2.4.1)
racc
process-metrics (0.2.1)
process-metrics (0.3.0)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.5.0)
protocol-http (0.25.0)
protocol-http1 (0.16.1)
protocol-http (0.33.0)
protocol-http1 (0.22.0)
protocol-http (~> 0.22)
protocol-http2 (0.15.1)
protocol-http2 (0.18.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.18)
protocol-rack (0.6.0)
protocol-http (~> 0.23)
protocol-rack (0.7.0)
protocol-http (~> 0.27)
rack (>= 1.0)
psych (5.1.2)
stringio
@ -495,13 +498,13 @@ PLATFORMS
DEPENDENCIES
RocketAMF!
addressable (~> 2.8)
async (~> 2.6)
async-http (~> 0.61.0)
async (~> 2.17)
async-http (~> 0.75.0)
bootsnap (~> 1.16)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.43.0)
falcon (~> 0.48.0)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.22.0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

View file

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

@ -0,0 +1,850 @@
// https://raw.githubusercontent.com/bigskysoftware/idiomorph/v0.3.0/dist/idiomorph.js
// base IIFE to define idiomorph
var Idiomorph = (function () {
'use strict';
//=============================================================================
// AND NOW IT BEGINS...
//=============================================================================
let EMPTY_SET = new Set();
// default configuration values, updatable by users now
let defaults = {
morphStyle: "outerHTML",
callbacks : {
beforeNodeAdded: noOp,
afterNodeAdded: noOp,
beforeNodeMorphed: noOp,
afterNodeMorphed: noOp,
beforeNodeRemoved: noOp,
afterNodeRemoved: noOp,
beforeAttributeUpdated: noOp,
},
head: {
style: 'merge',
shouldPreserve: function (elt) {
return elt.getAttribute("im-preserve") === "true";
},
shouldReAppend: function (elt) {
return elt.getAttribute("im-re-append") === "true";
},
shouldRemove: noOp,
afterHeadMorphed: noOp,
}
};
//=============================================================================
// Core Morphing Algorithm - morph, morphNormalizedContent, morphOldNodeTo, morphChildren
//=============================================================================
function morph(oldNode, newContent, config = {}) {
if (oldNode instanceof Document) {
oldNode = oldNode.documentElement;
}
if (typeof newContent === 'string') {
newContent = parseContent(newContent);
}
let normalizedContent = normalizeContent(newContent);
let ctx = createMorphContext(oldNode, normalizedContent, config);
return morphNormalizedContent(oldNode, normalizedContent, ctx);
}
function morphNormalizedContent(oldNode, normalizedNewContent, ctx) {
if (ctx.head.block) {
let oldHead = oldNode.querySelector('head');
let newHead = normalizedNewContent.querySelector('head');
if (oldHead && newHead) {
let promises = handleHeadElement(newHead, oldHead, ctx);
// when head promises resolve, call morph again, ignoring the head tag
Promise.all(promises).then(function () {
morphNormalizedContent(oldNode, normalizedNewContent, Object.assign(ctx, {
head: {
block: false,
ignore: true
}
}));
});
return;
}
}
if (ctx.morphStyle === "innerHTML") {
// innerHTML, so we are only updating the children
morphChildren(normalizedNewContent, oldNode, ctx);
return oldNode.children;
} else if (ctx.morphStyle === "outerHTML" || ctx.morphStyle == null) {
// otherwise find the best element match in the new content, morph that, and merge its siblings
// into either side of the best match
let bestMatch = findBestNodeMatch(normalizedNewContent, oldNode, ctx);
// stash the siblings that will need to be inserted on either side of the best match
let previousSibling = bestMatch?.previousSibling;
let nextSibling = bestMatch?.nextSibling;
// morph it
let morphedNode = morphOldNodeTo(oldNode, bestMatch, ctx);
if (bestMatch) {
// if there was a best match, merge the siblings in too and return the
// whole bunch
return insertSiblings(previousSibling, morphedNode, nextSibling);
} else {
// otherwise nothing was added to the DOM
return []
}
} else {
throw "Do not understand how to morph style " + ctx.morphStyle;
}
}
/**
* @param possibleActiveElement
* @param ctx
* @returns {boolean}
*/
function ignoreValueOfActiveElement(possibleActiveElement, ctx) {
return ctx.ignoreActiveValue && possibleActiveElement === document.activeElement;
}
/**
* @param oldNode root node to merge content into
* @param newContent new content to merge
* @param ctx the merge context
* @returns {Element} the element that ended up in the DOM
*/
function morphOldNodeTo(oldNode, newContent, ctx) {
if (ctx.ignoreActive && oldNode === document.activeElement) {
// don't morph focused element
} else if (newContent == null) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
oldNode.remove();
ctx.callbacks.afterNodeRemoved(oldNode);
return null;
} else if (!isSoftMatch(oldNode, newContent)) {
if (ctx.callbacks.beforeNodeRemoved(oldNode) === false) return oldNode;
if (ctx.callbacks.beforeNodeAdded(newContent) === false) return oldNode;
oldNode.parentElement.replaceChild(newContent, oldNode);
ctx.callbacks.afterNodeAdded(newContent);
ctx.callbacks.afterNodeRemoved(oldNode);
return newContent;
} else {
if (ctx.callbacks.beforeNodeMorphed(oldNode, newContent) === false) return oldNode;
if (oldNode instanceof HTMLHeadElement && ctx.head.ignore) {
// ignore the head element
} else if (oldNode instanceof HTMLHeadElement && ctx.head.style !== "morph") {
handleHeadElement(newContent, oldNode, ctx);
} else {
syncNodeFrom(newContent, oldNode, ctx);
if (!ignoreValueOfActiveElement(oldNode, ctx)) {
morphChildren(newContent, oldNode, ctx);
}
}
ctx.callbacks.afterNodeMorphed(oldNode, newContent);
return oldNode;
}
}
/**
* This is the core algorithm for matching up children. The idea is to use id sets to try to match up
* nodes as faithfully as possible. We greedily match, which allows us to keep the algorithm fast, but
* by using id sets, we are able to better match up with content deeper in the DOM.
*
* Basic algorithm is, for each node in the new content:
*
* - if we have reached the end of the old parent, append the new content
* - if the new content has an id set match with the current insertion point, morph
* - search for an id set match
* - if id set match found, morph
* - otherwise search for a "soft" match
* - if a soft match is found, morph
* - otherwise, prepend the new node before the current insertion point
*
* The two search algorithms terminate if competing node matches appear to outweigh what can be achieved
* with the current node. See findIdSetMatch() and findSoftMatch() for details.
*
* @param {Element} newParent the parent element of the new content
* @param {Element } oldParent the old content that we are merging the new content into
* @param ctx the merge context
*/
function morphChildren(newParent, oldParent, ctx) {
let nextNewChild = newParent.firstChild;
let insertionPoint = oldParent.firstChild;
let newChild;
// run through all the new content
while (nextNewChild) {
newChild = nextNewChild;
nextNewChild = newChild.nextSibling;
// if we are at the end of the exiting parent's children, just append
if (insertionPoint == null) {
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.appendChild(newChild);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// if the current node has an id set match then morph
if (isIdSetMatch(newChild, insertionPoint, ctx)) {
morphOldNodeTo(insertionPoint, newChild, ctx);
insertionPoint = insertionPoint.nextSibling;
removeIdsFromConsideration(ctx, newChild);
continue;
}
// otherwise search forward in the existing old children for an id set match
let idSetMatch = findIdSetMatch(newParent, oldParent, newChild, insertionPoint, ctx);
// if we found a potential match, remove the nodes until that point and morph
if (idSetMatch) {
insertionPoint = removeNodesBetween(insertionPoint, idSetMatch, ctx);
morphOldNodeTo(idSetMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// no id set match found, so scan forward for a soft match for the current node
let softMatch = findSoftMatch(newParent, oldParent, newChild, insertionPoint, ctx);
// if we found a soft match for the current node, morph
if (softMatch) {
insertionPoint = removeNodesBetween(insertionPoint, softMatch, ctx);
morphOldNodeTo(softMatch, newChild, ctx);
removeIdsFromConsideration(ctx, newChild);
continue;
}
// abandon all hope of morphing, just insert the new child before the insertion point
// and move on
if (ctx.callbacks.beforeNodeAdded(newChild) === false) return;
oldParent.insertBefore(newChild, insertionPoint);
ctx.callbacks.afterNodeAdded(newChild);
removeIdsFromConsideration(ctx, newChild);
}
// remove any remaining old nodes that didn't match up with new content
while (insertionPoint !== null) {
let tempNode = insertionPoint;
insertionPoint = insertionPoint.nextSibling;
removeNode(tempNode, ctx);
}
}
//=============================================================================
// Attribute Syncing Code
//=============================================================================
/**
* @param attr {String} the attribute to be mutated
* @param to {Element} the element that is going to be updated
* @param updateType {("update"|"remove")}
* @param ctx the merge context
* @returns {boolean} true if the attribute should be ignored, false otherwise
*/
function ignoreAttribute(attr, to, updateType, ctx) {
if(attr === 'value' && ctx.ignoreActiveValue && to === document.activeElement){
return true;
}
return ctx.callbacks.beforeAttributeUpdated(attr, to, updateType) === false;
}
/**
* syncs a given node with another node, copying over all attributes and
* inner element state from the 'from' node to the 'to' node
*
* @param {Element} from the element to copy attributes & state from
* @param {Element} to the element to copy attributes & state to
* @param ctx the merge context
*/
function syncNodeFrom(from, to, ctx) {
let type = from.nodeType
// if is an element type, sync the attributes from the
// new node into the new node
if (type === 1 /* element type */) {
const fromAttributes = from.attributes;
const toAttributes = to.attributes;
for (const fromAttribute of fromAttributes) {
if (ignoreAttribute(fromAttribute.name, to, 'update', ctx)) {
continue;
}
if (to.getAttribute(fromAttribute.name) !== fromAttribute.value) {
to.setAttribute(fromAttribute.name, fromAttribute.value);
}
}
// iterate backwards to avoid skipping over items when a delete occurs
for (let i = toAttributes.length - 1; 0 <= i; i--) {
const toAttribute = toAttributes[i];
if (ignoreAttribute(toAttribute.name, to, 'remove', ctx)) {
continue;
}
if (!from.hasAttribute(toAttribute.name)) {
to.removeAttribute(toAttribute.name);
}
}
}
// sync text nodes
if (type === 8 /* comment */ || type === 3 /* text */) {
if (to.nodeValue !== from.nodeValue) {
to.nodeValue = from.nodeValue;
}
}
if (!ignoreValueOfActiveElement(to, ctx)) {
// sync input values
syncInputValue(from, to, ctx);
}
}
/**
* @param from {Element} element to sync the value from
* @param to {Element} element to sync the value to
* @param attributeName {String} the attribute name
* @param ctx the merge context
*/
function syncBooleanAttribute(from, to, attributeName, ctx) {
if (from[attributeName] !== to[attributeName]) {
let ignoreUpdate = ignoreAttribute(attributeName, to, 'update', ctx);
if (!ignoreUpdate) {
to[attributeName] = from[attributeName];
}
if (from[attributeName]) {
if (!ignoreUpdate) {
to.setAttribute(attributeName, from[attributeName]);
}
} else {
if (!ignoreAttribute(attributeName, to, 'remove', ctx)) {
to.removeAttribute(attributeName);
}
}
}
}
/**
* NB: many bothans died to bring us information:
*
* https://github.com/patrick-steele-idem/morphdom/blob/master/src/specialElHandlers.js
* https://github.com/choojs/nanomorph/blob/master/lib/morph.jsL113
*
* @param from {Element} the element to sync the input value from
* @param to {Element} the element to sync the input value to
* @param ctx the merge context
*/
function syncInputValue(from, to, ctx) {
if (from instanceof HTMLInputElement &&
to instanceof HTMLInputElement &&
from.type !== 'file') {
let fromValue = from.value;
let toValue = to.value;
// sync boolean attributes
syncBooleanAttribute(from, to, 'checked', ctx);
syncBooleanAttribute(from, to, 'disabled', ctx);
if (!from.hasAttribute('value')) {
if (!ignoreAttribute('value', to, 'remove', ctx)) {
to.value = '';
to.removeAttribute('value');
}
} else if (fromValue !== toValue) {
if (!ignoreAttribute('value', to, 'update', ctx)) {
to.setAttribute('value', fromValue);
to.value = fromValue;
}
}
} else if (from instanceof HTMLOptionElement) {
syncBooleanAttribute(from, to, 'selected', ctx)
} else if (from instanceof HTMLTextAreaElement && to instanceof HTMLTextAreaElement) {
let fromValue = from.value;
let toValue = to.value;
if (ignoreAttribute('value', to, 'update', ctx)) {
return;
}
if (fromValue !== toValue) {
to.value = fromValue;
}
if (to.firstChild && to.firstChild.nodeValue !== fromValue) {
to.firstChild.nodeValue = fromValue
}
}
}
//=============================================================================
// the HEAD tag can be handled specially, either w/ a 'merge' or 'append' style
//=============================================================================
function handleHeadElement(newHeadTag, currentHead, ctx) {
let added = []
let removed = []
let preserved = []
let nodesToAppend = []
let headMergeStyle = ctx.head.style;
// put all new head elements into a Map, by their outerHTML
let srcToNewHeadNodes = new Map();
for (const newHeadChild of newHeadTag.children) {
srcToNewHeadNodes.set(newHeadChild.outerHTML, newHeadChild);
}
// for each elt in the current head
for (const currentHeadElt of currentHead.children) {
// If the current head element is in the map
let inNewContent = srcToNewHeadNodes.has(currentHeadElt.outerHTML);
let isReAppended = ctx.head.shouldReAppend(currentHeadElt);
let isPreserved = ctx.head.shouldPreserve(currentHeadElt);
if (inNewContent || isPreserved) {
if (isReAppended) {
// remove the current version and let the new version replace it and re-execute
removed.push(currentHeadElt);
} else {
// this element already exists and should not be re-appended, so remove it from
// the new content map, preserving it in the DOM
srcToNewHeadNodes.delete(currentHeadElt.outerHTML);
preserved.push(currentHeadElt);
}
} else {
if (headMergeStyle === "append") {
// we are appending and this existing element is not new content
// so if and only if it is marked for re-append do we do anything
if (isReAppended) {
removed.push(currentHeadElt);
nodesToAppend.push(currentHeadElt);
}
} else {
// if this is a merge, we remove this content since it is not in the new head
if (ctx.head.shouldRemove(currentHeadElt) !== false) {
removed.push(currentHeadElt);
}
}
}
}
// Push the remaining new head elements in the Map into the
// nodes to append to the head tag
nodesToAppend.push(...srcToNewHeadNodes.values());
log("to append: ", nodesToAppend);
let promises = [];
for (const newNode of nodesToAppend) {
log("adding: ", newNode);
let newElt = document.createRange().createContextualFragment(newNode.outerHTML).firstChild;
log(newElt);
if (ctx.callbacks.beforeNodeAdded(newElt) !== false) {
if (newElt.href || newElt.src) {
let resolve = null;
let promise = new Promise(function (_resolve) {
resolve = _resolve;
});
newElt.addEventListener('load', function () {
resolve();
});
promises.push(promise);
}
currentHead.appendChild(newElt);
ctx.callbacks.afterNodeAdded(newElt);
added.push(newElt);
}
}
// remove all removed elements, after we have appended the new elements to avoid
// additional network requests for things like style sheets
for (const removedElement of removed) {
if (ctx.callbacks.beforeNodeRemoved(removedElement) !== false) {
currentHead.removeChild(removedElement);
ctx.callbacks.afterNodeRemoved(removedElement);
}
}
ctx.head.afterHeadMorphed(currentHead, {added: added, kept: preserved, removed: removed});
return promises;
}
//=============================================================================
// Misc
//=============================================================================
function log() {
//console.log(arguments);
}
function noOp() {
}
/*
Deep merges the config object and the Idiomoroph.defaults object to
produce a final configuration object
*/
function mergeDefaults(config) {
let finalConfig = {};
// copy top level stuff into final config
Object.assign(finalConfig, defaults);
Object.assign(finalConfig, config);
// copy callbacks into final config (do this to deep merge the callbacks)
finalConfig.callbacks = {};
Object.assign(finalConfig.callbacks, defaults.callbacks);
Object.assign(finalConfig.callbacks, config.callbacks);
// copy head config into final config (do this to deep merge the head)
finalConfig.head = {};
Object.assign(finalConfig.head, defaults.head);
Object.assign(finalConfig.head, config.head);
return finalConfig;
}
function createMorphContext(oldNode, newContent, config) {
config = mergeDefaults(config);
return {
target: oldNode,
newContent: newContent,
config: config,
morphStyle: config.morphStyle,
ignoreActive: config.ignoreActive,
ignoreActiveValue: config.ignoreActiveValue,
idMap: createIdMap(oldNode, newContent),
deadIds: new Set(),
callbacks: config.callbacks,
head: config.head
}
}
function isIdSetMatch(node1, node2, ctx) {
if (node1 == null || node2 == null) {
return false;
}
if (node1.nodeType === node2.nodeType && node1.tagName === node2.tagName) {
if (node1.id !== "" && node1.id === node2.id) {
return true;
} else {
return getIdIntersectionCount(ctx, node1, node2) > 0;
}
}
return false;
}
function isSoftMatch(node1, node2) {
if (node1 == null || node2 == null) {
return false;
}
return node1.nodeType === node2.nodeType && node1.tagName === node2.tagName
}
function removeNodesBetween(startInclusive, endExclusive, ctx) {
while (startInclusive !== endExclusive) {
let tempNode = startInclusive;
startInclusive = startInclusive.nextSibling;
removeNode(tempNode, ctx);
}
removeIdsFromConsideration(ctx, endExclusive);
return endExclusive.nextSibling;
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential id match
// for the newChild. We stop if we find a potential id match for the new child OR
// if the number of potential id matches we are discarding is greater than the
// potential id matches for the new child
//=============================================================================
function findIdSetMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
// max id matches we are willing to discard in our search
let newChildPotentialIdCount = getIdIntersectionCount(ctx, newChild, oldParent);
let potentialMatch = null;
// only search forward if there is a possibility of an id match
if (newChildPotentialIdCount > 0) {
let potentialMatch = insertionPoint;
// if there is a possibility of an id match, scan forward
// keep track of the potential id match count we are discarding (the
// newChildPotentialIdCount must be greater than this to make it likely
// worth it)
let otherMatchCount = 0;
while (potentialMatch != null) {
// If we have an id match, return the current potential match
if (isIdSetMatch(newChild, potentialMatch, ctx)) {
return potentialMatch;
}
// computer the other potential matches of this new content
otherMatchCount += getIdIntersectionCount(ctx, potentialMatch, newContent);
if (otherMatchCount > newChildPotentialIdCount) {
// if we have more potential id matches in _other_ content, we
// do not have a good candidate for an id match, so return null
return null;
}
// advanced to the next old content child
potentialMatch = potentialMatch.nextSibling;
}
}
return potentialMatch;
}
//=============================================================================
// Scans forward from the insertionPoint in the old parent looking for a potential soft match
// for the newChild. We stop if we find a potential soft match for the new child OR
// if we find a potential id match in the old parents children OR if we find two
// potential soft matches for the next two pieces of new content
//=============================================================================
function findSoftMatch(newContent, oldParent, newChild, insertionPoint, ctx) {
let potentialSoftMatch = insertionPoint;
let nextSibling = newChild.nextSibling;
let siblingSoftMatchCount = 0;
while (potentialSoftMatch != null) {
if (getIdIntersectionCount(ctx, potentialSoftMatch, newContent) > 0) {
// the current potential soft match has a potential id set match with the remaining new
// content so bail out of looking
return null;
}
// if we have a soft match with the current node, return it
if (isSoftMatch(newChild, potentialSoftMatch)) {
return potentialSoftMatch;
}
if (isSoftMatch(nextSibling, potentialSoftMatch)) {
// the next new node has a soft match with this node, so
// increment the count of future soft matches
siblingSoftMatchCount++;
nextSibling = nextSibling.nextSibling;
// If there are two future soft matches, bail to allow the siblings to soft match
// so that we don't consume future soft matches for the sake of the current node
if (siblingSoftMatchCount >= 2) {
return null;
}
}
// advanced to the next old content child
potentialSoftMatch = potentialSoftMatch.nextSibling;
}
return potentialSoftMatch;
}
function parseContent(newContent) {
let parser = new DOMParser();
// remove svgs to avoid false-positive matches on head, etc.
let contentWithSvgsRemoved = newContent.replace(/<svg(\s[^>]*>|>)([\s\S]*?)<\/svg>/gim, '');
// if the newContent contains a html, head or body tag, we can simply parse it w/o wrapping
if (contentWithSvgsRemoved.match(/<\/html>/) || contentWithSvgsRemoved.match(/<\/head>/) || contentWithSvgsRemoved.match(/<\/body>/)) {
let content = parser.parseFromString(newContent, "text/html");
// if it is a full HTML document, return the document itself as the parent container
if (contentWithSvgsRemoved.match(/<\/html>/)) {
content.generatedByIdiomorph = true;
return content;
} else {
// otherwise return the html element as the parent container
let htmlElement = content.firstChild;
if (htmlElement) {
htmlElement.generatedByIdiomorph = true;
return htmlElement;
} else {
return null;
}
}
} else {
// if it is partial HTML, wrap it in a template tag to provide a parent element and also to help
// deal with touchy tags like tr, tbody, etc.
let responseDoc = parser.parseFromString("<body><template>" + newContent + "</template></body>", "text/html");
let content = responseDoc.body.querySelector('template').content;
content.generatedByIdiomorph = true;
return content
}
}
function normalizeContent(newContent) {
if (newContent == null) {
// noinspection UnnecessaryLocalVariableJS
const dummyParent = document.createElement('div');
return dummyParent;
} else if (newContent.generatedByIdiomorph) {
// the template tag created by idiomorph parsing can serve as a dummy parent
return newContent;
} else if (newContent instanceof Node) {
// a single node is added as a child to a dummy parent
const dummyParent = document.createElement('div');
dummyParent.append(newContent);
return dummyParent;
} else {
// all nodes in the array or HTMLElement collection are consolidated under
// a single dummy parent element
const dummyParent = document.createElement('div');
for (const elt of [...newContent]) {
dummyParent.append(elt);
}
return dummyParent;
}
}
function insertSiblings(previousSibling, morphedNode, nextSibling) {
let stack = []
let added = []
while (previousSibling != null) {
stack.push(previousSibling);
previousSibling = previousSibling.previousSibling;
}
while (stack.length > 0) {
let node = stack.pop();
added.push(node); // push added preceding siblings on in order and insert
morphedNode.parentElement.insertBefore(node, morphedNode);
}
added.push(morphedNode);
while (nextSibling != null) {
stack.push(nextSibling);
added.push(nextSibling); // here we are going in order, so push on as we scan, rather than add
nextSibling = nextSibling.nextSibling;
}
while (stack.length > 0) {
morphedNode.parentElement.insertBefore(stack.pop(), morphedNode.nextSibling);
}
return added;
}
function findBestNodeMatch(newContent, oldNode, ctx) {
let currentElement;
currentElement = newContent.firstChild;
let bestElement = currentElement;
let score = 0;
while (currentElement) {
let newScore = scoreElement(currentElement, oldNode, ctx);
if (newScore > score) {
bestElement = currentElement;
score = newScore;
}
currentElement = currentElement.nextSibling;
}
return bestElement;
}
function scoreElement(node1, node2, ctx) {
if (isSoftMatch(node1, node2)) {
return .5 + getIdIntersectionCount(ctx, node1, node2);
}
return 0;
}
function removeNode(tempNode, ctx) {
removeIdsFromConsideration(ctx, tempNode)
if (ctx.callbacks.beforeNodeRemoved(tempNode) === false) return;
tempNode.remove();
ctx.callbacks.afterNodeRemoved(tempNode);
}
//=============================================================================
// ID Set Functions
//=============================================================================
function isIdInConsideration(ctx, id) {
return !ctx.deadIds.has(id);
}
function idIsWithinNode(ctx, id, targetNode) {
let idSet = ctx.idMap.get(targetNode) || EMPTY_SET;
return idSet.has(id);
}
function removeIdsFromConsideration(ctx, node) {
let idSet = ctx.idMap.get(node) || EMPTY_SET;
for (const id of idSet) {
ctx.deadIds.add(id);
}
}
function getIdIntersectionCount(ctx, node1, node2) {
let sourceSet = ctx.idMap.get(node1) || EMPTY_SET;
let matchCount = 0;
for (const id of sourceSet) {
// a potential match is an id in the source and potentialIdsSet, but
// that has not already been merged into the DOM
if (isIdInConsideration(ctx, id) && idIsWithinNode(ctx, id, node2)) {
++matchCount;
}
}
return matchCount;
}
/**
* A bottom up algorithm that finds all elements with ids inside of the node
* argument and populates id sets for those nodes and all their parents, generating
* a set of ids contained within all nodes for the entire hierarchy in the DOM
*
* @param node {Element}
* @param {Map<Node, Set<String>>} idMap
*/
function populateIdMapForNode(node, idMap) {
let nodeParent = node.parentElement;
// find all elements with an id property
let idElements = node.querySelectorAll('[id]');
for (const elt of idElements) {
let current = elt;
// walk up the parent hierarchy of that element, adding the id
// of element to the parent's id set
while (current !== nodeParent && current != null) {
let idSet = idMap.get(current);
// if the id set doesn't exist, create it and insert it in the map
if (idSet == null) {
idSet = new Set();
idMap.set(current, idSet);
}
idSet.add(elt.id);
current = current.parentElement;
}
}
}
/**
* This function computes a map of nodes to all ids contained within that node (inclusive of the
* node). This map can be used to ask if two nodes have intersecting sets of ids, which allows
* for a looser definition of "matching" than tradition id matching, and allows child nodes
* to contribute to a parent nodes matching.
*
* @param {Element} oldContent the old content that will be morphed
* @param {Element} newContent the new content to morph to
* @returns {Map<Node, Set<String>>} a map of nodes to id sets for the
*/
function createIdMap(oldContent, newContent) {
let idMap = new Map();
populateIdMapForNode(oldContent, idMap);
populateIdMapForNode(newContent, idMap);
return idMap;
}
//=============================================================================
// This is what ends up becoming the Idiomorph global object
//=============================================================================
return {
morph,
defaults
}
})();

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,221 @@
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,5 +1,14 @@
var DEBUG = document.location.search.substr(0, 6) == "?debug";
function petThumbnailUrl(pet_name) {
// if first character is "@", use the hash url
if (pet_name[0] == "@") {
return "https://pets.neopets.com/cp/" + pet_name.substr(1) + "/1/1.png";
}
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
/* Needed items form */
(function () {
var UI = {};
@ -65,10 +74,6 @@ var DEBUG = document.location.search.substr(0, 6) == "?debug";
loadItems(data.query);
}
function petThumbnailUrl(pet_name) {
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
/* Items */
function loadItems(query) {
@ -127,7 +132,7 @@ var DEBUG = document.location.search.substr(0, 6) == "?debug";
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name })
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el);
this.load = function () {

View file

@ -0,0 +1,356 @@
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,9 +5,15 @@ body.items-index, body.items-show, body.items-needed, body.item_trades
text-align: center
input[type=text]
font-size: 125%
width: 15em
.item-search-form
display: flex
gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1
margin-bottom: 1em

View file

@ -0,0 +1,12 @@
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,6 +4,7 @@
@import partials/clean/mixins
@import layout
@import responsive
@import partials/jquery.jgrowl
@ -20,5 +21,4 @@
@import outfits/index
@import outfits/new
@import pets/bulk
@import swf_assets/links
@import users/top_contributors

View file

@ -0,0 +1,64 @@
.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,6 +3,9 @@
@import "../partials/item_header"
body.items-show
#container
width: 900px // A bit more generous to the preview area!
.item-header
+item-header
@ -37,3 +40,345 @@ body.items-show
.nc-icon
height: 16px
width: 16px
.preview-area
margin: 0 auto
position: relative
.customize-more
position: absolute
top: 1em
right: 1em
display: flex
align-items: center
text-decoration: none
background: #EDF2F7
padding: .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,9 +7,8 @@ body.outfits-new
#pet-not-found
display: none
.neopass-announcement
border: 1px solid #cd8400
color: #764a00
.announcement
border: 1px solid $module-border-color
padding: .5em
display: grid
grid-template-areas: "thumbnail content"
@ -24,9 +23,6 @@ body.outfits-new
p:last-of-type
margin-bottom: 0
a
color: #be7a00
#outfit-forms
+clearfix
+module

View file

@ -35,6 +35,7 @@
text-align: left
display: flex
align-items: center
flex-wrap: wrap
gap: 1em
abbr
@ -127,6 +128,7 @@
.item-subpages-nav
display: flex
align-items: flex-end
gap: 1em
.preview-link
margin-right: auto
@ -167,4 +169,4 @@
background: $background-color
padding-bottom: calc(.5em + 1px)
font-weight: bold
margin-bottom: -1px
margin-bottom: -1px

View file

@ -1,10 +0,0 @@
@import "../partials/assets-list"
body.swf_assets-links
#swf-assets
+assets-list
li
span
font-size: 75%
word-wrap: break-word

View file

@ -0,0 +1,12 @@
#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,6 +28,12 @@ class ItemsController < ApplicationController
render json: {
items: @items.as_json(
methods: [:nc?, :pb?, :owned?, :wanted?],
include: {
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
},
),
appearances: load_appearances.as_json(
include: {
@ -82,6 +88,21 @@ class ItemsController < ApplicationController
group_by_owned
@current_user_quantities = current_user.item_quantities_for(@item)
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
format.gif do
@ -180,7 +201,7 @@ class ItemsController < ApplicationController
appearance_params[:color_id], appearance_params[:species_id])
end
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]).
target.appearances_for(@items, swf_asset_includes: [:zone]).
tap do |appearances|
# Preload the manifests for these SWF assets concurrently, rather than
# loading them in sequence when we generate the JSON.
@ -188,6 +209,43 @@ class ItemsController < ApplicationController
SwfAsset.preload_manifests(swf_assets)
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)
@items = []

View file

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

@ -1,6 +1,6 @@
module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL
path_or_url
@ -101,6 +101,12 @@ module ApplicationHelper
"matchu@openneo.net"
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!
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
@ -231,6 +237,15 @@ module ApplicationHelper
@hide_title_header = true
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
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
end

View file

@ -31,11 +31,14 @@ module ItemsHelper
end
def standard_species_search_links
build_on_pet_types(Species.alphabetical) do |pet_type|
image = pet_type_image(pet_type, :happy, :zoom)
all_species = Species.alphabetical.map(&:id)
PetType.random_basic_per_species(all_species).map do |pet_type|
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}"
link_to(image, items_path(:q => query))
end
end.join.html_safe
end
def closet_list_verb(owned)
@ -112,14 +115,7 @@ module ItemsHelper
item_or_name = item_or_name.name if item_or_name.is_a? Item
SHOP_WIZARD_URL_TEMPLATE.expand(string: item_or_name).to_s
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(
"https://www.neopets.com/island/tradingpost.phtml?type=browse&criteria=item_exact{&search_string}"
)
@ -224,21 +220,37 @@ module ItemsHelper
end
end
private
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
def outfit_viewer_is_playing
cookies["DTIOutfitViewerIsPlaying"] == "true"
end
def pet_type_image(pet_type, emotion, size)
def item_fits?(item, pet_type)
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:)
human_name = pet_type.species.name.humanize
image_tag(src, :alt => human_name, :title => human_name)
srcset = if size == :face
[[pet_type_image_url(pet_type, emotion:, size: :face_2x), "2x"]]
end
image_tag(src, srcset:, **options)
end
def item_header_user_lists_form_state

View file

@ -1,7 +1,7 @@
module OutfitsHelper
LAST_DAY_OF_NEOPASS_ANNOUNCEMENT = Date.parse("2024-05-05")
def show_neopass_announcement?
Date.today <= LAST_DAY_OF_NEOPASS_ANNOUNCEMENT
LAST_DAY_OF_ANNOUNCEMENT = Date.parse("2024-09-13")
def show_announcement?
Date.today <= LAST_DAY_OF_ANNOUNCEMENT
end
def destination_tag(value)

View file

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

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

@ -1,691 +0,0 @@
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,5 +1,4 @@
import AppProvider from "./AppProvider";
import ItemPageOutfitPreview from "./ItemPageOutfitPreview";
import WardrobePage from "./WardrobePage";
export { AppProvider, ItemPageOutfitPreview, WardrobePage };
export { AppProvider, WardrobePage };

View file

@ -201,10 +201,10 @@ function normalizeItemSearchAppearance(data, item) {
__typename: "ItemAppearance",
id: `item-${item.id}-body-${data.body.id}`,
layers: data.swf_assets.map(normalizeSwfAssetToLayer),
restrictedZones: data.swf_assets
.map((a) => a.restricted_zones)
.flat()
.map(normalizeZone),
restrictedZones: [
...item.restricted_zones,
...data.swf_assets.map((a) => a.restricted_zones).flat(),
].map(normalizeZone),
};
}

View file

@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord
swf_asset.image_url
end
# Given a list of item IDs, return how they look on this alt style.
def appearances_for(item_ids, ...)
Item.appearances_for(item_ids, self, ...)
# Given a list of items, return how they look on this alt style.
def appearances_for(items, ...)
Item.appearances_for(items, self, ...)
end
def biology=(biology)

View file

@ -35,6 +35,16 @@ class Color < ApplicationRecord
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?
now = Time.now.in_time_zone('Pacific Time (US & Canada)')
now.month == 4 && now.day == 1

View file

@ -25,8 +25,6 @@ class Item < ApplicationRecord
NCRarities = [0, 500]
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, -> {
order(arel_table[:created_at].desc) if arel_table[:created_at]
@ -278,83 +276,20 @@ class Item < ApplicationRecord
end
@restricted_zone_ids
end
def occupied_zone_ids
occupied_zones.map(&:id)
end
def occupied_zones(options={})
options[:scope] ||= Zone.all
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
def occupied_zones
zone_ids = swf_assets.map(&:zone_id).uniq
Zone.find(zone_ids)
end
def affected_zones
restricted_zones + occupied_zones
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
@species_support_ids_array ||= read_attribute('species_support_ids').split(',').map(&:to_i) rescue nil
end
@ -489,6 +424,15 @@ class Item < ApplicationRecord
}.merge(options))
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!
if @parent_swf_asset_relationships_to_update && @current_body_id
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
@ -555,20 +499,48 @@ class Item < ApplicationRecord
# 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
# bit more suited to it!
Appearance = Struct.new(:body, :swf_assets) do
Appearance = Struct.new(:item, :body, :swf_assets) do
include ActiveModel::Serializers::JSON
delegate :present?, :empty?, to: :swf_assets
delegate :species, :fits?, :fits_all?, to: :body
def attributes
{body: body, swf_assets: swf_assets}
{item:, body:, 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
Appearance::Body = Struct.new(:id, :species) do
include ActiveModel::Serializers::JSON
def attributes
{id: id, species: species}
{id:, species:}
end
def fits_all?
id == 0
end
def fits?(target)
fits_all? || target.body_id == id
end
end
def appearances
@appearances ||= build_appearances
end
def build_appearances
all_swf_assets = swf_assets.to_a
# If there are no assets yet, there are no appearances.
@ -581,28 +553,48 @@ class Item < ApplicationRecord
# If there are no body-specific assets, return one appearance for them all.
if swf_assets_by_body_id.empty?
body = Appearance::Body.new(0, nil)
return [Appearance.new(body, swf_assets_for_all_bodies)]
return [Appearance.new(self, body, swf_assets_for_all_bodies)]
end
# 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,
# 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_for_body = body_specific_assets + swf_assets_for_all_bodies
species = Species.with_body_id(body_id).first!
body = Appearance::Body.new(body_id, species)
Appearance.new(body, swf_assets_for_body)
body = Appearance::Body.new(body_id, species_by_body_id[body_id])
Appearance.new(self, body, swf_assets_for_body)
end
end
# Given a list of item IDs, return how they look on the given target (either
# a pet type or an alt style).
def self.appearances_for(item_ids, target, swf_asset_includes: [])
def appearance_for(target, ...)
Item.appearances_for([self], target, ...)[id]
end
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
# body.
relationships = ParentSwfAssetRelationship.
includes(swf_asset: swf_asset_includes).
where(parent_type: "Item", parent_id: item_ids).
where(parent_type: "Item", parent_id: items.map(&:id)).
where(swf_asset: {body_id: [target.body_id, 0]})
pet_type_body = Appearance::Body.new(target.body_id, target.species)
@ -613,13 +605,13 @@ class Item < ApplicationRecord
transform_values { |rels| rels.map(&:swf_asset) }
# Finally, for each item, return an appearance—even if it's empty!
item_ids.to_h do |item_id|
assets = assets_by_item_id.fetch(item_id, [])
items.to_h do |item|
assets = assets_by_item_id.fetch(item.id, [])
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
body = fits_all_pets ? all_pets_body : pet_type_body
[item_id, Appearance.new(body, assets)]
[item.id, Appearance.new(item, body, assets)]
end
end

View file

@ -1,9 +1,15 @@
class Outfit < ApplicationRecord
has_many :item_outfit_relationships, :dependent => :destroy
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
class_name: 'ItemOutfitRelationship'
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 :pet_state, optional: true # We validate presence below!
belongs_to :user, optional: true
@ -25,7 +31,12 @@ class Outfit < ApplicationRecord
before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology
delegate :color, to: :pet_state
delegate :pose, 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) }
@ -107,18 +118,6 @@ class Outfit < ApplicationRecord
)
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)
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
@ -166,6 +165,90 @@ class Outfit < ApplicationRecord
self.item_outfit_relationships = new_relationships
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
# If no name was provided, start with "Untitled outfit".
self.name = "Untitled outfit" if name.blank?

View file

@ -11,23 +11,29 @@ class PetType < ApplicationRecord
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) {
color = Color.find_by_name!(color_name)
species = Species.find_by_name!(species_name)
where(color_id: color.id, species_id: species.id)
}
def self.special_color_or_basic(special_color)
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)
where(color_id: color_ids)
end
scope :preferring_species, ->(species_id) {
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
}
scope :preferring_color, ->(color_id) {
joins(:color).order([Arel.sql("color_id = ? DESC"), color_id])
}
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)
random_pet_types = []
# TODO: omg so lame :P
standards = special_color_or_basic(nil).group_by(&:species_id)
basics_by_species_id = basic.group_by(&:species_id)
species_ids.each do |species_id|
pet_types = standards[species_id]
pet_types = basics_by_species_id[species_id]
random_pet_types << pet_types[rand(pet_types.size)] if pet_types
end
random_pet_types
@ -101,9 +107,10 @@ class PetType < ApplicationRecord
def canonical_pet_state
# For consistency (randomness is always scary!), we use the PetType ID to
# determine which gender to prefer. That way, it'll be stable, but we'll
# still get the *vibes* of uniform randomness.
preferred_gender = id % 2 == 0 ? :fem : :masc
# determine which gender to prefer, if it's not built into the color. That
# way, it'll be stable, but we'll still get the *vibes* of randomness.
preferred_gender = color.default_gender_presentation ||
(id % 2 == 0 ? :fem : :masc)
# 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
@ -115,18 +122,23 @@ class PetType < ApplicationRecord
pet_states.sort_by { |pet_state|
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.glitched? ? -1 : 1, # Prefer is not glitched
pet_state.mood_id, # Prefer mood is happy, then sad, then sick
gender == preferred_gender ? -1 : 1, # Prefer our "random" gender
-pet_state.id, # Prefer newer pet states
!pet_state.glitched? ? -1 : 1, # Prefer is not glitched
]
}.first
end
# Given a list of item IDs, return how they look on this pet type.
def appearances_for(item_ids, ...)
Item.appearances_for(item_ids, self, ...)
# Given a list of items, return how they look on this pet type.
def appearances_for(item, ...)
Item.appearances_for(item, self, ...)
end
def self.all_by_ids_or_children(ids, pet_states)

View file

@ -3,11 +3,6 @@ class Species < ApplicationRecord
has_many :alt_styles
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={})
super({only: [:id, :name], methods: [:human_name]}.merge(options))
@ -20,4 +15,15 @@ class Species < ApplicationRecord
I18n.translate('species.default_human_name')
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

View file

@ -15,7 +15,6 @@ class SwfAsset < ApplicationRecord
belongs_to :zone
has_many :parent_swf_asset_relationships
has_one :contribution, :as => :contributed, :inverse_of => :contributed
has_many :parent_swf_asset_relationships
before_validation :normalize_manifest_url, if: :manifest_url?
@ -141,7 +140,10 @@ class SwfAsset < ApplicationRecord
# 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
# seen!
{ js: assets_by_ext[:js].last }
{
js: assets_by_ext[:js].last,
sprites: assets_by_ext.fetch(:png, []),
}
else
# Otherwise, return the first PNG and the first SVG. (Unlike the JS
# case, it's important to choose the *first* PNG, because sometimes
@ -186,8 +188,21 @@ class SwfAsset < ApplicationRecord
nil
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
return nil unless manifest_asset_urls[:js]
return nil unless canvas_movie?
CANVAS_MOVIE_IMAGE_URL_TEMPLATE.expand(
libraryUrl: manifest_asset_urls[:js],
@ -221,6 +236,17 @@ class SwfAsset < ApplicationRecord
self[:known_glitches] = new_known_glitches
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
[].tap do |ids|
zones_restrict.chars.each_with_index do |bit, index|

View file

@ -1,8 +1,4 @@
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 :matching_label, ->(label) {
where(plain_label: Zone.plainify_label(label))
@ -13,10 +9,6 @@ class Zone < ActiveRecord::Base
super({only: [:id, :depth, :label]}.merge(options))
end
def uncertain_label
@sometimes ? "#{label} sometimes" : label
end
def is_commonly_used_by_items
# 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

View file

@ -25,39 +25,40 @@ module NCMall
ROOT_DOCUMENT_URL = "https://ncmall.neopets.com/mall/shop.phtml"
PAGE_LINK_PATTERN = /load_items_pane\(['"](.+?)['"], ([0-9]+)\).+?>(.+?)</
def self.load_page_links
Sync do
response = INTERNET.get(ROOT_DOCUMENT_URL, [
html = Sync do
INTERNET.get(ROOT_DOCUMENT_URL, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
])
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})"
end
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})"
response.read
end
# Extract `load_items_pane` calls from the root document's HTML. (We use
# a very simplified regex, rather than actually parsing the full HTML!)
html = response.read
html.scan(PAGE_LINK_PATTERN).
map { |type, cat, label| {type:, cat:, label:} }.
uniq
end
# Extract `load_items_pane` calls from the root document's HTML. (We use
# a very simplified regex, rather than actually parsing the full HTML!)
html.scan(PAGE_LINK_PATTERN).
map { |type, cat, label| {type:, cat:, label:} }.
uniq
end
private
def self.load_page_by_url(url)
Sync do
response = INTERNET.get(url, [
INTERNET.get(url, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
])
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})"
end
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{url})"
parse_nc_page response.read
end
parse_nc_page response.read
end
end

View file

@ -31,20 +31,20 @@ module NeoPass
LINKAGE_URL = "https://oidc.neopets.com/linkage/all"
def self.load_linkages(access_token)
response = Sync do
response = INTERNET.get(LINKAGE_URL, [
linkages_str = Sync do
INTERNET.get(LINKAGE_URL, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
["Authorization", "Bearer #{access_token}"],
])
end
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
end
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{LINKAGE_URL})"
response.read
end
end
linkages_str = response.body.read
begin
linkages = JSON.parse(linkages_str)
rescue JSON::ParserError

View file

@ -72,14 +72,15 @@ module NeopetsMediaArchive
# We use this in the `swf_assets:manifests:load` task to perform many
# requests in parallel!
Sync do
response = INTERNET.get(uri, [
INTERNET.get(uri, [
["User-Agent", Rails.configuration.user_agent_for_neopets],
])
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{uri})"
]) do |response|
if response.status != 200
raise ResponseNotOK.new(response.status),
"expected status 200 but got #{response.status} (#{uri})"
end
response.read
end
response.body.read
end
end

View file

@ -1,2 +1,2 @@
<%= javascript_include_tag 'https://analytics.openneo.net/js/script.js',
defer: true, 'data-domain': 'impress.openneo.net' %>
async: true, 'data-domain': 'impress.openneo.net' %>

View file

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

After

Width:  |  Height:  |  Size: 979 B

View file

@ -38,7 +38,6 @@
title: nc_trade_value_updated_at_text(item.nc_trade_value)
- unless item.nc?
= 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.auction_genie'), auction_genie_url_for(item)

View file

@ -0,0 +1,26 @@
%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,5 +1,6 @@
- title @item.name
- canonical_path @item
- use_responsive_design
= render partial: "item_header",
locals: {item: @item, trades: @trades, current_subpage: "preview",
@ -13,7 +14,101 @@
how we handle zones. Until then, these items will be <em>very</em> buggy,
sorry!
#outfit-preview-root{'data-item-id': @item.id}
= turbo_frame_tag "item-preview" do
.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?
#item-contributors
@ -23,6 +118,10 @@
%li= link_to(contributor.name, user_contributions_path(contributor)) + format_contribution_count(count)
%footer= t '.contributors.footer'
- content_for :javascripts_body do
= javascript_include_tag 'item-page', defer: true
- content_for :stylesheets do
= stylesheet_link_tag "application/hanger-spinner"
- 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,6 +13,8 @@
%link{href: image_path('favicon.png'), rel: 'icon'}
= yield :stylesheets
= stylesheet_link_tag "application"
- if use_responsive_design?
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
= yield :meta
= open_graph_tags
= csrf_meta_tag

View file

@ -7,7 +7,7 @@
= image_tag 'https://images.neopets.com/items/mall_floatingneggfaerie.gif'
%span= t 'infinite_closet'
- content_for :content do
= form_tag items_path, :method => :get do
= form_tag items_path, method: :get, class: "item-search-form" do
= text_field_tag :q, @query.to_s
= submit_tag t('.search'), :name => nil
= yield

View file

@ -4,26 +4,27 @@
%p#pet-not-found.alert= t 'pets.load.not_found'
- if show_neopass_announcement?
%section.neopass-announcement
= image_tag "about/neopass-survey.png", width: 70, height: 70,
srcset: {"about/neopass-survey@2x.png": "2x"},
- if show_announcement?
%section.announcement
= image_tag "about/announcement.png", width: 70, height: 70,
srcset: {"about/announcement@2x.png": "2x"},
class: "neopass-thumbnail"
.neopass-content
.content
%p
%strong
💭 Thank you for sending us your NeoPass feedback!
= link_to "We've updated the item page!",
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
We're working with TNT now to build new integrations, based on what you
told us! We're glad
= link_to "log in with NeoPass", about_neopass_path
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 💖
%p{style: "font-style: italic; opacity: .85; font-size: 85%"}
By the way, our integration work with TNT is on pause while they focus
on the
= link_to "~Void Within plot~!", "https://www.neopets.com/tvw/",
target: "_blank", style: "color: purple; font-weight: bold"
%br
%em —Matchu
We'll start it back up closer to the new year.
#outfit-forms
- localized_cache :action_suffix => 'outfit_forms_intro' do
@ -134,4 +135,4 @@
= javascript_include_tag 'ajax_auth', 'lib/jquery.timeago', defer: true
- content_for :javascripts_body do
= javascript_include_tag 'outfits/new', defer: true
= javascript_include_tag 'outfits/new', defer: true

View file

@ -68,7 +68,7 @@
%script#bulk-pets-submission-template{:type => 'text/x-jquery/tmpl'}
%li.waiting
%img{:src => 'https://pets.neopets.com/cpn/${pet_name}/1/1.png'}
%img{:src => '${pet_thumbnail}'}
%span.name ${pet_name}
%span.waiting-message= t '.bulk_pets.waiting'
%span.loading-message= t '.bulk_pets.loading'

View file

@ -0,0 +1,36 @@
!!! 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
# 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`.
ansible-playbook -i deploy/inventory.cfg deploy/deploy.yml
cd $(dirname $0)/../deploy && ansible-playbook deploy.yml

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash
# 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.'
ansible-playbook -K -i deploy/inventory.cfg deploy/setup.yml
cd $(dirname $0)/../deploy && ansible-playbook -K setup.yml

View file

@ -267,7 +267,6 @@ en-MEEP:
resources:
jn_items: JN Meepits
shop_wizard: Meep Wizard
super_shop_wizard: Meeper Wizard
trading_post: Treeps
auction_genie: Aucteeps
closet_hangers:

View file

@ -311,7 +311,6 @@ en:
impress_2020: DTI 2020
owls: "Owls: %{value}"
shop_wizard: Shop Wizard
super_shop_wizard: Super Wizard
trading_post: Trades
auction_genie: Auctions
closet_hangers:

View file

@ -211,7 +211,6 @@ es:
resources:
jn_items: Objetos de JN
shop_wizard: Asistente de Tiendas
super_shop_wizard: Super Asistente de Tiendas
trading_post: Quiosco del trueque
auction_genie: Subastas
closet_hangers:

View file

@ -209,7 +209,6 @@ pt:
resources:
jn_items: JN Itens
shop_wizard: Mágico Pecincheiro
super_shop_wizard: Super Mágico Pecincheiro
trading_post: Trocas
auction_genie: Leilões
closet_hangers:

View file

@ -37,6 +37,7 @@ OpenneoImpressItems::Application.routes.draw do
resources :alt_styles, path: 'alt-styles', only: [:index]
end
resources :alt_styles, path: 'alt-styles', only: [:index]
resources :swf_assets, path: 'swf-assets', only: [:show]
# Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet

7
deploy/ansible.cfg Normal file
View file

@ -0,0 +1,7 @@
[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
command:
chdir: "{{ remote_app_root }}"
cmd: /opt/ruby-3.3.0/bin/bundle config set --local deployment true
cmd: /opt/ruby-3.3.4/bin/bundle config set --local deployment true
# This ensures that, while attempting our current deploy, we don't
# 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
command:
chdir: "{{ remote_app_root }}"
cmd: /opt/ruby-3.3.0/bin/bundle config set --local clean false
cmd: /opt/ruby-3.3.4/bin/bundle config set --local clean false
# NOTE: Bundler recommends this, and they're pretty smart about it: if the
# 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
command:
chdir: "{{ remote_app_root }}"
cmd: "/opt/ruby-3.3.0/bin/bundle config set --local path {{ remote_project_root}}/shared/bundle"
cmd: "/opt/ruby-3.3.4/bin/bundle config set --local path {{ remote_project_root}}/shared/bundle"
- name: Run `bundle install` to install dependencies in remote folder
command:
@ -87,7 +87,7 @@
# The `--local` flag instructs Bundler to use the cached dependencies
# in `vendor/cache`, instead of reading from the web, which is much
# faster and more reliable!
cmd: /opt/ruby-3.3.0/bin/bundle install --local
cmd: /opt/ruby-3.3.4/bin/bundle install --local
- name: Update the `current` folder to point to the new version
file:
@ -111,7 +111,7 @@
- name: Clean up gems no longer used in the current app version
command:
chdir: "{{ remote_app_root }}"
cmd: /opt/ruby-3.3.0/bin/bundle clean
cmd: /opt/ruby-3.3.4/bin/bundle clean
when: not skip_set_as_current
- name: Find older app versions to clean up

View file

@ -5,7 +5,7 @@ Description=Dress to Impress webapp
User=impress
Restart=always
WorkingDirectory=/srv/impress/current
ExecStart=/opt/ruby-3.3.0/bin/bundle exec falcon host
ExecStart=/opt/ruby-3.3.4/bin/bundle exec falcon host
Environment="RAILS_ENV=production"
; Set EXECJS_RUNTIME to save us from needing to install Node
Environment="EXECJS_RUNTIME=Disabled"

View file

@ -170,21 +170,21 @@
git:
repo: https://github.com/rbenv/ruby-build.git
dest: /opt/ruby-build
version: e1b36a32fb87d61955ac38f1889b7e3cb3b2f407
version: d22fa95a6e4c77945304c16ebe0d9513fec98cfb
- name: Check if Ruby 3.3.0 is already installed
- name: Check if Ruby 3.3.4 is already installed
stat:
path: /opt/ruby-3.3.0
path: /opt/ruby-3.3.4
register: ruby_dir
- name: Install Ruby 3.3.0
command: "/opt/ruby-build/bin/ruby-build 3.3.0 /opt/ruby-3.3.0"
- name: Install Ruby 3.3.4
command: "/opt/ruby-build/bin/ruby-build 3.3.4 /opt/ruby-3.3.4"
when: not ruby_dir.stat.exists
- name: Add Ruby 3.3.0 to the global PATH, for developer convenience
- name: Add Ruby 3.3.4 to the global PATH, for developer convenience
copy:
dest: /etc/profile.d/ruby_path.sh
content: PATH="/opt/ruby-3.3.0/bin:$PATH"
content: PATH="/opt/ruby-3.3.4/bin:$PATH"
- name: Install system dependencies for impress's Ruby gems
apt:
@ -248,14 +248,14 @@
become_user: impress
command:
chdir: /srv/impress/versions/initial-placeholder
cmd: /opt/ruby-3.3.0/bin/bundle config set --local deployment true
cmd: /opt/ruby-3.3.4/bin/bundle config set --local deployment true
when: not current_app_version.stat.exists
- name: Install the placeholder app's dependencies
become_user: impress
command:
chdir: /srv/impress/versions/initial-placeholder
cmd: /opt/ruby-3.3.0/bin/bundle install
cmd: /opt/ruby-3.3.4/bin/bundle install
when: not current_app_version.stat.exists
- name: Set the placeholder app as the current version

102
lib/tasks/rainbow_pool.rake Normal file
View file

@ -0,0 +1,102 @@
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",
"prepare": "husky install"
},
"packageManager": "yarn@4.2.1"
"packageManager": "yarn@4.4.1"
}

Binary file not shown.

BIN
vendor/cache/async-2.17.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/async-service-0.12.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

BIN
vendor/cache/io-endpoint-0.13.1.gem vendored Normal file

Binary file not shown.

BIN
vendor/cache/io-stream-0.4.0.gem vendored Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.