1
0
Fork 0
forked from OpenNeo/impress

Compare commits

...

349 commits

Author SHA1 Message Date
735ca6c07b Upgrade to Rails 8.0.2 2025-03-29 14:45:51 -07:00
38bad12778 Fix item animations for asset URLs that contain unescaped spaces
Before this change, the "Ornamental Lake with Goldies" item would fail
to preview on the item page: the iframe for the animation layer would
display an error page.

The error was:

```
Invalid Content Security Policy script-src: "https://images.neopets.com/cp/items/data/000/000/497/497366_deca9f2827/497366_HTML5 Canvas.js". Directive values must not contain whitespace or semicolons. Please use multiple arguments or other directive methods instead. (ActionDispatch::ContentSecurityPolicy::InvalidDirectiveError)
```

This is because the URL that Neopets sends us for this JS file contains
an unescaped space character. This isn't usually an issue for e.g.
loading a URL in the browser, but it's *not* valid syntax for inclusion
in a Content Security Policy.

In this change, we update our CSP code to parse URLs into
`Addressable::URI` objects, which enables us to call the `normalize!`
method, which fixes oddities like that.

The URL now correctly appears in the CSP as
`https://images.neopets.com/cp/items/data/000/000/497/497366_deca9f2827/497366_HTML5%20Canvas.js`.
2025-03-29 14:45:41 -07:00
c48b2b14aa Add workarounds for new Neopets.com security rules
Neopets.com recently added some new security rules that, if not
satisfied, cause the request to return 403 Forbidden.

We figured these out through trial and error, and added them to the
`DTIRequests` library, so they would apply to all requests we make.

We also updated our AMFPHP library to use `DTIRequests` as well, as an
easy way to get the same security rules to apply to those requests.

This change was motivated by pet loading being down for the past day or
so, because all pet loading requests were returning 403 Forbidden! Now,
we've fixed it, hooray!
2025-03-29 14:14:46 -07:00
475c4eb8dd Upgrade to Ruby 3.3.7 2025-03-29 13:12:35 -07:00
8ba094a0be Add Support form for users, with shadowban option 2025-02-16 09:32:52 -08:00
2c2ba1fc67 Oops, fix visual bug for items page contact link icons 2025-02-16 08:55:57 -08:00
97ffffb67a Add configurable full name field to alt styles
Sigh, the "Valentine Plushie" series is messing with me again! It
doesn't follow the previously established pattern of the names being
"<series> <color> <species>", because in this case the base color is
considered "Valentine".

Okay, well! In this change we add `full_name` as an explicit database
field, and set the previous full name value as a fallback. (We also
extract the generic fallback logic into `ApplicationRecord`, to help us
express it more concisely.)

We also tweak `adjective_name` to be able to shorten custom `full_name`
values, too. That way, in the outfit editor, the Styles options show
correct values like "Cherub Plushie" for the "Cherub Plushie Acara".

I also make some changes in the outfit editor to better accommodate the
longer series names, to try to better handle long words but also to
just only use the first word of the series main name anyway. (Currently,
all series main names are one word, except "Valentine Plushie" becomes
"Valentine".)
2025-02-15 21:52:47 -08:00
667f562a88 Oh right, use the series name in the message 2025-01-22 16:46:36 -08:00
a4c3c31279 Add hint to outfit editor when searching for "token", "nostalgic", etc
I don't know how reliably people actually know that we support NC
Styles, because the token items aren't searchable.

In this change, we add a little message to teach people about this
feature, if they search for words that match known styles, or the
colloquial word "token" (even though style token items aren't actually
called that on-site).
2025-01-21 15:07:17 -08:00
b3f635c96c Fix bug parsing discount time when importing from NC Mall
Previously, we took the incoming date as just an integer, which was
raising a database error when we attempted to save it to a timestamp
field. Now, we correctly parse it into a timestamp.

I imagine we just haven't run the import manually while there's a real
discount in the records before? Weird!
2025-01-18 10:49:04 -08:00
86a1875d6d Tweak gem grouping for test libraries
Idk a clear and concise way to group all these together, *and* include
`rspec-rails` in both development and test as the install instructions
recommend, but omit `webmock` from development.

Not that it matters a lot, I just want to be minimal about the gems we
include, for the sake of clarity.

This formulation is a bit verbose, but I think it's maximally clear and
self-consistent, and I appreciate that. I might change my mind later
though?

Anyway, I mostly just noticed this because of the redundancy where
`webmock`'s `group: :test` I think doesn't do anything? Shrug
2025-01-12 19:21:55 -08:00
bf1c8f71fe Oops, fix external HTTP requests by updating async-http gem
Huh okay, looks like maybe the `protocol-http` or `protocol-http2` gem
made non-backwards-compatible changes that broke `async-http`. But
moving to the very latest `async-http` seems to fix it. Okay!
2025-01-12 19:11:24 -08:00
39fe53c51f Run bundle:update, and work around bug in latest protocol-rack
It's been a while, time to update all our minor/patch versions!

When I first ran this, the dev server stopped working entirely, saying:

```
NoMethodError: undefined method `ip_address' for an instance of Socket
```

I traced this back to what seems to be a bug introduced in
`protocol-rack` version `0.11.0`, so I modified the Gemfile to hold us
back from that version in particular. After that, everything is looking
good!
2025-01-12 13:07:14 -08:00
8eb3f73deb Run rails app:update to update config files 2025-01-12 12:36:18 -08:00
eba5f04ec0 Upgrade to Rails 8.0.1 2025-01-12 12:15:23 -08:00
e09296ef51 Fix outfit editor bug where opening style picker increases page height 2025-01-12 12:10:47 -08:00
b6e3079599 Use shorter style name in outfit editor's pose picker label
Now that some of these series names are very long (like "Prismatic
Cocoa: Festive"), they can throw off the layout. Let's just use the
shorter one, it's clear enough!
2025-01-12 12:02:52 -08:00
a385a5b962 Better ordering for NC Styles in the outfit editor
Previously, when opening the pose picker and looking at Styles, the
Cybunny options were sorted like this:

- Default
- Celebratory 25th Anniversary
- Festive Christmas
- Nostalgic Baby
- Nostalgic Blue
- Nostalgic Christmas
- Nostalgic Darigan
- Nostalgic Faerie
- Nostalgic Grey
- Nostalgic Maraquan
- Nostalgic Mutant
- Nostalgic Plushie
- Nostalgic Robot
- Nostalgic Royalboy
- Nostalgic Royalgirl
- Nostalgic Snow
- Nostalgic Tyrannian
- Prismatic Cocoa: Festive Christmas
- Prismatic Cocoa: Nostalgic Christmas
- Prismatic Tinsel: Festive Christmas
- Prismatic Tinsel: Nostalgic Christmas
- Spooky Halloween

Now, they're sorted like this:

- Default
- Celebratory 25th Anniversary
- Nostalgic Baby
- Nostalgic Blue
- Festive Christmas
- Prismatic Cocoa: Festive Christmas
- Prismatic Tinsel: Festive Christmas
- Nostalgic Christmas
- Prismatic Cocoa: Nostalgic Christmas
- Prismatic Tinsel: Nostalgic Christmas
- Nostalgic Darigan
- Nostalgic Faerie
- Nostalgic Grey
- Spooky Halloween
- Nostalgic Maraquan
- Nostalgic Mutant
- Nostalgic Plushie
- Nostalgic Robot
- Nostalgic Royalboy
- Nostalgic Royalgirl
- Nostalgic Snow
- Nostalgic Tyrannian

Note especially the Christmas case, which is all together now! I think
it's also more in line with people's expectations for Halloween to be
alphabetically among the rest, instead of being at the bottom for being
"Spooky".

There's enough styles now that I'm starting to wonder if there's other
UI affordances worth having here, like e.g. only showing (or at least
prioritizing) styles that match the chosen color? But I don't want to
mislead people about compatibility, either.
2025-01-12 11:58:37 -08:00
6ced72e10a Better grouping for alt style series names in the filter dropdown 2025-01-08 15:51:28 -08:00
eff7f75a3a Oops, fix crash in Shopping List for PB items
Huh, I guess I refactored the URL handling at some point and messed up
this call site? Ok whatever!
2025-01-06 00:25:36 -08:00
39e6872f59 Add some better comments and organization to swf_assets/show.js
Mostly headers, a comment that was out of date, and a function I moved
to be in a slightly more sensible spot!
2025-01-04 13:47:23 -08:00
3940513244 Oops, fix silly mistake when combining zones by label on the item page
Oh, right, silly: my previous version of this change still grouped by
zone, then mapped the zones to their labels. This didn't *merge* the
lists of appearances for zones that share the same label; just one of
the zones would win, and the others would disappear.

In this change, I just go upstream and actually group them by label in
the first place, instead of grouping by zone then trying to merge and
transform them.
2025-01-02 19:39:53 -08:00
28cdef29d0 Combine zones with the same label on the item page
For example, the "Red Knitted Beanie with Wig" occupies two different
"Hat" zones: one for behind the head, and one for in front. It's not
useful to split them up!
2025-01-02 19:26:11 -08:00
5be35591c2 Use CSS instead of server requests for image format in pet styles form
Instead of offering a form to request a different format, we just
render both in the HTML, and use CSS to swap between the two. Love to
see the `:has` filter come through for us!
2025-01-02 19:04:48 -08:00
7f62417294 Fix bug sorting pet styles by creation date
Before this change, the sort order when searching for
"Prismatic Pine: Nostalgic" showed:

- [Added Dec 18, 2024] Prismatic Pine: Nostalgic Christmas Flotsam
- [Added Dec 19, 2024] Prismatic Pine: Nostalgic Christmas Gelert
- [Added Dec 18, 2024] Prismatic Pine: Nostalgic Christmas Bruce
- [Added Dec 17, 2024] Prismatic Pine: Nostalgic Christmas Scorchio
- <more>

This is because the Gelert was created at 11:37 NST on Dec 19, whereas
the Flotsam was created at 18:11 NST on Dec 18—but in UTC, which is how
timestamps are stored in the database, these are both Dec 19, so the
Flotsam was sorting first alphabetically.

In this change, we do a hacky transform from UTC to NST-ish. I didn't
want to set up the deploy process to pull named time zones into MySQL,
and then have this as a potential gotcha for the dev environment
later—so instead, I pretend `-08:00` is a good-enough specification of
NST.

This will sometimes create slightly incorrect sort ordering when it
*is* Daylight Savings, and a record was created around midnight. I'm
okay with that!
2024-12-29 16:11:00 -08:00
fe6d42ef67 Remove "<New?>" from pet style search filters
When there's an unlabeled style, previously we'd include the placeholder
value "<New?>" in the search dropdown, even though we don't actually
support searching by it.

Now, we don't! I did this in part by just refactoring how we look this
stuff up, with queries that don't load *all* alt styles into memory,
which will help perf a bit as more of them are released.
2024-12-29 15:52:50 -08:00
b8772c3aad Add no results message for alt style searches 2024-12-29 15:47:12 -08:00
8502b780c2 Add FORCE=1 flag to override series names in Styling Studio auto-import
I ran a bad version of this job yesterday, and set a lot of the new
styles to an incorrect "Prismatic" series name. This will help me fix
them!

There's still styles to manually fix, where TNT decided not to put the
full series name in the item name. (Presumably some of the names were
too long? Or maybe they just forgot?) But this is a better starting
point!
2024-12-29 15:39:52 -08:00
2f090f3924 Oops, improve series name parsing in Styling Studio auto-importer
Ahh right, that's part of why I skipped Prismatics: it's no longer true
that the first word of the style is its series name.

In this change, I try to parse out everything before the pet name part
of the style name, and default to skipping it if we can't quite get it
right.
2024-12-29 15:26:26 -08:00
ab572b6576 Fix magic magnifier positioning in Firefox
Oh oops, Firefox is a bit stricter about interpreting custom elements
as inline elements by default instead of block elements. This messes up
the positioning relative to the container. Fixed!
2024-12-28 16:11:38 -08:00
019484b6dd Oops, fix showing the magic magnifier in Firefox
Oh, guess Firefox doesn't support this CSS container query feature yet!
Use a more compatible technique instead.
2024-12-28 16:08:28 -08:00
34bf27069b Oops, fix silly routing mistake where I set path twice for same route
Ruby 3.3.6 has a warning for this, neat! I apparently didn't notice the
`path` option already there at first, and just added a new one.

This change shouldn't affect behavior, it just is clearer and removes a
warning!
2024-12-28 15:58:41 -08:00
84dec2d82e Tweak output for Styling Studio auto-importer
A style not being modeled yet is not a big deal, whereas some of those
other warnings actually require manual intervention. I want to make the
list easier to scan for the warn icons!
2024-12-28 15:53:24 -08:00
d75492bd1d Add Prismatic styles to Styling Studio auto-importer
I missed this the first time, and had just kinda been putting it off.
But now there's a lot of new Prismatics, so let's finally do it!
2024-12-28 15:52:48 -08:00
b61526f6ad Upgrade to Ruby 3.3.6
Fedora upgraded its system Ruby, and I'm on that laptop today, and I
prefer to have prod keep pace rather than use rbenv to keep myself and
prod knowingly on an older version!
2024-12-28 15:34:42 -08:00
2b8fe68387 Refactor rails neopets:import:rainbow_pool to run in parallel
We're using our new helpers to make it easier, yay!
2024-12-16 14:12:38 -08:00
86205c5e44 Create new DTIRequests.{get,post} helpers for HTTP requests
Instead of using `Async::HTTP::Internet` directly, and always applying
our `User-Agent` header manually, let's build a helper for it!
2024-12-16 14:12:19 -08:00
5546d6df5d Use new DTIRequests.load_many helper in SwfAsset.preload_manifests 2024-12-16 13:19:58 -08:00
2be21a9238 Use new DTIRequests.load_many helper in Item.preload_nc_trade_values 2024-12-16 13:19:16 -08:00
1d4771ecc5 Create new DTIRequests.load_many helper, to make parallel requests
Just a wrapper for the barrier/semaphore thing we're doing constantly!

I only applied it in the importer rake tasks for now. There's other
call sites to move over to it, too!
2024-12-16 13:16:03 -08:00
7eb209e206 Update pet state bulk-labeling to skip to next unlabeled if desired
Previously, "Then: Go to unlabeled appearance" would always take you to
the *first* unlabeled appearance in our database.

Now, we go to the *next* unlabeled appearance in the list, relative to
this one.
2024-12-08 10:08:39 -08:00
30d42d29c1 Oops, fix magnifier position bug when page is scrolled 2024-12-07 12:41:03 -08:00
347419aee8 Add SVG/PNG picker when editing pet appearances
I've known there are bugs in the SVGs pretty often, because they're not
very well attended-to—I noticed pretty quick that the Marble Eyrie, for
example, has its Body asset saved correctly in PNG, but its SVG is just
another copy of the head, oops!

I think SVG is still a nice default for this UI, but I added a little
form to switch to PNG, to give us a debugging method and escape hatch
if it starts to get weird.
2024-12-07 12:16:52 -08:00
98965ccc6f Increase contrast for non-highlighted layers when editing pet appearance
This washes out the colors a bit more than necessary on lighter pets,
but helps a lot on darker pets. It really kinda pushes everything other
than the lineart all the way to white, which tbh is a pretty neat
sketch-like effect.
2024-12-07 12:00:10 -08:00
763f6d66be Fix minor bug to hide magnifier if its position isn't ready yet 2024-12-07 11:53:18 -08:00
addf2d6cc5 Use SVG for the magnified outfit viewer when editing pet appearance 2024-12-07 11:45:01 -08:00
b2a23b3e7b Add magnification when editing pet appearance
I couldn't find a library for this functionality that didn't require
jQuery, and I don't want to be adding *more* jQuery requirements. So, I
decided to throw together my own!

The `<magic-magnifier>` component copies its contents into a "lens"
element, then uses basic JS to track mouse position, then uses CSS to
move the lens and its contents into a helpful position.

One thing I noticed here is that the zoom is a bit crunchy because
we're using PNG images, and it's hard to zoom in even further than we
already are. I might try switching this UI to use the SVG images by
default instead?
2024-12-07 11:39:37 -08:00
b3f3b39aa0 Add helpful layer interactions on pet appearance edit page
When you hover the row for a layer in the table, it highlights the
corresponding layer in the outfit viewer. And when you click anywhere
in the row, it opens the first link (usually the PNG image).
2024-12-07 10:41:52 -08:00
d92e3288ab Add layers table to pet appearance edit page
Gonna add some more functionality here too, but right now we at least
have this!
2024-12-07 09:58:24 -08:00
c32a495780 Announce the temporary server upgrade (and refactor announcement helper) 2024-12-01 19:26:42 -08:00
ea5c315c2a Oops, fix typo in "Glitched?" label in pet appearance edit form 2024-12-01 11:27:18 -08:00
ab238ab2a6 Add "Support summary" section to Rainbow Pool
This both gives us a sense of progress on labeling, and adds a link to
get started in bulk labeling mode!
2024-12-01 11:13:21 -08:00
0d2648d030 Move support_staff? method into ApplicationController
I want to use it in a controller in our next change!
2024-12-01 11:12:24 -08:00
d9bf4f745b Skip glitched appearances in bulk-labeling mode
It's not as important to label glitched states, and sometimes the glitch
prevents it from being visually identifiable. Don't sweat 'em!
2024-12-01 10:39:19 -08:00
407c4b38a5 Add link to reference pet type when labeling pet appearances
Sometimes I forget like, what the masc/fem variants of a given pet
actually look like? Some are super obvious about things like eyelashes,
and others use more subtle eye differences.

This is a cheap lil hack to make it easier to open a reference! Ideally
I think it would be neat to like, when you hover over an option, have
it show you the reference variant of that pose? But this is good enough
I think!
2024-12-01 10:28:58 -08:00
6dc5aa28a4 When labeling pet appearances on mobile, give pose options equal height
If the screen is narrow, many of the bubbles will wrap their text onto
two lines, but "Unconverted" won't. Give it equal height to the rest
anyway, for visual consistency!
2024-12-01 10:27:38 -08:00
b656ccd982 Add bulk labeling mode for pet appearances
We copy the same feature from alt styles, now that the UI is shared via
support form helpers! Easy peasy!

This adds a "Then: Go to unlabeled appearance" checkbox next to the
submit button on the pet appearance edit form. If checked, it takes you
to the first unlabeled appearance in the database, and keeps the box
checked for next time. Slam through 'em!
2024-12-01 10:10:33 -08:00
02836494ae Add debug gem in development
This helped me debug a thing in the upcoming change! It lets you drop a
`debugger` line into the app, then run `rdbg --attach` in another
terminal to get into a debug session. Neat!
2024-12-01 10:05:54 -08:00
b6e6f27fdf Minor refactor to support_form_with implementation
Realizing that, with the keyword argument spread syntax, I don't need
to do merging, I can just. spread at the right place!

My rationale for the ordering here is: if the caller theoretically tried
to override the builder (even though I don't see why), I think we would
want to respect that. Whereas the `class` argument should be overridden
because we're safely *merging* our `.support-form` class into it.
2024-12-01 09:45:26 -08:00
aeb00f73cf Extract alt style's "go to next" field into a support form helper
I want to reuse this for unlabeled pet styles is why! (That's been the
immediate motivation for this refactor, but also I do just like that
it'll make support forms easier to build.)
2024-12-01 09:42:19 -08:00
06a301e6d7 Add actions helper to support form builder 2024-12-01 09:30:17 -08:00
1119bbb292 Extract the more complex support form helpers into templates
I think helpers are fine for the simpler ones that are basically *just*
wrapper tags, but once it starts getting into `concat`, I think that's
too unfamiliar of a syntax for developers; let's bail into our usual
templating system!

I'm not sure about putting them in `application/support_form` like this.
That's cute for one-offs like `application/hanger_spinner`, because
`render partial: "hanger_spinner"` assumes the `application` view folder
by default, but that doesn't work once it's nested: it looks for a
`views/support_form` folder.

I think maybe it could soon be time to bail from the strict "view
folders belong to controllers" thing, similar to how we did for
`SupportFormHelper`, and add a `components` folder or similar? Idk, not
sure yet!
2024-12-01 09:26:40 -08:00
fdbfa3c03f Fix styles in support form when field is errored
Ah right, `> label` doesn't work with how Rails will wrap broken labels
and inputs each in a `.field_with_errors` element. Fixed, and added
some basic coloring!
2024-11-30 11:50:31 -08:00
252f4f1df1 Add errors helper to support form builder
It still has no good CSS to it, but that's okay, this is just to DRY it
up.
2024-11-30 11:46:19 -08:00
2d3d4051fe Oops, return HTTP Bad Request when item editing fails
Just a subtle thing, but Turbo can be picky about return types, and
won't reload the page with the errors in it if the status is 200.
2024-11-30 11:45:35 -08:00
3cd02baa09 Add thumbnail_input method to support form builder
Just to clean up this relatively common input type!
2024-11-30 11:34:02 -08:00
8347633a84 Add SupportFormBuilder to make the support form templates nicer
Instead of hand-rolling HTML, this offers helpers like `f.field`, to
help ensure the HTML is consistent, and to keep the templates more
focused on the unique form elements.
2024-11-30 11:26:23 -08:00
661a5385f4 Migrate pet state edit form to .support-form class
Most notable change here is extracting the pose option bubbles into a
`data-type="radio-grid"`, and pulling that into the `.support-form`
CSS. My rationale is that, unlike most fields, this field benefits from
being 100%-width, and I don't want to specify that as an override if I
can avoid it, because that's fragile-y.

Instead, I extract this into a generic type of field that
`.support-form` can use (it feels pretty reusable anyway!), and require
the caller to specify how many columns they want as `--num-columns`.
2024-11-30 10:49:47 -08:00
c27477fabe Refactor support-form CSS to be more reusable layout
Specifically, I'm going for a more-vertical layout, cuz I want to bring
PetState over to it, and the weird grid situation wasn't gonna fit the
big pose label radios.
2024-11-30 10:33:58 -08:00
c7bea666c9 Add the "body fit" field to the item editing page 2024-11-20 12:23:30 -08:00
f49f9f386d Add item editing fields to manually override an item to be an NC item 2024-11-20 12:13:33 -08:00
3937ba354f Add edit page for items, to set modeling status for done/glitchy items 2024-11-20 12:07:25 -08:00
388bb9a251 Oops, fix mistakes when extracting support-form class
I didn't include the alt styles call site when making the commit, and
also I named the file differently than other nearby files are named!
2024-11-20 11:24:56 -08:00
e846a75f7a Use modeling hints to remove some items from is:modeling searches
There's still plenty left, but we have 213 we "manually" marked as
"done" (I think I ran a batch job on everything Chips told me was on
the page and already done), and that should help a lot!
2024-11-20 11:22:33 -08:00
270b27c1d2 Extract alt style form CSS into a new "support-form" class
Gonna use this for item editing too, I think!
2024-11-20 11:16:46 -08:00
4cbac13df1 Remove careful SQL-selecting on homepage
This keeps causing missing-attribute crashes when I change things, and
I don't think the performance benefit is a big deal for how the page
currently runs, esp as we keep gathering more attributes? I feel like
`description` is the main "large" one we're omitting, and like. Shrug!
2024-11-20 10:44:33 -08:00
0261d02137 Downgrade most item validations to be *not nil*, rather than present
Been running into the item "Hanging Plushie Grundo Background Item" not
being modelable, because TNT seem to have left its description blank!

Let's be less picky about what data we take in, but keep the intention
of these validations: to ensure that *we* don't make a mistake and
forget a field when importing items!
2024-11-19 17:00:47 -08:00
e82c606ee8 Ah beans, fix a homepage crash from the modeling logic changes
Tbh I'm not sure how much this select clause is helping us, but w/e.
2024-11-19 16:49:20 -08:00
ed5b62e161 Use PetType's created_at to predict who an item might be compatible with
This is a basic attempt at the Vandagyre logic, but also things like
"Maraquan items released before the Maraquan X was released"!

I also added a new task, `rails items:update_cached_fields`, which needs
to be run after this change, because it affects the value of
`Item#predicted_fully_modeled?`.

Eyeballing the updated search results for `-is:modeled`, this feels
pretty close? I'm guessing it's not perfect (e.g. maybe a pet type we
got modeled late into its existence, or some items that just never did
fit a certain pet), but feels pretty good.

I also know we had the "modeling hints" override in Impress 2020, which
we aren't reading yet. We should probably take that into account here
too!
2024-11-19 16:41:50 -08:00
5472ccebef Add is:modeled query to items 2024-11-19 15:54:55 -08:00
f6f618c9d5 Add Item.is{_not}_modeled scopes, for use in search later
We're now caching `predicted_fully_modeled?` on the database record, so
we can query by it in the database!

I'm moving on from the model I did in Impress 2020, of writing really
big fancy single-source-of-truth queries based on the assets themselves.

I see the merit of that in terms of theoretical reliability, but in
practice I think it will be *more* reliable to have one *in-code*
definition of modeling status (which we need anyway for generating the
homepage modeling requests), and just save that in a queryable way.
2024-11-19 15:52:52 -08:00
39bed6b157 Make Item's update_cached_fields callback more reliable
In our tests, I discovered an unexpected behavior where calling
`item.swf_assets << swf_asset` wasn't updating computed fields
correctly.

This isn't something we actually do in-app, I think the modeling system
happened to trigger the callbacks in a way that still worked fine?

But I think this is a good idea for reliability, since caching is such
a notoriously difficult thing to get right anyway! And it makes our
tests simpler and clearer.

Specifically, `compatible_body_ids` references `swf_assets`, which, I'm
kinda surprised, *doesn't* include the newly-added asset yet when the
`ParentSwfAssetRelationship.after_save` hook runs while calling
`item.swf_assets << swf_asset`. Reloading it fixes this!
2024-11-19 14:26:06 -08:00
af5187edb6 Refactor item modeling spec, in anticipation of Item.is_modeled tests
I'm grouping some shared behaviors to pull into the different cases, so
that we can check the behaviors of a fully-modeled item vs a
not-fully-modeled item in *all* of the relevant cases.

Specifically, I'm planning to add `is:modeled` search filters, and
creating pending placeholder tests for them!
2024-11-19 13:44:31 -08:00
21eaf7b266 Fix silly variable scoping issue in item spec
Oh right, `@remote_id` is an instance variable so we can auto-increment
it over time, but `url` is just a derived value, and can just be local!
Silly!
2024-11-19 13:32:47 -08:00
91851bc340 Add tests for Maraquan item modeling predictions
I also added a test I forgot for the standard case: when you've modeled
each individual species.
2024-11-19 13:31:34 -08:00
3e7d27eaa3 Add a reminder to write modeling prediction tests for special colors 2024-11-19 12:18:17 -08:00
f7109e398a Better handle modeling predictions for items with *no* data
This doesn't generally happen, but did the other day when I rolled back
some of the database's SWF asset records but kept the items—and it was
a bit confusing that the homepage marked them as fully modeled!
2024-11-19 12:15:21 -08:00
f90380c4e6 Add tests for item modeling predictions
The stuff on the homepage! I'm also thinking about how to use these for
better discovery of items that need more modeling.
2024-11-19 12:10:49 -08:00
218dc5b6f9 Improve Solargraph LSP in our spec files
The main thing is that I was getting "RequireNotFound" warnings for
`require 'rails_helper'`, because the LSP seems unaware of how RSpec
offers `spec/` as a root for requires.

I think the `require_relative` is clearer anyway, I'm decently
satisfied with it. And if I decide it's too much ugly, we can try
something else in the Solargraph config or something sometime!
2024-11-19 11:28:36 -08:00
bc0097850d Say "NC Style" instead of "Alt Style" in contribution descriptions
Just for increased consistency!
2024-11-16 12:21:43 -08:00
ec0b8d9cb9 Only prompt for Neologin cookie once when running rails neopets:import 2024-11-16 12:11:13 -08:00
a57b3629db Refactor Neopets import tasks all into a neopets:import namespace
and with a `rails neopets:import` task you can call to do them all at
once!

I'm gonna do some other stuff here too to make `neopets:import` easier
to call all in one go, like prompting for the Neologin cookie just
once at the start.

Note that this changes the cron setup, so you gotta run
`bin/deploy:setup` after this deploys!
2024-11-16 11:58:43 -08:00
1d1dc15320 Remove some more incorrect limits on ID fields in the database
I'm running into this with the automated tests and the fixtures I think
sometimes using large auto-generated IDs?

But the point is, our tables generally use Rails's default `:integer`
size for its IDs, and then columns that reference them are *smaller*,
which is… not correct stuff, y'know?

So I figure, let's just expand the columns. We don't have enough data
that being real picky about the integer sizes matters, so let's keep it
simple and more obviously correct.
2024-11-15 20:39:38 -08:00
b6c21dfe40 Oops, fix sort order for alt styles
Oh huh, when doing Rainbow Pool stuff, I put the ordering in the wrong
place! It's a sensible ordering for the Rainbow Pool page, but not so
much for the JSON view!
2024-11-15 20:28:38 -08:00
c4a7e7916f Fall back to blank image if alt style has no preview image
This is currently crashing the Rainbow Pool when the Anniversary Techo
would appear, because the asset seems to be missing? The SWF doesn't
seem to exist, nor does its manifest.
2024-11-15 20:04:45 -08:00
217d25edab Handle new colors/species in the Rainbow Pool
Oh right, yeah, we like to do things gracefully around here when
there's no corresponding color/species record yet!

Paying more attention to this, I'm thinking like… it could be a cool
idea to, in modeling, *create* the new color/species record, and just
not have all the attributes filled in yet? Especially now that we're
less dependent on attributes like `standard` to be set for correct
functioning.

But for now, we follow the same strategy we do elsewhere in the app: a
pet type can have `color_id` and `species_id` that don't correspond to
a real record, and we cover over that smoothly.
2024-11-15 19:56:07 -08:00
dd213e8078 Increase the maximum value for pet types' color ID and species ID
Oh dang, we're on color  now, and looks like our maximum value is
127. Let's expand that!

I noticed this because I'm writing tests for some stuff, and used "456"
as a placeholder ID number, and it just fully did not work, and I'm
like. Oh.
2024-11-15 19:29:13 -08:00
c5995a2bd1 Oops, turn modeling back on
Huh, I guess when I reapplied my refactors to modeling disabling the
other day, I didn't notice that I turned it off in production. And I
guess I didn't deploy this at the time cuz it's just refactors, but
when I deployed other changes yesterday this came with it. Whoops!
2024-11-15 17:37:54 -08:00
1ad3ea8f96 Add rails alt_styles:import task to import info from Styling Studio
Yay whew! Magic time!
2024-11-14 19:03:44 -08:00
b245690a60 Oops, fix bug when loading species with no alt styles in the Studio 2024-11-14 19:03:06 -08:00
3ed1c46b64 Add NCMall.load_styles method, not yet used
I only now thought through that I can scrape these instead of enter
them manually, similar to how we did our Rainbow Pool scraper… hooray!

I'm actually writing tests for stuff too, wowie!
2024-11-14 18:45:08 -08:00
9e3ce74ed5 Auto-focus series name when labeling alt styles
Helpful when doing a bunch in a row (like today's big release!)
2024-11-14 17:14:03 -08:00
5f31e38428 Reapply "Extract modeling ViewerData class into new Pet::ModelingSnapshot file"
This change was modified a bit after cherry-picking, to no longer
include the broken changes to item modeling in 9eaee4a.

(cherry picked from commit 90407403ba)
2024-11-10 11:43:54 -08:00
8f9daf4d52 Reapply "Use our IntegerSet serializer for PetState#swf_asset_ids"
(cherry picked from commit 242b85470d)
2024-11-10 11:41:06 -08:00
3242981eb2 Reapply changes to how disabling modeling works
```shell
git cherry-pick d82c7f817a --no-commit
```
2024-11-10 11:39:51 -08:00
54b25ef08e Reintroduce some of our modeling refactors, without touching items
Okay so, when we reverted a buncha stuff in e3d196f, it was in response
to a bug where item modeling data was getting deleted. And I was tired,
and just took a big simple hammer to it of reverting all the modeling
refactors.

Here, we reintroduce *some* of them: the biology ones before the item
bug. And tests still pass, and in fact I can un-pending some of them!

I might also try to reapply the change where we extract it all into a
new file, but without the item parts.

```shell
git cherry-pick --no-commit 13ceec8fcc
git cherry-pick --no-commit f81415d327
git cherry-pick --no-commit c03e7446e3
git cherry-pick --no-commit 52ca41dbff
```
2024-11-10 11:36:23 -08:00
e4e81f0694 Update modeling bug announcement, now that things are working again
Also, while we're here! To restore the lost data, I:

1. Downloaded this scheduled public data backup, which was taken
   thankfully the day before we updated modeling code!
   https://impress.openneo.net/public-data/2024-11-03T08_15_02Z-scheduled.sql.gz
2. Trimmed it just to the section about the `parents_swf_assets` table:
   dropping it, then rebuilding it from scratch.
3. Ran this modified backup SQL dump on the production server.
4. Ran the code from `db/migrate/20241001052510_add_cached_fields_to_items.rb`
   to bring items' cached fields back into the correct state.

I also had to fix some errors in the item data that prevented some
items from passing the latest validations:

```rb
Item.where(rarity: "").update_all(rarity: "???")
Item.where(description: "").update_all(description: "???")
Item.where(zones_restrict: "").update_all(zones_restrict: "00000
00000000000000000000000000000000000000000000000")
```
2024-11-06 14:34:15 -08:00
e3d196fe87 Revert modeling refactors to the old modeling that worked!
Because we ended up with such a big error, and it doesn't have an easy
fix, I'm wrapping up today by reverting the entire set of refactors
we've done lately, so modeling in production can continue while we
improve this code further over time.

I generated this commit by hand-picking the refactor-y commits
recently, running `git revert --no-commit <hash>` in reverse order,
then manually updating `pet_spec.rb` to reflect the state of the code:
passing the most important behavioral tests, but no longer passing one
of the kinds of annoyances I *did* fix in the new code.

```shell
git revert --no-commit 48c1a58df9
git revert --no-commit 42e7eabdd8
git revert --no-commit d82c7f817a
git revert --no-commit 5264947608
git revert --no-commit 90407403ba
git revert --no-commit 242b85470d
git revert --no-commit 9eaee4a2d4
git revert --no-commit 52ca41dbff
git revert --no-commit c03e7446e3
git revert --no-commit f81415d327
git revert --no-commit 13ceec8fcc
```
2024-11-06 14:31:16 -08:00
0b3dd02323 Add failing test for modeling bug where we break existing connections
As I'm writing out my solution for this, I'm almost wondering if it's
time for the refactor I've been Theoretically Planning Someday, to move
items to a real `ItemAppearance` model in the database similar to
`PetState`… Hmm hmm hmm…

For now though, I'm taking a break!
2024-11-06 14:08:32 -08:00
48c1a58df9 Fix new bug where re-modeling a background would reset it from ID 0
This bug never made it into production I think, it was a consequence of
some of how I refactored stuff in the recent changes? I think??

But yeah, I refactor how we manage `SwfAsset#body_id`, to be a bit more
explicit about when and how it can change, instead of the weird
callbacks that tbqh have bit us too often…
2024-11-06 13:48:01 -08:00
42e7eabdd8 Fix modeling bug where compatible_body_ids field was not updating
Ah right, the callbacks in `ParentSwfAssetRelationship` don't get
called when Rails does automatic join-model management stuff. We need
the `Item` to call its `update_cached_fields` callback itself, too!

When fixing this, I found a new bug that arose, in how we infer
`body_id` for assets that fit all pets. Fixing that next!
2024-11-06 13:39:32 -08:00
a208fca8d2 Improve modeling tests for records that shouldn't change
This gives better output when they fail, and also avoids spurious
failures like when an array for `cached_compatible_body_ids` is replaced
by an identical one! (I'm running into this right now, and yeah, it
helps a lot lol)
2024-11-06 13:04:50 -08:00
3ac89e830e Announcement about modeling being broken 2024-11-06 11:55:05 -08:00
d82c7f817a Disable modeling in production, while we investigate errors
Hmm, I think I made a mistake on `modeling_snapshot.rb:69`: I'm
assigning the *entire* `item.swf_assets` relation to *just* the assets
for the new model of it, which breaks all the other connections.

First, I'm disabling modeling. Then, I'll restore a backup. Then, I'll
write tests for that case, and fix it up!
2024-11-06 11:54:28 -08:00
5264947608 Minor tweaks to modeling private methods 2024-11-03 12:24:54 -08:00
90407403ba Extract modeling ViewerData class into new Pet::ModelingSnapshot file
Both extracted and renamed!
2024-11-03 12:23:51 -08:00
242b85470d Use our IntegerSet serializer for PetState#swf_asset_ids 2024-11-03 12:16:27 -08:00
43717e2535 Remove unused PetState#reassign_duplicates! 2024-11-03 12:07:57 -08:00
bc1f7152bf Remove unused SwfAsset.from_wardrobe_link_params 2024-11-03 12:07:23 -08:00
9eaee4a2d4 Refactor item modeling
Simpler, more encapsulated, and fixes the pending jank stuff in the
tests!
2024-11-03 12:05:37 -08:00
52ca41dbff Extract biology processing from AltStyle into Pet
I'm trying to pull more of the modeling code out of the individual
classes, and into this encapsulated preprocessing, so it's a lot more
in-one-place!
2024-11-03 11:46:29 -08:00
c03e7446e3 Refactor out biology assets in modeling code a bit 2024-11-03 11:41:18 -08:00
6402e5abc3 Remove the pending marker on some modeling tests 2024-11-02 21:39:34 -07:00
f81415d327 Refactor modeling viewer data handling into a new ViewerData class 2024-11-02 21:34:19 -07:00
13ceec8fcc Simplify modeling code for biology data
We're leaning more into Rails collection management and autosave stuff!
2024-11-02 21:15:12 -07:00
40765c729e Remove unused Pet.with_pet_type_color_ids scope 2024-11-02 20:21:59 -07:00
d26f3a7598 Add tests for modeling alt style assets 2024-11-02 20:15:35 -07:00
06721f77e9 Add alt style modeling tests
I didn't do the assets part of it yet, just the style object!
2024-10-29 22:20:43 -07:00
f9be3dceb1 Oh right, use Blue Jetsam "original biology" for modded Majal_Kita 2024-10-26 14:32:47 -07:00
c9c080e74d Merge branch 'main' into modeling-tests 2024-10-25 17:33:39 -07:00
e65634d8bc Set up tests for Majal_Kita and its alt style
Not actually touching alt style yet, just the very basic stuff about
how alt style can cause loading to fail in certain extremely rare cases
(specifically, if it's our first time seeing the underlying
color/species combo too, which… isn't gonna happen irl on DTI for a long
time if ever, I would guess, but hey!)
2024-10-25 14:47:51 -07:00
4c5d14c591 Add tests for re-modeling Thyassa, in a different mood 2024-10-25 14:15:25 -07:00
28bd6ecca4 Test that modeling items twice doesn't affect them or their assets 2024-10-25 13:47:23 -07:00
7a837edaf6 Use alias they in specs instead of it, when it reads better 2024-10-25 13:38:02 -07:00
f3894759d6 Add tests for modeling item assets 2024-10-25 13:34:02 -07:00
30ada0b7e1 For biology assets, make clearer the weirdness with saving first 2024-10-25 13:04:46 -07:00
8a38ce90dc Refactor tests for saving things when pet is saved
This got more complicated with the items case, and I think this is a
stronger idiom for communicating it!
2024-10-25 12:55:31 -07:00
6d25b3424f Add tests for matts_bat's items
Not their assets yet, just the items
2024-10-24 15:58:16 -07:00
8902527438 Basic modeling spec for my pet, matts_bat
No items yet, just putting in the very basics of biology, more
shortform than the Thyassa case
2024-10-24 15:37:57 -07:00
044dface14 Add RSpec to the commit hook 2024-10-24 15:22:39 -07:00
b1890d4f6f Refactor existing modeling tests to follow more RSpec-y syntax
I'm learning how to group things and stuff!
2024-10-24 15:16:55 -07:00
3a5f33fd56 Add tests that modeling a pet a second time doesn't affect its records
That is, if everything is the same as before, we don't need to change
anything in our database!

I also learned a bit more about RSpec syntax sugars, it's cute!
2024-10-24 15:04:25 -07:00
a54a844e03 Refactor modeling spec to use a new "a_record_matching" matcher 2024-10-24 14:42:05 -07:00
c78d45a0b5 Copy edit on pet styles
The NC Pet Styles sentence getting broken across two lines I think
makes it too hard to notice.

Design-wise, it would be nice to just call better attention to this
feature altogether in some higher-level design-language-y way, but!
Whatever!
2024-10-22 16:48:37 -07:00
930bfca028 Add creation date to alt styles listings
Copied from how pet types do it!
2024-10-22 16:46:55 -07:00
29aa769bda Oops, tiny copy edit for alt styles page
Right, missed this when renaming them, lol!
2024-10-22 16:44:42 -07:00
66438eae1a Add mode to keep labeled alt styles until they're all labeled
If you check this box, it'll keep you in a mode where saving an alt
style redirects you to the *next* one that needs labeling, until
they're all done. Useful for big drops!
2024-10-22 16:39:25 -07:00
3b5b13c172 Fewer slowest examples in RSpec, cuz these are fast and it's fine
I want to not turn it off entirely, so that if there's a nasty one it
becomes visible, but we don't need all that vertical space for this
small test suite rn!
2024-10-21 16:47:51 -07:00
5b1d1f0695 Add assets to modeling tests, and also uhh some other fixes
I forget, there was some tricky debugging about getting the fixtures
right, I think the previous commit doesn't *actually* pass from a clean
setting. Ah well, looks good now!
2024-10-21 16:46:10 -07:00
e92e315743 Move modeling tests to RSpec
Also note the jsbundling bump, that was so I can us the relatively-new
`SKIP_YARN_INSTALL=1` flag when running tests, to be a bit faster.
2024-10-21 16:03:58 -07:00
eb2fb125b9 Basic modeling test!
Just getting a basic foothold here. I'm thinking about moving this to
RSpec, cuz I feel like the assertions are gonna get pretty specific
and groupable.
2024-10-21 15:29:22 -07:00
d8ff99475e Add the Rails tests to the pre-commit hook
I'm gonna work on adding modeling tests, and I want to not be breaking
them without realizing! The trade history ones are good to be checking
more often like this, too.
2024-10-21 14:35:26 -07:00
9726ecb1a5 Fix trade activity tests: use a valid item fixture, not a placeholder
In 540ce08caa, I updated the Item class
to be more explicit about what fields are required, so this test would
fail in a more helpful way, instead of just crashing from `name` being
`nil` when trying to infer the Dyeworks info.

Now, we update the test to use Rails's standard "fixture" system to set
up a more-correct placeholder item, instead!
2024-10-21 14:26:36 -07:00
540ce08caa Handle invalid Item state a bit better
Catch missing fields in validation before sending it to the DB, and
skip the Dyeworks stuff if the name is missing.

I ran into this looking into `test/trade_activity_test.rb`, which fails
right now because we try to create a boring placeholder item with
minimal fields, which Dyeworks can't call `name.match()` on!

Now, the test fails with a more helpful error about the item being
invalid. Next, I'll fix that!
2024-10-21 14:24:45 -07:00
881e63cfbd Output JSON from rails pets:load[pet_name]
Gonna use this for making mock data for automatic testing!
2024-10-21 14:03:56 -07:00
09e5a39b4c Whoops, fix alt styles when modeling real pets wearing them
Just never did this, I guess!! 😅
2024-10-18 19:16:41 -07:00
bf20c9bb31 Ah beans, I goofed alt style modeling *again*
Feeling for-real about getting a test suite set up because oh my god
2024-10-18 19:01:26 -07:00
7607c2c015 Oops, fix sloppiness about pet service refactor
I guess I like super didn't test this end-to-end, oops!!
2024-10-18 18:14:01 -07:00
abfe1e6df7 Extract neopets_origin into a config value 2024-10-18 18:00:48 -07:00
e36e273d50 Extract Neopets::CustomPets service from the Pet class
Just getting this stuff out of Pet, in part because I want to start
being able to unit test modeling, and that will require stubbing out
what this service returns!
2024-10-18 17:40:31 -07:00
83e5ad6bcc Update alt styles copy to adjust for them not all being Nostalgic now 2024-10-18 17:29:48 -07:00
acb52cb870 Move NCMall and NeoPass services into a Neopets module
Just a bit more clarity of grouping! I'm also thinking about extracting
modeling APIs into a service file like this too, in which case I think
this would help clarify what it is.
2024-10-18 17:27:15 -07:00
7ef689d658 Remove unused ostruct import
Only noticed it cuz there's a deprecation warning, and so I was like,
do we use it? I think not anymore!
2024-10-18 17:20:02 -07:00
23c083ff1d Use "real" series name field when editing alt styles
Just a little improvement to the form, so when there's no series name,
the text field is empty—even though in most contexts we *pretend* it's
"<New?">
2024-10-18 17:13:16 -07:00
6b7c73870a Stop inferring AltStyle series name, now that it's getting more varied
They're not all Nostalgic anymore! Oh, how the times have changed!

This way, new ones will appear as "<New?>", until support staff come in
and label them (with our cool new tools!)
2024-10-18 17:07:38 -07:00
e7a0ff1234 Make deleting an AltStyle also delete its ParentSwfAssetRelationships
Not relevant in-app as such, I'm just deleting records to re-test
things in development, and it helps to keep things in a more consistent
state!
2024-10-18 17:06:13 -07:00
50c9ba53e7 Add announcement explaining alt style bugs
so people know it's fixed and can model them now lol
2024-10-18 17:04:44 -07:00
89c729ecbe Oops, fix bug preventing new alt styles from being saved
Whoops, I didn't realize this change I made to validation for the alt
style editing form, was goofing up alt style modeling!

The trick is, the validation was happening before the `before_create`
hook. Now I've reformulated these as `before_validation` hooks, so
we're not rejecting new alt styles for having no thumbnail!
2024-10-18 17:04:26 -07:00
bb83f6fd36 add redirect from /alt-styles to /rainbow-pool/styles
In a recent change, I changed the path for this page—but forgot to
keep the old URL available as a redirect, for older links! Fixed.
2024-10-18 14:25:03 -07:00
7891acd3b1 Oops, missed a spot for Modeling -> Rainbow Pool link updates
Whoops, clicking the *paint brush image* still linked to the Modeling
Hub, instead of the Rainbow Pool. Fixed!
2024-10-12 14:17:49 -07:00
16deee94e4 Remove now-unused pet state show page 2024-10-11 17:48:23 -07:00
2cc0c5b031 Link pet states to outfit editor instead of their not-useful show page
I was considering doing more with the show page at one point, but ehh,
I think the outfit editor is the better place for that stuff anyway.
2024-10-11 17:44:56 -07:00
381a892af8 Add a bit more space around Rainbow Pool filter forms 2024-10-11 17:39:35 -07:00
1a0fb68b1c Add more explanation copy to Rainbow Pool pages 2024-10-11 17:38:34 -07:00
4f9fbc1ac0 Improve pet type "Added" timestamp styles 2024-10-11 16:24:47 -07:00
ad51690617 Sort pet types alphabetically when filtering the Rainbow Pool
The default is latest first, but when you're searching a color or
species, you probably just want a consistent alphabetical order.
2024-10-11 16:24:24 -07:00
5648f55d2c Add date to pet types in Rainbow Pool 2024-10-11 16:00:07 -07:00
6934b636fb Update Pet Styles copy 2024-10-11 15:50:31 -07:00
83fe0d20e0 Add Rainbow Pool breadcrumbs to Pet Styles page 2024-10-11 15:42:19 -07:00
be5ad31a1d Link alt styles to the outfit editor, rather than to the big image URL 2024-10-11 15:40:20 -07:00
1626f0706c Move Alt Styles into /rainbow-pool URLs, say "Pet Styles" more in text 2024-10-11 15:37:37 -07:00
7fad6abfed Add homepage link to Rainbow Pool, and move Modeling Hub to footer 2024-10-11 15:27:36 -07:00
c985c50a1b Add special styles for the placeholder option in select tags
Noticed this for the Rainbow Pool filters!
2024-10-11 15:23:35 -07:00
bba863b94b When filtering to a specific pet type, redirect right to it!
That is, if you filter to "Candy Chia", we just redirect straight to
it, if it exists; rather than showing you a results page.
2024-10-11 15:13:59 -07:00
7c1b3ca447 Add "no results" output for Rainbow Pool filters with no results 2024-10-11 15:13:22 -07:00
71f0aa4908 Oops, fix modeling logic
Oh huh, I guess most of the new items we had when I rewrote this were
Maraquan, and I didn't test enough on standard species-specific items.

Before this change, partially-modeled items for standard pets would
appear as fully modeled, because the presence of the "nonstandard"
color Orange (because of the Orange Chia) meant that the "standard" key
didn't actually have any unique bodies (it was all `["standard", 47]`).

Here, I take my own comments' advice and move away from the standard
label as part of the logic. Instead, we look first for nonstandard
colors with unique bodies, which we'll call out as modelable; and then
check whether there are any basic bodies *not* covered by those special
colors.

That way, compatibility with the Maraquan Acara (a unique body) means
we'll treat Maraquan as a modelable color; and then we'll ignore the
basic bodies, even though it *does* fit the basic Mynci, because there
aren't any compatible basic bodies that aren't *also* Maraquan bodies.

This also means that compatibility with both the Blue Acara and Orange
Acara does *not* preclude a normal item from needing basic pets for
models: because, while Orange is a slightly nonstandard color in the
case of Chia, it doesn't have its own unique body for this item, so we
ignore it when predicting compatibility with basic colors.
2024-10-08 22:46:11 -07:00
13a0362e6d Use PetState#updated_at for the supported pose cache key, not latest ID
This is because labeling poses with the Support tools *should*
invalidate the `PetState.all_supported_poses` cache! But the previous
cache key would only invalidate when a new pet state is *added*, not
when one is *edited*.
2024-10-07 17:56:42 -07:00
fe67211fdf Add created_at/updated_at to PetState
This has just been absent for too long! We've lost a lot of data about
when poses were first modeled, which is a shame.

But I want this in now, because I was just doing caching on
/rainbow-pool.json, and realized that _labeling_ poses is another way
pet states can update rather than just being created!

So we need an `updated_at` field, to be able to quickly detect edits
that require us to invalidate the cache on
`PetState.all_supported_poses`. I'll do that next!
2024-10-07 17:54:54 -07:00
0244653cb0 Add /rainbow-pool.json for all species, colors, and poses
This clocks in a bit bigger than what Impress 2020 does in terms of
binary encoding (with gzip it's at 11K instead of 4K), but I'm okay
with that for the simplicity win.

Gonna try to swap this in for where we're still using Impress 2020 for
the species/color picker in the outfit editor!
2024-10-07 17:38:53 -07:00
2c0d55edd1 Remove unused code related to no-longer-present asset downloads 2024-10-07 17:06:14 -07:00
be0faaa36e Improve top nav support on mobile for responsive design pages
Before this change, pages that opt in with `use_responsive_design`
would often have the top nav be real cluttered for logged-in users. (I
think I happened to first test this responsive design without being
logged in on my dev box, oops!) Because the home link and `#userbar`
were absolutely positioned on the page, they would frequently overlap.

Here, I stop doing our old tricks to make the top nav load last on the
page. (This was to get "main content" loading faster, which I think is
a. not as relevant today with more commonly faster connections, and b.
was a bit naive to think that it'd be helpful to have to wait a long
time to _navigate_ if a page is unexpectedly large.)

These tricks used to leave some padding at the top of the `#container`,
which these elements would then visually fill via `position: absolute`
once they load.

Next, I update the CSS (for the responsive design pages only) to use
the new `#main-nav` container to lay them out in Flexbox: all in one
row if possible, or wrapped if needed.

Some designs hide stuff like this into a hamburger menu or such when
the screen gets small. I haven't done that here! No specific reason,
I'm just not sold that it's that much better, or worth the trouble.

I tested this on the following combinations:
1. Logged out, homepage
2. Logged in, homepage
3. Logged out, `/items`
4. Logged in, `/items`
5. Logged out, `/items/89487-Dyeworks-Purple-Haunted-Sky-Background`
6. Logged in, `/items/89487-Dyeworks-Purple-Haunted-Sky-Background`

Hope it's solid! 🤞
2024-10-05 17:52:38 -07:00
f87f4e61b3 Add extra support info to Rainbow Pool pet types
Easy-to-notice hints for which pet types need more labeling!
2024-10-04 19:24:40 -07:00
dfca88bed3 Oops, use the Rainbow Pool list styles under "Other" show/hide 2024-10-04 18:46:53 -07:00
bd001e643e Oops, avoid scooping up weird Chia bodies in predicted_body_ids
Before this change, a fully-modeled item (Dyeworks Burgundy: Gown of
the Night) was displaying as still needing the Chia. This was because
looking for "standard" body IDs like this caught up some of the weird
Chia bodies.

I think there's probably something here where we need to like, relabel
certain colors? But honestly, the better version of this logic would
probably be to lean more into the `basic` label in this logic.

But hey, that's a refactor for another time. I gotta go eat!
2024-10-03 15:39:35 -07:00
1d51e28144 Post perf upgrades announcement (and job-hunting ask 💖️) 2024-10-03 15:09:38 -07:00
fe4db1b605 Improve prediction for what pets need modeling for an item
Noticing a lot of Maraquan items on the homepage today, and they're
doing that thing of expecting standard body types to be relevant too,
because I think we wrote this logic before the Maraquan Mynci ended up
having the same standard Mynci body? (Maybe? Or maybe I'm being
ahistorical and I just wrote this wrong to begin with lol)

In any case, this is more accurate, and I think I'm also maybe
incidentally noticing that it's running faster, at least in my brief
before/after production testing? (There's *more* queries, like 100! But
many of them are *very* fast lookups, coming in at under 1ms—and also a
lot of them are dupes being served by Rails's request-scoped query
cache.)
2024-10-03 13:49:15 -07:00
860b8eef72 Remove not-very-useful caching for homepage modeling
Huh, I hadn't realized that like, we'd already set up the controller to
always *run* basically all of the modeling logic, and the caching in
the view layer wasn't saving us any queries anymore. Kinda silly!

Remove the caching call, just to simplify the codebase (I like to avoid
caching things that don't specifically need it!).

And hey, love that the modeling code in the controller is now *way*
faster to run! You love to see it!
2024-10-02 18:26:49 -07:00
61e22e3943 Oops, remove no-longer-true comment about a code block I just deleted! 2024-10-02 18:20:22 -07:00
03e4233f67 Use cached compatible body IDs on homepage modeling code
This should make it load way faster! Maybe don't even need to mess with
caching the resulting HTML anymore, like we currently do?
2024-10-02 17:55:20 -07:00
b6bddb14be Oops, fix new bug in homepage modeling code
Missed a spot on `Item#basic_body_ids`!
2024-10-02 17:54:14 -07:00
e52838ba70 Use Rails serialize method to save/load cached fields in Item
Just packing some serialization complexity away into its own thing, so
the model code doesn't need to sweat it!
2024-10-02 17:50:42 -07:00
7ba68c52d4 Simplify homepage modeling code a bit
I have some other changes planned too, but these are some easy ones. I
also turn back on this stuff in development, in hopes that my changes
can make these queries fast enough to not be a big deal anymore!
2024-10-02 17:26:32 -07:00
26add4577c Use cached fields for item searches, instead of big joins
This is the second part of the previous change `efda6d74`, in which we
switch out the item search query conditions!

This was a two-parter to ease deployment: first deploy the change with
the migration, then *run* the migration (because it's an unusually slow
one), then deploy this change that actually uses it.

Another way to approach this would've been to deploy it all in one
commit, but not set it as `current` until we had run the migration.
That would have been a reasonable approach too!
2024-09-30 23:16:03 -07:00
efda6d74ab Add cached fields to Item model for searching, but don't use them yet
This is the first part of a change to improve search performance, by
caching occupied zone IDs and supported body IDs onto the Item record
itself, instead of always doing joins with `SwfAsset`.

It's unfortunate, because part of the power of SQL is joins! But doing
joins with big tables, in ways that can't take advantage of indexes in
the same ways as we often want to, is… slow.

It's possible there's something I'm misunderstanding about SQL
optimization, and this _could_ be done with query optimization or
indexes instead of duplicating data like this? This complexity carries
the risk of data getting out of sync in unforeseen ways. But this is
what I know how to do, and it seems to be working, so! Okay!
2024-09-30 23:10:44 -07:00
4a431a4ae8 Oops, omit the commit field from Rainbow Pool filter URLs
By default, Rails gives this button the name `commit`, so it appears in
the URL the form sends to. By setting the name to `nil`, Rails doesn't
set a `name` attribute on the HTML element, so it's *not* included.
2024-09-30 18:05:05 -07:00
4bcc3aaebb Limit Rainbow Pool filter dropdown size
cuz the "Prismatic Pink: Nostalgic" stuff is gonna get pretty long if
we just do the default behavior of letting it grow to max content size!
2024-09-30 17:42:52 -07:00
5890e52e53 Use full name when showing Alt Styles in the list 2024-09-30 17:41:21 -07:00
dd8426fefd Paginate Alt Styles, sort by most recent first-seen date 2024-09-30 17:35:18 -07:00
2a9818b2d1 Add series name filter to Alt Styles filter form
Right now this just is Nostalgic, but I'll label the rest soon!
2024-09-30 17:34:31 -07:00
0b72b5568c Add edit form for Alt Styles, for Support staff only
We'll need this to fix up the series names and thumbnails for the new
prismatic styles!
2024-09-30 17:21:45 -07:00
86e1f31231 Only show *relevant* colors in Alt Styles filters
That is, there is no 8-bit alt style, so don't bother including it in
the filter form; same for most other colors.
2024-09-30 16:35:58 -07:00
a99fb3ec02 Use Rainbow Pool styles for Alt Styles lists 2024-09-30 16:24:43 -07:00
d11c18129d Refactor Rainbow Pool to use shared styles for the list elements
The lists of pet types and pet states had very similar styles, which I
mostly copy-pasted. Now that I want to use them for Alt Styles too, I'm
refactoring!
2024-09-30 16:21:47 -07:00
0958111341 Share styles between pet types and alt styles as "rainbow-pool" CSS 2024-09-30 16:10:26 -07:00
775baa250b Add filter form to alt styles page
Oh wow, alt styles are getting some real work! I'll improve both the
user-facing and Support-facing tooling, to better handle the complexity.
2024-09-30 16:06:22 -07:00
2bd8afd486 Fix whitespace around "(X species)" in item page zone info 2024-09-29 15:05:57 -07:00
1f1c6d92b1 Oops, fix item page's "Customize More" not animating after color change
Ah whoops, I didn't notice that, when Turbo morphs the
`<measured-container>` into what the server HTML returns, it deletes
the `style` attribute we were using.

In this change, I refactor for `MeasuredContainer` to be the component
rather than `MeasuredContent`, so that it can also be responsible for
listening for changes to its own `style` prop, and remeasuring when
they happen.

We're also careful to avoid infinite loops, by only doing this when the
property is missing! (Otherwise, setting `--natural-width` triggers the
callback again, oops!)
2024-09-29 14:59:52 -07:00
e4a640ccee Oops, fix minor breadcrumbs display bug on pet_types#show 2024-09-27 22:39:22 -07:00
d465f4125e For support staff, Rainbow Pool links directly to edit page, not show
Just on the assumption that like. We're mostly here to edit things
2024-09-27 22:35:13 -07:00
946a6326ac Use radio buttons for poses in Rainbow Pool form, instead of dropdown
Just a bit easier to find what you want! especially with the grid layout
2024-09-27 22:34:52 -07:00
d5a901b917 Add edit form to Rainbow Pool for pet states, for support staff only 2024-09-27 22:14:00 -07:00
39e5ca59c4 Add breadcrumbs to Rainbow Pool pages 2024-09-27 20:01:07 -07:00
4fa80d33cc Merge branch 'main' into rainbow-pool 2024-09-27 19:43:31 -07:00
d66f81c96b Remove support for old "Nebula (fake)" April Fools color
This hasn't worked for a while anyway! Let's remove the bits of code
where we deal with it, and the database field that signals it. (We also
make a corresponding change in Impress 2020, so it doesn't crash trying
to query based on the `prank` column.)

I also ran this snippet to clear out all the Nebula stuff in the db:

```rb
Color.transaction do
	nebula = Color.where(prank: true).find_by_name("Nebula")
	nebula.pet_types.includes(pet_states: :swf_assets).each do |pet_type|
		pet_type.pet_states.each do |pet_state|
			pet_state.parent_swf_asset_relationships.each do |psa|
				psa.swf_asset.destroy!
				psa.destroy!
			end
			pet_state.destroy!
		end
		pet_type.destroy!
	end
	nebula.destroy!
end
```
2024-09-27 19:38:53 -07:00
f8a5ce4490 Improve Rainbow Pool filter form styles 2024-09-27 19:10:37 -07:00
81f0845d4a Improve Rainbow Pool link styles 2024-09-27 18:45:45 -07:00
f0257ba2d3 Merge branch 'main' into rainbow-pool 2024-09-27 18:32:04 -07:00
d056a5e766 Oops, don't show not-directly-for-sale items as being "0 NC"
"Fall Woodland Leaves Filter" is an example, it's part of the two-item
*pack* named "Fall Woodland Minitheus Petpet Foreground". The NC Mall
page for it will include the secondary items in `object_data`, but it's
not part of the storefront itself—and the only thing indicating that is
the `render` list.

Theoretically, we could use this to construct more data about like,
packs and stuff, automatically? But also, I don't want to backfill it
for everything historically, so like. Whatever.
2024-09-27 18:27:12 -07:00
5214a14990 Rescue from ActiveRecord::ConnectionTimeoutError
Just to stop filling the crash logs with it… if they spike, we'll be
alerted by the downtime monitor anyway.
2024-09-27 17:50:35 -07:00
06a89689d8 Oops, fix crash when modeling Patchwork Staff (AMFPHP string encoding!)
See comment for details! I wonder if other items have been affected by
this in the past. I think probably what happened before was that we
successfully created this item, but failed to create the *translation*,
so when migrating over the Patchwork Staff all its translated fields
were empty? (That's what I found looking in the database today.)

But yeah, thankfully our crash logging at health.openneo.net gave me
the name of a pet someone was trying to model, and so I was able to
find the bug and fix it!
2024-09-27 15:18:43 -07:00
a08fb89d59 Oops, don't crash when an item has no previews
A weird state to get into, one would expect impossible! But something
funny is going on with the Kiko Lake Team Popcorn item (85598)!
2024-09-27 15:18:43 -07:00
80307f21f7 Add Rainbow Pool homepage, with basic filter form 2024-09-26 21:10:25 -07:00
75040ffbf3 Add pages for the Rainbow Pool pet states 2024-09-26 20:24:31 -07:00
6f45cd0485 Add a bit more info to Rainbow Pool glitched label 2024-09-26 19:34:30 -07:00
4e33477c65 Hide unconverted below the "Other" list for Rainbow Pool poses 2024-09-26 19:33:16 -07:00
b28255cafd WIP: Better styles for Rainbow Pool pet type page 2024-09-26 18:39:32 -07:00
99e8b46157 Oops, fix bug parsing "8-Bit-Chia" in Rainbow Pool URLs 2024-09-26 18:36:49 -07:00
734b7fba1d WIP: Outfit viewers on pet type Rainbow Pool page
Now that we have such a convenient lil outfit viewer component we built
for the item page preview, it's easy peasy to drop it in here too! And
it's all nice and lightweight, since in this case it's basically just.
image tags, with some supporting enhancements.

Anyway, this page has no actual useful styles of its own yet. Gonna
make it look nice and such!
2024-09-26 18:20:05 -07:00
a1d6961249 WIP: Placeholder page for Rainbow Pool pet type
I'm experimenting with a Rainbow Pool ish UI, mainly as a support tool
for exploring and labeling poses—but one we can probably just show to
real users too!

Right now, I just use pet type images as a placeholder, and I polished
up some of the `pet_type_image` API. But we're probably gonna drop
these for a full outfit viewer, now that I think of it.
2024-09-26 14:56:45 -07:00
e7148ffae3 Oops, finish removing record_tag_helper gem
My bad!!
2024-09-26 12:53:16 -07:00
64b1d11faa Remove old record_tag_helper gem
This is a transitional gem to help with upgrading from old versions of
Rails: it provides a deprecated feature that Rails removed.

I audited and I *think* we only used it in one place, and that this one
place doesn't even use any of its functionality for styling or
scripting? So, begone!
2024-09-26 12:50:47 -07:00
e63f4df25b Run bundle update 2024-09-26 12:42:18 -07:00
535a0029f9 Replace some JS with the @starting-style CSS directive
Oh sweet, I learned about a new CSS feature with good-enough support!
This lets you use CSS transitions for an element as it enters the page,
or becomes visible.

Firefox only has partial support for this feature rn, but its partial
support covers our case, I tested to make sure! (Specifically, it
doesn't handle transitioning from `display: none` yet, which isn't what
we're doing.)
2024-09-24 19:33:06 -07:00
c0e4291745 Remove FragmentLocalization and localized_cache helper
We replace the `localized_cache` helper with just simple keys provided
to the `cache` helper, with `locale=#{I18n.locale}` inlined. End of an
era!
2024-09-20 20:10:04 -07:00
d27c03606f Delete unused images
Whew, quite a history here! I didn't _extensively_ audit for these, but
I scanned with pretty good searches and hit major pages and they didn't
crash, so. Good enough for me!
2024-09-20 19:38:52 -07:00
40a3f5bf68 Don't show the list filter for petpage exports if you have no lists 2024-09-20 19:30:23 -07:00
4bc38db5aa Replace closet_hangers/petpage.js with modern CSS
We use jQuery to basically simulate the `:has()` pseudoselector. Let's
just, use `:has()` now!
2024-09-20 19:27:39 -07:00
2ab1951e68 Move closet_hangers/petpage stylesheet into its own CSS file 2024-09-20 19:26:06 -07:00
cae2f3ca74 Serve jquery and jquery.tmpl from our own codebase, instead of a CDN
Right, yeah, we've been depending on an external CDN for a long time
for jQuery and the jQuery Template library, and I don't like that kind
of external dependency! Let's put it in with the rest of our libs.
2024-09-20 19:23:53 -07:00
31619071af Remove ajax_auth.js lib, by merging it in where needed
It's only actually used in two JS files, so rather than doing a weird
global `$.ajaxSetup` call, let's just inline it into the small handful
of AJAX calls that actually care.
2024-09-20 19:10:26 -07:00
f20a1b5398 Oops, fix locale form with Turbo pageloads
Before this change, this would only work on the first pageload, and
fail after doing a Turbo page navigation. Now, it works all the time!
2024-09-20 18:55:08 -07:00
3bd6f09a54 Remove "About NeoPass" page, now that it's on the blog 2024-09-20 18:43:38 -07:00
38474d19d7 Oops, fix broken strings on Neopets page import wizard
Uhh I guess when I half-removed a feature from the translations list (I
don't remember when?), it left two different dictionaries labeled
`neopets_page_import_tasks.new`, and the second one overwrote the
first. Oops! Yikes!

By removing these, the translations *above* them actually get to apply
to the page correctly. Before this change, the page just showed the
translation keys as placeholders, womp womp.
2024-09-20 18:16:06 -07:00
73e0b3bb3c Remove some silly view template caching calls
When I was trying to debug slow view code one time long long ago, I was
like "let's cache any part of the template that's static!"

And like. no that's silly, I don't trust that this speeds anything up,
but it _definitely_ adds complexity. Let's just not.
2024-09-20 18:08:11 -07:00
1f53615654 Add "State of DTI: 2024" blog post announcement 2024-09-20 18:02:58 -07:00
7f55456454 Explicitly disable the unused ActionCable Rails feature
Just for consistency with the other features we're not using, we turn
off ActionCable when loading the app. I just removed
`config/cable.yml`, so I figure, let's not load a feature without the
config file it expects! (even though that didn't seem to bother it)
2024-09-20 13:14:00 -07:00
f23bebb607 Remove unused config/cable.yml and config/store.yml files
We're not using the ActionCable or ActiveStorage Rails features in this
app, so we can clear out these default config files. If we need them
later, it's not hard to re-find / re-generate them!
2024-09-20 13:12:47 -07:00
cf2cd41531 Remove unused config/basic_type_hashes.yml file
Our production data now contains basic hashes for all species/color
combinations, and it's easy enough for a dev copy of the site to get
them too by running `rails public_data:pull`. So, I think it's time to
retire this hardcoded set, and get one more file out of our codebase!
2024-09-20 13:10:15 -07:00
d45162897d Upgrade to Rails 7.2.1
No pressing reason, I'm just doing upgrades today, and noticed a new
version is out, and scrolled the patch notes and there's no obvious
breaking changes for my purposes, so. Up we go!
2024-09-20 12:57:59 -07:00
02b510bb3f Upgrade to Yarn 4.5.0 2024-09-20 12:47:54 -07:00
9ebc498888 Upgrade to Ruby 3.3.5, and improve the mechanisms for it a bit
I move `ruby_version` into an Ansible variable, to make it easier to
update in the future!
2024-09-20 12:47:35 -07:00
5bf2ef42a0 Move JS libraries to vendor/javascript
The silly motivation is that I wanted to remove `.prettierignore`,
which just exists to omit that one folder from `npm run format`. But it
also seems like this is the standard place to put them—a standard
created long after we first set this up lol
2024-09-13 21:16:46 -07:00
0a5d369735 Remove i18n locale config complexity we don't use anymore
I forget what this was for, I think part of it was for managing item
names in different languages, and the "private" locale thing was
probably for WIP locales? But yeah, not used, delete!
2024-09-13 20:55:09 -07:00
ebd400369a Remove misc unused files 2024-09-13 20:43:32 -07:00
81e4d16816 Remove unused Delicious-Heavy.otf font
I don't think we've uhh ever used this? Idk?
2024-09-13 20:39:22 -07:00
95ae669549 Remove Noto fonts and just use system-ui
Yeah, I don't remember why So Many Years Ago I felt it was important to
use the Droid fonts; I adapted this choice into the Noto fonts when
modernizing the other day, but, tbh, the default system fonts are
probably just a better fit for like. everything we do, and then *not*
downloading MB of font files.

I also feel like a lot of the contexts where we used serif fonts were
like, frankly incidental, based on where we chose `<p>` for semantic
reasons? I don't think any of them actually are made much better by
serifs, I'm okay with just simplifying and dropping that, instead of
looking for a better serif font stack to replace it.
2024-09-13 20:07:12 -07:00
989c96fd2b Oops, fix pb_item for "Royal Girl Elephante Gold Bracelets" and similar
There's some funny bugs we had here, like "Relic Elephante Jewellery"
and "Royal Girl Skeith Bodice" getting assigned "Ice", and
"Tyrannian Meerca Spear" being "Pea" lmao

I went and checked all the assignments now and they look good to me!

```ruby
Item.is_pb.order(:name).
  map { |i| [i.pb_color&.human_name, i.name] }
```
2024-09-13 19:56:41 -07:00
fdf1f31867 Add pets:find task to look up pets of a given color/species 2024-09-13 18:59:17 -07:00
c7b0ec71ef Add pet_types:guess task to guess poses for Invisible etc pets 2024-09-13 18:12:28 -07:00
287d7af1b9 Fix minor whitespace issue on item page "Occupies" zone list
Ahh right, when you indent stuff underneath a tag in HAML, it does the
same indented form in the output HTML, which adds whitespace that
creates a problem for how we're doing this list.

Before this change, the "Engulfed in Flames Effect" item showed below
the preview: `Occupies: Background Item , Lower Foreground Item`, with
an extra space before the comma.

After this change, it now shows
`Occupies: Background Item, Lower Foreground Item`, as intended.
2024-09-12 16:03:10 -07:00
58d7c38523 Simplify CSP header for SWF asset embeds, to fix 502 for some assets
Fun little bug: viewing the "Engulfed in Flames Effect" item was
showing our "502 Bad Gateway" custom error page in the embed. This is
because the Rails app was providing a `Content-Security-Policy` header
value that was longer than nginx is configured by default to allow, so
it was refusing the response, and showing the same 502 error as if the
app hadn't responded at all. (We discovered this by opening
`/var/log/nginx/error.log`, which explained this very clearly, ty~!)

In this change, we no longer list every `images.neopets.com` asset,
instead marking the entire domain as a valid image source for the
SWF asset embed iframe. I don't _love_ this solution, I liked the
property of specifying literally exactly the assets we allow! But I
don't think there's any practical danger here, and it helps a *lot* for
making this more reliable.

(If we could have solved this reliably by increasing nginx's allowed
response header size, I probably would've done that? But I researched a
bit, and ultimately concluded that I don't trust other intermediary
software like firewalls not to have the same issue. Let's not be
pushing the limits of HTTP headers of all things!)
2024-09-12 15:59:18 -07:00
68b6f46939 Oops, fix typo blocking non-bold-or-italic Delicious font from loading 2024-09-09 21:45:52 -07:00
cf6a19a7fc Use Noto Sans as a fallback if Delicious fails to load
This shouldn't ever be an issue in practice? I just noticed it because
something funny is going on with the `#userbar` element specifically
not using the Delicious font, and so I figured, hey, this simulates a
very real possible scenario, I'd rather use our consistent sans font
in this case!
2024-09-09 21:44:39 -07:00
9e052789db fix hash in Thanks for showing us banner 2024-09-09 21:37:56 -07:00
30f211caf3 Remove some now-unused homepage styles
These must be from long ago! Shrug!
2024-09-09 21:35:06 -07:00
dab865689f Refactor module sections on homepage, to handle font change
Huh okay, moving to my other machine, the change to Noto Sans subtly
broke the homepage layout a bit, wrapping the form buttons to the next
line in the three module sections.

Here, I refactor to more modern grid/flexbox sensibilities. Btw, there
was a Flexbox thing that didn't work quite how I expected? I commented
on my confusion, but checked in Chrome and Firefox and it seems to work
in both, so, ok!
2024-09-09 21:33:05 -07:00
874483eacb Fix SassC::SyntaxError when compiling perfectly valid CSS files
Okay cool, so this was an error that was happening *only* when building
assets for production: Sass's CSS minifier isn't familiar with all
modern CSS syntax (I think is the issue?), and so errors on things that
are actually totally okay.

I had previously worked around this in `swf_assets/show.css` with an
equivalent syntax that Sass recognized. But in this latest case with
the new `fonts.css.erb`, it was upset about the `src` list for the
fonts, and I don't know a workaround for that.

So, let's just disable Sass's CSS minification for now. I imagine the
difference isn't huge when CSS compresses just fine with gzip anyway?
(Most of what you can "minify" in CSS is whitespace, and that largely
seems silly to me when gzip is running.)
2024-09-09 19:59:43 -07:00
2466e5971e Move font definitions into a separate CSS file, using ERB and not Sass
Just checking out what doing asset references in a non-Sass way is!
2024-09-09 19:49:51 -07:00
ce50a19d31 Remove second reference to Delicious fonts that I missed 2024-09-09 19:45:58 -07:00
d55512ad99 Remove unused _blue.sass file
I think these were for the old site layout, which we replaced with the
"clean" theme?
2024-09-09 19:34:10 -07:00
852dc74001 Migrate from Droid fonts to Noto fonts, and host them locally
I was just scrolling our CSS and surprised to find we use Google Fonts
embeds! I don't like depending on external hosts like that.

Google Fonts doesn't offer the Droid fonts for download anymore,
though—looks like the Noto fonts are their spiritual successor. The
Droid Serif and Noto Serif fonts look visually identical to me, but the
Sans ones are a bit different… I kinda like the charm of the Droid Sans
better, but ah well! I'd rather be moving forward with a more modern
font with more reliable glyph support etc for now.
2024-09-09 19:31:41 -07:00
1d36e60df9 Remove now-unnecessary extra header on Modeling Hub page 2024-09-09 19:06:19 -07:00
0f7b01bec3 Remove unused DEBUG variable from pets/bulk.js
Huh, I wonder what that used to do! Oh well!
2024-09-09 19:05:07 -07:00
a14c4fca48 Remove needed items form on Modeling Hub
I think this has just been broken for a long time? And I don't think
it's very useful in a world 15 years later, where our problem *used* to
be giant gaps in our library, which isn't really our data problem
anymore.
2024-09-09 18:56:39 -07:00
a315282b70 Extract all the item pages' stylesheets into their own CSS files
No more of this loading everything into `application.css`! I'm
arbitrarily starting here because that's where I've been playing
lately, but this is part of a larger effort to move toward a more
straightforward CSS architecture (and away from Sass even?)
2024-09-09 18:48:08 -07:00
38b2bad044 Include application stylesheet first, so other styles override it
Ran into a funny thing on an upcoming change, where a style on the
page-specific stylesheet was getting undone by the *CSS Reset* of all
things in the application stylesheet. Resets come first!

I haven't audited that I didn't break a ton of stuff with this change,
but. I hope not! :)
2024-09-09 18:33:28 -07:00
8a8dd468be Improve handling of image hash pet names on the homepage
Closes , by adapting the suggested changes! Thank you!!

We both change how we create pet name preview jobs, by catching the `@`
case early; and we better handle symbols in pet names when showing the
thank you message, by parsing the query string more correctly.

Co-Authored-By: Steve C <diceroll123@gmail.com>
2024-09-09 18:25:56 -07:00
d84ab44771 Fix response when modeling pet from Modeling Hub homepage form
This was always modeling correctly, but not showing the message,
because Turbo doesn't handle anchors in redirect URLs the same way the
browser's full page loads do.

I forget why we had this as a `#` URL anyway to begin with. Use `?`
instead!
2024-09-09 18:17:09 -07:00
903fb19d5c Remove some unused code from outfits/new.js
This whole file could probably be made a LOT smaller and simpler but
hey, I'm not in the mood lol!
2024-09-09 18:11:03 -07:00
7f356cdede Slight improvement to Customize More noscript behavior
My wife suggested this, thanks Aria!! Identical behavior, but not using
the hack of reading the `style` attribute.
2024-09-09 16:27:39 -07:00
2aed7b21db Add noscript hover behavior for the Customize More button on item page 2024-09-09 16:19:20 -07:00
d1ac66a80f Remove "Customize more" tooltip, now that the button shows it on hover 2024-09-09 16:13:35 -07:00
0e314482f7 Set Prettier default to tabs instead of spaces, run on all JS
I haven't been running Prettier consistently on things in this project.
Now, it's quick-runnable, and I've got it on everything!

Also, I just think tabs are the right default for this kind of thing,
and I'm glad to get to switch over to it! (In `package.json`.)
2024-09-09 16:11:48 -07:00
71ffb7f1be Add the slide-out "Customize more" label back to item preview button
I skipped this for a bit because I couldn't think of a simple way to
adapt this behavior to a web component + vanilla CSS setting, but then
I thought of CSS variables, and sat down and cranked this out!
2024-09-09 13:05:42 -07:00
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 , 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
acade360a8 Update Ruby to 3.3.4, and update gems
For the gems, I mostly just ran `bundle update`; with the exception of
`httparty`, because latest Ruby throws a deprecation warning about its
use of the deprecated `csv` stdlib, which the latest version resolves.

One other little thing: this is on my new Fedora workstation, and I had
to deal with a known bug where the `sassc` gem compiles a `libsass.so`
file, but saves it in the wrong place somehow.

Here's the known bug, and the comment that helped me:
https://github.com/sass/sassc-ruby/issues/146#issuecomment-2028974524

And here's what I ran to get it into the right place:

```shell
ln -s ~/.local/share/gem/ruby/3.3.0/extensions/aarch64-linux/3.3.0/sassc-2.4.0/sassc/libsass.so \
      ~/.local/share/gem/ruby/3.3.0/gems/sassc-2.4.0/lib/sassc/libsass.so
```

This thing about `libsass` isn't reflected in the code changes anywhere
in this commit! I'm just mentioning it so that it's literally written
down anywhere. (I did try other comments' advice to use an older
version of `sassc` first, but I ran into compilation errors, so figured
this machine-side hack was better than untangling that mess.)
2024-08-27 17:06:22 -07:00
fdfc6c9756 Add matchu@deathstar SSH key
I got a new laptop!
2024-08-19 10:48:16 -07:00
522 changed files with 19478 additions and 16350 deletions
.gitignore
.husky
.rspec.ruby-versionGemfileGemfile.lock
app
assets
controllers
helpers
javascript

2
.gitignore vendored
View file

@ -4,6 +4,8 @@ log/*.log
tmp/**/*
.env
.env.*
/spec/examples.txt
/.yardoc
/app/assets/builds/*
!/app/assets/builds/.keep

View file

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

1
.rspec Normal file
View file

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

View file

@ -1 +1 @@
3.3.0
3.3.7

41
Gemfile
View file

@ -1,10 +1,13 @@
source 'https://rubygems.org'
ruby '3.3.0'
ruby '3.3.7'
gem 'rails', '~> 7.1', '>= 7.1.3.4'
gem 'rails', '~> 8.0', '>= 8.0.1'
# The HTTP server running the Rails instance.
gem 'falcon', '~> 0.43.0'
gem 'falcon', '~> 0.48.0'
# HACK: Workaround for https://github.com/socketry/protocol-rack/issues/20
gem 'protocol-rack', '~> 0.10.0', '< 0.11.0'
# Our database is MySQL, in both development and production.
gem 'mysql2', '~> 0.5.5'
@ -19,7 +22,7 @@ gem 'haml', '~> 6.1', '>= 6.1.1'
gem 'sass-rails', '~> 6.0'
gem 'terser', '~> 1.1', '>= 1.1.17'
gem 'react-rails', '~> 2.7', '>= 2.7.1'
gem 'jsbundling-rails', '~> 1.1'
gem 'jsbundling-rails', '~> 1.3'
gem 'turbo-rails', '~> 2.0'
# For authentication.
@ -33,7 +36,7 @@ gem "omniauth_openid_connect", "~> 0.7.1"
gem 'will_paginate', '~> 4.0'
# For translation, both for the site UI and for Neopets data.
gem 'rails-i18n', '~> 7.0', '>= 7.0.7'
gem 'rails-i18n', '~> 8.0', '>= 8.0.1'
gem 'http_accept_language', '~> 2.1', '>= 2.1.1'
# For reading and parsing HTML from Neopets.com, like importing Closet pages.
@ -57,19 +60,19 @@ gem 'letter_opener', '~> 1.8', '>= 1.8.1', group: :development
gem 'parallel', '~> 1.23'
# For miscellaneous HTTP requests.
gem "httparty", "~> 0.21.0"
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.86.0", require: false
gem "thread-local", "~> 1.1", require: false
# For debugging.
gem 'web-console', '~> 4.2', group: :development
# TODO: Review our use of content_tag_for etc and uninstall this!
gem 'record_tag_helper', '~> 1.0', '>= 1.0.1'
group :development do
gem 'debug', '~> 1.9.2'
gem 'web-console', '~> 4.2'
end
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '~> 1.16', require: false
@ -87,5 +90,15 @@ gem "sentry-rails", "~> 5.12"
gem "shell", "~> 0.8.1"
# For workspace autocomplete.
gem "solargraph", "~> 0.50.0", group: :development
gem "solargraph-rails", "~> 1.1", group: :development
group :development do
gem "solargraph", "~> 0.50.0"
gem "solargraph-rails", "~> 1.1"
end
# For automated tests.
group :development, :test do
gem "rspec-rails", "~> 7.0"
end
group :test do
gem "webmock", "~> 3.24"
end

View file

@ -7,106 +7,105 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
actioncable (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
zeitwerk (~> 2.6)
actionmailbox (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.1.3.4)
actionpack (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activesupport (= 7.1.3.4)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
actionmailbox (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
mail (>= 2.8.0)
actionmailer (8.0.2)
actionpack (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activesupport (= 8.0.2)
mail (>= 2.8.0)
rails-dom-testing (~> 2.2)
actionpack (7.1.3.4)
actionview (= 7.1.3.4)
activesupport (= 7.1.3.4)
actionpack (8.0.2)
actionview (= 8.0.2)
activesupport (= 8.0.2)
nokogiri (>= 1.8.5)
racc
rack (>= 2.2.4)
rack-session (>= 1.0.1)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
actiontext (7.1.3.4)
actionpack (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
useragent (~> 0.16)
actiontext (8.0.2)
actionpack (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.1.3.4)
activesupport (= 7.1.3.4)
actionview (8.0.2)
activesupport (= 8.0.2)
builder (~> 3.1)
erubi (~> 1.11)
rails-dom-testing (~> 2.2)
rails-html-sanitizer (~> 1.6)
activejob (7.1.3.4)
activesupport (= 7.1.3.4)
activejob (8.0.2)
activesupport (= 8.0.2)
globalid (>= 0.3.6)
activemodel (7.1.3.4)
activesupport (= 7.1.3.4)
activerecord (7.1.3.4)
activemodel (= 7.1.3.4)
activesupport (= 7.1.3.4)
activemodel (8.0.2)
activesupport (= 8.0.2)
activerecord (8.0.2)
activemodel (= 8.0.2)
activesupport (= 8.0.2)
timeout (>= 0.4.0)
activestorage (7.1.3.4)
actionpack (= 7.1.3.4)
activejob (= 7.1.3.4)
activerecord (= 7.1.3.4)
activesupport (= 7.1.3.4)
activestorage (8.0.2)
actionpack (= 8.0.2)
activejob (= 8.0.2)
activerecord (= 8.0.2)
activesupport (= 8.0.2)
marcel (~> 1.0)
activesupport (7.1.3.4)
activesupport (8.0.2)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
mutex_m
tzinfo (~> 2.0)
addressable (2.8.6)
public_suffix (>= 2.0.2, < 6.0)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
uri (>= 0.13.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
aes_key_wrap (1.1.0)
ast (2.4.2)
async (2.8.1)
console (~> 1.10)
async (2.21.1)
console (~> 1.29)
fiber-annotation
io-event (~> 1.1)
timers (~> 4.1)
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-http-cache (0.4.3)
io-event (~> 1.6, >= 1.6.5)
async-container (0.18.3)
async (~> 2.10)
async-http (0.86.0)
async (>= 2.10.2)
async-pool (~> 0.9)
io-endpoint (~> 0.14)
io-stream (~> 0.6)
metrics (~> 0.12)
protocol-http (~> 0.43)
protocol-http1 (>= 0.28.1)
protocol-http2 (~> 0.22)
traces (~> 0.10)
async-http-cache (0.4.4)
async-http (~> 0.56)
async-io (1.41.0)
async
async-pool (0.4.0)
async-pool (0.10.2)
async (>= 1.25)
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)
@ -115,23 +114,31 @@ GEM
backport (1.2.0)
base64 (0.2.0)
bcrypt (3.1.20)
benchmark (0.3.0)
bigdecimal (3.1.6)
benchmark (0.4.0)
bigdecimal (3.1.9)
bindata (2.5.0)
bindex (0.8.1)
bootsnap (1.18.3)
bootsnap (1.18.4)
msgpack (~> 1.2)
build-environment (1.13.0)
builder (3.2.4)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
console (1.23.4)
builder (3.3.0)
childprocess (5.1.0)
logger (~> 1.5)
concurrent-ruby (1.3.5)
connection_pool (2.5.0)
console (1.29.2)
fiber-annotation
fiber-local
fiber-local (~> 1.1)
json
crack (1.0.0)
bigdecimal
rexml
crass (1.0.6)
date (3.3.4)
devise (4.9.3)
csv (3.3.2)
date (3.4.1)
debug (1.9.2)
irb (~> 1.10)
reline (>= 0.3.8)
devise (4.9.4)
bcrypt (~> 3.0)
orm_adapter (~> 0.1)
railties (>= 4.1.0)
@ -144,75 +151,85 @@ GEM
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
drb (2.2.0)
ruby2_keywords
drb (2.2.1)
e2mmap (0.1.0)
email_validator (2.2.4)
activemodel
erubi (1.12.0)
execjs (2.9.1)
falcon (0.43.0)
erubi (1.13.1)
execjs (2.10.0)
falcon (0.48.4)
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)
faraday (2.9.0)
faraday-net_http (>= 2.0, < 3.2)
process-metrics (~> 0.2)
protocol-http (~> 0.31)
protocol-rack (~> 0.7)
samovar (~> 2.3)
faraday (2.12.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-follow_redirects (0.3.0)
faraday (>= 1, < 3)
faraday-net_http (3.1.0)
net-http
ffi (1.16.3)
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
ffi (1.17.1)
fiber-annotation (0.2.0)
fiber-local (1.0.0)
fiber-local (1.1.0)
fiber-storage
fiber-storage (1.0.0)
globalid (1.2.1)
activesupport (>= 6.1)
haml (6.3.0)
temple (>= 0.8.2)
thor
tilt
hashdiff (1.1.2)
hashie (5.0.0)
http_accept_language (2.1.1)
httparty (0.21.0)
httparty (0.22.0)
csv
mini_mime (>= 1.0.0)
multi_xml (>= 0.5.2)
i18n (1.14.1)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
io-console (0.7.2)
io-event (1.4.4)
irb (1.11.2)
rdoc
io-console (0.8.0)
io-endpoint (0.14.0)
io-event (1.7.5)
io-stream (0.6.1)
irb (1.15.1)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
jaro_winkler (1.6.0)
jsbundling-rails (1.3.0)
jsbundling-rails (1.3.1)
railties (>= 6.0.0)
json (2.7.1)
json-jwt (1.16.6)
json (2.9.1)
json-jwt (1.16.7)
activesupport (>= 4.2)
aes_key_wrap
base64
bindata
faraday (~> 2.0)
faraday-follow_redirects
kramdown (2.4.0)
rexml
kramdown (2.5.1)
rexml (>= 3.3.9)
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
language_server-protocol (3.17.0.3)
launchy (2.5.2)
launchy (3.0.1)
addressable (~> 2.8)
letter_opener (1.9.0)
launchy (>= 2.2, < 3)
localhost (1.2.0)
loofah (2.22.0)
childprocess (~> 5.0)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
localhost (1.3.1)
logger (1.7.0)
loofah (2.24.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@ -222,40 +239,41 @@ GEM
net-smtp
mapping (1.1.1)
marcel (1.0.4)
memory_profiler (1.0.1)
memory_profiler (1.1.0)
metrics (0.12.1)
mini_mime (1.1.5)
mini_portile2 (2.8.5)
minitest (5.22.2)
msgpack (1.7.2)
multi_xml (0.6.0)
mutex_m (0.2.0)
mini_portile2 (2.8.8)
minitest (5.25.5)
msgpack (1.7.5)
multi_xml (0.7.1)
bigdecimal (~> 3.1)
mysql2 (0.5.6)
net-http (0.4.1)
net-http (0.6.0)
uri
net-imap (0.4.10)
net-imap (0.5.6)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.2)
timeout
net-smtp (0.4.0.1)
net-smtp (0.5.1)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.2)
nio4r (2.7.4)
nokogiri (1.18.6)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
omniauth (2.1.2)
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-rails_csrf_protection (1.0.1)
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
omniauth_openid_connect (0.7.1)
omniauth (>= 1.9, < 3)
openid_connect (~> 2.2)
openid_connect (2.3.0)
openid_connect (2.3.1)
activemodel
attr_required (>= 1.0.0)
email_validator
@ -268,30 +286,35 @@ GEM
tzinfo
validate_url
webfinger (~> 2.0)
openssl (3.2.0)
openssl (3.3.0)
orm_adapter (0.5.0)
parallel (1.24.0)
parser (3.3.3.0)
parallel (1.26.3)
parser (3.3.6.0)
ast (~> 2.4.1)
racc
process-metrics (0.2.1)
pp (0.6.2)
prettyprint
prettyprint (0.2.0)
process-metrics (0.3.0)
console (~> 1.8)
json (~> 2)
samovar (~> 2.1)
protocol-hpack (1.4.2)
protocol-http (0.25.0)
protocol-http1 (0.16.1)
protocol-hpack (1.5.1)
protocol-http (0.47.1)
protocol-http1 (0.28.1)
protocol-http (~> 0.22)
protocol-http2 (0.15.1)
protocol-http2 (0.22.0)
protocol-hpack (~> 1.4)
protocol-http (~> 0.18)
protocol-rack (0.4.1)
protocol-http (~> 0.23)
protocol-rack (0.10.1)
protocol-http (~> 0.37)
rack (>= 1.0)
psych (5.1.2)
psych (5.2.3)
date
stringio
public_suffix (5.0.4)
racc (1.7.3)
rack (3.0.9.1)
public_suffix (6.0.1)
racc (1.8.1)
rack (3.1.12)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-mini-profiler (3.3.1)
@ -303,53 +326,54 @@ GEM
faraday-follow_redirects
json-jwt (>= 1.11.0)
rack (>= 2.1.0)
rack-protection (4.0.0)
rack-protection (4.1.1)
base64 (>= 0.1.0)
logger (>= 1.6.0)
rack (>= 3.0.0, < 4)
rack-session (2.0.0)
rack-session (2.1.0)
base64 (>= 0.1.0)
rack (>= 3.0.0)
rack-test (2.1.0)
rack-test (2.2.0)
rack (>= 1.3)
rackup (2.1.0)
rackup (2.2.1)
rack (>= 3)
webrick (~> 1.8)
rails (7.1.3.4)
actioncable (= 7.1.3.4)
actionmailbox (= 7.1.3.4)
actionmailer (= 7.1.3.4)
actionpack (= 7.1.3.4)
actiontext (= 7.1.3.4)
actionview (= 7.1.3.4)
activejob (= 7.1.3.4)
activemodel (= 7.1.3.4)
activerecord (= 7.1.3.4)
activestorage (= 7.1.3.4)
activesupport (= 7.1.3.4)
rails (8.0.2)
actioncable (= 8.0.2)
actionmailbox (= 8.0.2)
actionmailer (= 8.0.2)
actionpack (= 8.0.2)
actiontext (= 8.0.2)
actionview (= 8.0.2)
activejob (= 8.0.2)
activemodel (= 8.0.2)
activerecord (= 8.0.2)
activestorage (= 8.0.2)
activesupport (= 8.0.2)
bundler (>= 1.15.0)
railties (= 7.1.3.4)
railties (= 8.0.2)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
nokogiri (>= 1.6)
rails-html-sanitizer (1.6.0)
rails-html-sanitizer (1.6.2)
loofah (~> 2.21)
nokogiri (~> 1.14)
rails-i18n (7.0.8)
nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0)
rails-i18n (8.0.1)
i18n (>= 0.7, < 2)
railties (>= 6.0.0, < 8)
railties (7.1.3.4)
actionpack (= 7.1.3.4)
activesupport (= 7.1.3.4)
irb
railties (>= 8.0.0, < 9)
railties (8.0.2)
actionpack (= 8.0.2)
activesupport (= 8.0.2)
irb (~> 1.13)
rackup (>= 1.0.0)
rake (>= 12.2)
thor (~> 1.0, >= 1.2.2)
zeitwerk (~> 2.6)
rainbow (3.1.1)
rake (13.1.0)
rake (13.2.1)
rbs (2.8.4)
rdiscount (2.2.7.3)
rdoc (6.6.2)
rdoc (6.13.1)
psych (>= 4.0.0)
react-rails (2.7.1)
babel-transpiler (>= 0.7.0)
@ -357,37 +381,49 @@ GEM
execjs
railties (>= 3.2)
tilt
record_tag_helper (1.0.1)
actionview (>= 5)
regexp_parser (2.9.2)
reline (0.4.2)
regexp_parser (2.10.0)
reline (0.6.0)
io-console (~> 0.5)
responders (3.1.1)
actionpack (>= 5.2)
railties (>= 5.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.1)
strscan
rubocop (1.64.1)
rexml (3.4.0)
rspec-core (3.13.2)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.2)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (7.1.0)
actionpack (>= 7.0)
activesupport (>= 7.0)
railties (>= 7.0)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.2)
rubocop (1.70.0)
json (~> 2.3)
language_server-protocol (>= 3.17.0)
parallel (~> 1.10)
parser (>= 3.3.0.2)
rainbow (>= 2.2.2, < 4.0)
regexp_parser (>= 1.8, < 3.0)
rexml (>= 3.2.5, < 4.0)
rubocop-ast (>= 1.31.1, < 2.0)
regexp_parser (>= 2.9.3, < 3.0)
rubocop-ast (>= 1.36.2, < 2.0)
ruby-progressbar (~> 1.7)
unicode-display_width (>= 2.4.0, < 3.0)
rubocop-ast (1.31.3)
unicode-display_width (>= 2.4.0, < 4.0)
rubocop-ast (1.37.0)
parser (>= 3.3.1.0)
ruby-progressbar (1.13.0)
ruby2_keywords (0.0.5)
samovar (2.2.0)
samovar (2.3.0)
console (~> 1.0)
mapping (~> 1.0)
sanitize (6.1.0)
sanitize (6.1.3)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
sass-rails (6.0.0)
@ -400,10 +436,12 @@ GEM
sprockets (> 3.0)
sprockets-rails
tilt
sentry-rails (5.16.1)
securerandom (0.4.1)
sentry-rails (5.22.1)
railties (>= 5.0)
sentry-ruby (~> 5.16.1)
sentry-ruby (5.16.1)
sentry-ruby (~> 5.22.1)
sentry-ruby (5.22.1)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
shell (0.8.1)
e2mmap
@ -430,13 +468,12 @@ GEM
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
actionpack (>= 5.2)
activesupport (>= 5.2)
sprockets-rails (3.5.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
sprockets (>= 3.0.0)
stackprof (0.2.26)
stringio (3.1.0)
strscan (3.1.0)
stringio (3.1.6)
swd (2.0.3)
activesupport (>= 3)
attr_required (>= 0.0.5)
@ -444,22 +481,23 @@ GEM
faraday-follow_redirects
sync (0.5.0)
temple (0.10.3)
terser (1.2.0)
terser (1.2.4)
execjs (>= 0.3.0, < 3)
thor (1.3.0)
thor (1.3.2)
thread-local (1.1.0)
tilt (2.3.0)
timeout (0.4.1)
timers (4.3.5)
traces (0.11.1)
turbo-rails (2.0.5)
tilt (2.5.0)
timeout (0.4.3)
traces (0.14.1)
turbo-rails (2.0.11)
actionpack (>= 6.0.0)
activejob (>= 6.0.0)
railties (>= 6.0.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (2.5.0)
uri (0.13.0)
unicode-display_width (3.1.3)
unicode-emoji (~> 4.0, >= 4.0.4)
unicode-emoji (4.0.4)
uri (1.0.3)
useragent (0.16.11)
validate_url (1.0.15)
activemodel (>= 3.0.0)
public_suffix
@ -474,13 +512,17 @@ GEM
activesupport
faraday (~> 2.0)
faraday-follow_redirects
webrick (1.8.1)
websocket-driver (0.7.6)
webmock (3.24.0)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
websocket-driver (0.7.7)
base64
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.5)
will_paginate (4.0.0)
yard (0.9.36)
zeitwerk (2.6.13)
will_paginate (4.0.1)
yard (0.9.37)
zeitwerk (2.7.2)
PLATFORMS
ruby
@ -488,17 +530,18 @@ PLATFORMS
DEPENDENCIES
RocketAMF!
addressable (~> 2.8)
async (~> 2.6)
async-http (~> 0.61.0)
async (~> 2.17)
async-http (~> 0.86.0)
bootsnap (~> 1.16)
debug (~> 1.9.2)
devise (~> 4.9, >= 4.9.2)
devise-encryptable (~> 0.2.0)
dotenv-rails (~> 2.8, >= 2.8.1)
falcon (~> 0.43.0)
falcon (~> 0.48.0)
haml (~> 6.1, >= 6.1.1)
http_accept_language (~> 2.1, >= 2.1.1)
httparty (~> 0.21.0)
jsbundling-rails (~> 1.1)
httparty (~> 0.22.0)
jsbundling-rails (~> 1.3)
letter_opener (~> 1.8, >= 1.8.1)
memory_profiler (~> 1.0)
mysql2 (~> 0.5.5)
@ -507,13 +550,14 @@ DEPENDENCIES
omniauth-rails_csrf_protection (~> 1.0)
omniauth_openid_connect (~> 0.7.1)
parallel (~> 1.23)
protocol-rack (~> 0.10.0, < 0.11.0)
rack-attack (~> 6.7)
rack-mini-profiler (~> 3.1)
rails (~> 7.1, >= 7.1.3.4)
rails-i18n (~> 7.0, >= 7.0.7)
rails (~> 8.0, >= 8.0.1)
rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1)
react-rails (~> 2.7, >= 2.7.1)
record_tag_helper (~> 1.0, >= 1.0.1)
rspec-rails (~> 7.0)
sanitize (~> 6.0, >= 6.0.2)
sass-rails (~> 6.0)
sentry-rails (~> 5.12)
@ -527,10 +571,11 @@ DEPENDENCIES
thread-local (~> 1.1)
turbo-rails (~> 2.0)
web-console (~> 4.2)
webmock (~> 3.24)
will_paginate (~> 4.0)
RUBY VERSION
ruby 3.3.0p0
ruby 3.3.7p123
BUNDLED WITH
2.5.5
2.5.18

View file

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

Binary file not shown.

Binary file not shown.

After

(image error) Size: 7.9 KiB

Binary file not shown.

After

(image error) Size: 23 KiB

Binary file not shown.

Before

(image error) Size: 172 KiB

Binary file not shown.

Before

(image error) Size: 8.9 KiB

Binary file not shown.

Before

(image error) Size: 22 KiB

Binary file not shown.

Before

(image error) Size: 1.9 KiB

Binary file not shown.

Before

(image error) Size: 585 B

Binary file not shown.

Before

(image error) Size: 601 B

Binary file not shown.

Before

(image error) Size: 206 B

Binary file not shown.

Before

(image error) Size: 516 B

Binary file not shown.

Before

(image error) Size: 127 KiB

Binary file not shown.

Before

(image error) Size: 1.7 KiB

Binary file not shown.

Before

(image error) Size: 1.7 KiB

Binary file not shown.

Before

(image error) Size: 16 KiB

Binary file not shown.

Before

(image error) Size: 10 KiB

Binary file not shown.

Before

(image error) Size: 5.1 KiB

Binary file not shown.

Before

(image error) Size: 38 KiB

Binary file not shown.

After

(image error) Size: 2.8 KiB

View file

@ -1,20 +0,0 @@
(function () {
var CSRFProtection;
var token = $('meta[name="csrf-token"]').attr("content");
if (token) {
CSRFProtection = function (xhr, settings) {
var sendToken =
typeof settings.useCSRFProtection === "undefined" || // default to true
settings.useCSRFProtection;
if (sendToken) {
xhr.setRequestHeader("X-CSRF-Token", token);
}
};
} else {
CSRFProtection = $.noop;
}
$.ajaxSetup({
beforeSend: CSRFProtection,
});
})();

File diff suppressed because it is too large Load diff

View file

@ -1,8 +0,0 @@
(function () {
function setChecked() {
var el = $(this);
el.closest("li").toggleClass("checked", el.is(":checked"));
}
$("#petpage-closet-lists input").click(setChecked).each(setChecked);
})();

View file

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

View file

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

View file

@ -0,0 +1,115 @@
// When the species face picker changes, update and submit the main picker form.
document.addEventListener("change", (e) => {
if (!e.target.matches("species-face-picker")) return;
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form",
);
const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']",
);
mainSpeciesField.value = e.target.value;
mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
} catch (error) {
console.error("Couldn't update species picker: ", error);
}
});
// If the preview frame fails to load, try a full pageload.
document.addEventListener("turbo:frame-missing", (e) => {
if (!e.target.matches("#item-preview")) return;
e.detail.visit(e.detail.response.url);
e.preventDefault();
});
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
class SpeciesFacePicker extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
}
get value() {
return this.querySelector("input[type=radio]:checked")?.value;
}
#handleClick(e) {
if (e.target.matches("input[type=radio]")) {
this.dispatchEvent(new Event("change", { bubbles: true }));
}
}
}
class SpeciesFacePickerOptions extends HTMLElement {
static observedAttributes = ["inert", "aria-hidden"];
connectedCallback() {
// Once this component is loaded, we stop being inert and aria-hidden. We're ready!
this.#activate();
}
attributeChangedCallback() {
// If a Turbo Frame tries to morph us into being inert again, activate again!
// (It's important that the server's HTML always return `inert`, for progressive
// enhancement; and it's important to morph this element, so radio focus state
// is preserved. To thread that needle, we have to monitor and remove!)
this.#activate();
}
#activate() {
this.removeAttribute("inert");
this.removeAttribute("aria-hidden");
}
}
// TODO: If it ever gets wide support, remove this in favor of the CSS rule
// `interpolate-size: allow-keywords`, to animate directly from `auto`.
// https://drafts.csswg.org/css-values-5/#valdef-interpolate-size-allow-keywords
class MeasuredContainer extends HTMLElement {
static observedAttributes = ["style"];
connectedCallback() {
setTimeout(() => this.#measure(), 0);
}
attributeChangedCallback() {
// When `--natural-width` gets morphed away by Turbo, measure it again!
if (this.style.getPropertyValue("--natural-width") === "") {
this.#measure();
}
}
#measure() {
// Find our `<measured-content>` child, and set our natural width as
// `var(--natural-width)` in the context of our CSS styles.
const content = this.querySelector("measured-content");
if (content == null) {
throw new Error(`<measured-container> must contain a <measured-content>`);
}
this.style.setProperty("--natural-width", content.offsetWidth + "px");
}
}
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
customElements.define("measured-container", MeasuredContainer);

View file

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

View file

@ -3,10 +3,12 @@ class OutfitViewer extends HTMLElement {
constructor() {
super();
this.#internals = this.attachInternals();
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);
}
@ -67,6 +69,8 @@ class OutfitLayer extends HTMLElement {
}
disconnectedCallback() {
// When this `<outfit-layer>` leaves the DOM, stop listening for iframe
// messages, if we were.
window.removeEventListener("message", this.#onMessage);
}
@ -83,33 +87,36 @@ class OutfitLayer extends HTMLElement {
const iframe = this.querySelector("iframe");
if (image) {
// Initialize status based on the image's current `complete` attribute,
// then wait for load/error events to update it further if needed.
// 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.
// 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 {
throw new Error(`<outfit-layer> must contain an <img> or <iframe> tag`);
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");
@ -118,16 +125,21 @@ class OutfitLayer extends HTMLElement {
this.#setStatus("error");
} else {
throw new Error(
`<outfit-layer> got unexpected status: ${JSON.stringify(data.status)}`,
`<outfit-layer> got unexpected status: ` +
JSON.stringify(data.status),
);
}
} else {
throw new Error(
`<outfit-layer> got unexpected message: ${JSON.stringify(data)}`,
`<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");
@ -135,6 +147,9 @@ class OutfitLayer extends HTMLElement {
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");
@ -144,7 +159,16 @@ class OutfitLayer extends HTMLElement {
}
#sendMessageToIframe(message) {
if (this.iframe?.contentWindow == null) {
// 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;
}
@ -161,27 +185,30 @@ customElements.define("outfit-layer", OutfitLayer);
// 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 = (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;
}
},
},
});
};
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
}
});

View file

@ -1,272 +1,253 @@
(function () {
function petImage(id, size) {
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
}
function petImage(id, size) {
return "https://pets.neopets.com/" + id + "/1/" + size + ".png";
}
var PetQuery = {},
query_string = document.location.hash || document.location.search;
var PetQuery = {},
query_string = document.location.hash || document.location.search;
$.each(query_string.substr(1).split("&"), function () {
var split_piece = this.split("=");
if (split_piece.length == 2) {
PetQuery[split_piece[0]] = split_piece[1];
}
});
for (const [key, value] of new URLSearchParams(query_string).entries()) {
PetQuery[key] = value;
}
if (PetQuery.name) {
if (PetQuery.species && PetQuery.color) {
$("#pet-query-notice-template")
.tmpl({
pet_name: PetQuery.name,
pet_image_url: petImage("cpn/" + PetQuery.name, 1),
})
.prependTo("#container");
}
}
if (PetQuery.name) {
if (PetQuery.species && PetQuery.color) {
var image_url = petImage("cpn/" + PetQuery.name, 1);
if (PetQuery.name.startsWith("@")) {
image_url = petImage("cp/" + PetQuery.name.substr(1), 1);
}
$("#pet-query-notice-template")
.tmpl({
pet_name: PetQuery.name,
pet_image_url: image_url,
})
.prependTo("#container");
}
}
var preview_el = $("#pet-preview"),
img_el = preview_el.find("img"),
response_el = preview_el.find("span");
var preview_el = $("#pet-preview"),
img_el = preview_el.find("img"),
response_el = preview_el.find("span");
var defaultPreviewUrl = img_el.attr("src");
var defaultPreviewUrl = img_el.attr("src");
preview_el.click(function () {
Preview.Job.current.visit();
});
preview_el.click(function () {
Preview.Job.current.visit();
});
var Preview = {
clear: function () {
if (typeof Preview.Job.fallback != "undefined")
Preview.Job.fallback.setAsCurrent();
},
displayLoading: function () {
preview_el.addClass("loading");
response_el.text("Loading...");
},
failed: function () {
preview_el.addClass("hidden");
},
notFound: function (key, options) {
Preview.failed();
response_el.empty();
$("#preview-" + key + "-template")
.tmpl(options)
.appendTo(response_el);
},
updateWithName: function (name_el) {
var name = name_el.val(),
job;
if (name) {
currentName = name;
if (!Preview.Job.current || name != Preview.Job.current.name) {
job = new Preview.Job.Name(name);
job.setAsCurrent();
Preview.displayLoading();
}
} else {
Preview.clear();
}
},
};
var Preview = {
clear: function () {
if (typeof Preview.Job.fallback != "undefined")
Preview.Job.fallback.setAsCurrent();
},
displayLoading: function () {
preview_el.addClass("loading");
response_el.text("Loading...");
},
failed: function () {
preview_el.addClass("hidden");
},
notFound: function (key, options) {
Preview.failed();
response_el.empty();
$("#preview-" + key + "-template")
.tmpl(options)
.appendTo(response_el);
},
updateWithName: function (name_el) {
var name = name_el.val(),
job;
if (name) {
currentName = name;
if (!Preview.Job.current || name != Preview.Job.current.name) {
job = new Preview.Job.Name(name);
job.setAsCurrent();
Preview.displayLoading();
}
} else {
Preview.clear();
}
},
};
function loadNotable() {
// TODO: add HTTPS to notables
// $.getJSON('https://notables.openneo.net/api/1/days/ago/1?callback=?', function (response) {
// var notables = response.notables;
// var i = Math.floor(Math.random() * notables.length);
// Preview.Job.fallback = new Preview.Job.Name(notables[i].petName);
// if(!Preview.Job.current) {
// Preview.Job.fallback.setAsCurrent();
// }
// });
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
}
function loadFeature() {
$.getJSON("/donations/features", function (features) {
if (features.length > 0) {
var feature = features[Math.floor(Math.random() * features.length)];
Preview.Job.fallback = new Preview.Job.Feature(feature);
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
}
});
}
function loadFeature() {
$.getJSON("/donations/features", function (features) {
if (features.length > 0) {
var feature = features[Math.floor(Math.random() * features.length)];
Preview.Job.fallback = new Preview.Job.Feature(feature);
if (!Preview.Job.current) {
Preview.Job.fallback.setAsCurrent();
}
} else {
loadNotable();
}
});
}
loadFeature();
loadFeature();
Preview.Job = function (key, base) {
var job = this,
quality = 2;
job.loading = false;
Preview.Job = function (key, base) {
var job = this,
quality = 2;
job.loading = false;
function getImageSrc() {
if (base === "cp" || base === "cpn") {
return petImage(base + "/" + key, quality);
} else if (base === "url") {
return key;
} else {
throw new Error("unrecognized image base " + base);
}
}
function getImageSrc() {
if (key.substr(0, 3) === "a:-") {
// lol lazy code for prank image :P
// TODO: HTTPS?
return (
"https://swfimages.impress.openneo.net" +
"/biology/000/000/0-2/" +
key.substr(2) +
"/300x300.png"
);
} else if (base === "cp" || base === "cpn") {
return petImage(base + "/" + key, quality);
} else if (base === "url") {
return key;
} else {
throw new Error("unrecognized image base " + base);
}
}
function load() {
job.loading = true;
img_el.attr("src", getImageSrc());
}
function load() {
job.loading = true;
img_el.attr("src", getImageSrc());
}
this.increaseQualityIfPossible = function () {
if (quality == 2) {
quality = 4;
load();
}
};
this.increaseQualityIfPossible = function () {
if (quality == 2) {
quality = 4;
load();
}
};
this.setAsCurrent = function () {
Preview.Job.current = job;
load();
};
this.setAsCurrent = function () {
Preview.Job.current = job;
load();
};
this.notFound = function () {
Preview.notFound("pet-not-found");
};
};
this.notFound = function () {
Preview.notFound("pet-not-found");
};
};
Preview.Job.Name = function (name) {
this.name = name;
if (name.startsWith("@")) {
// This is an image hash "pet name".
Preview.Job.apply(this, [name.substr(1), "cp"]);
} else {
// This is a normal pet name.
Preview.Job.apply(this, [name, "cpn"]);
}
Preview.Job.Name = function (name) {
this.name = name;
Preview.Job.apply(this, [name, "cpn"]);
this.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();
};
};
this.visit = function () {
$(".main-pet-name").val(this.name).closest("form").submit();
};
};
Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]);
Preview.Job.Hash = function (hash, form) {
Preview.Job.apply(this, [hash, "cp"]);
this.visit = function () {
window.location =
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
this.visit = function () {
window.location =
"/wardrobe?color=" +
form.find(".color").val() +
"&species=" +
form.find(".species").val();
};
};
Preview.Job.Feature = function (feature) {
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
Preview.Job.Feature = function (feature) {
Preview.Job.apply(this, [feature.outfit_image_url, "url"]);
this.name = "Thanks for donating, " + feature.donor_name + "!"; // TODO: i18n
this.visit = function () {
window.location = "/donate";
};
this.visit = function () {
window.location = "/donate";
};
this.notFound = function () {
// The outfit thumbnail hasn't generated or is missing or something.
// Let's fall back to a boring image for now.
var boring = new Preview.Job.Feature({
donor_name: feature.donor_name,
outfit_image_url: defaultPreviewUrl,
});
boring.setAsCurrent();
};
};
this.notFound = function () {
// The outfit thumbnail hasn't generated or is missing or something.
// Let's fall back to a boring image for now.
var boring = new Preview.Job.Feature({
donor_name: feature.donor_name,
outfit_image_url: defaultPreviewUrl,
});
boring.setAsCurrent();
};
};
$(function () {
var previewWithNameTimeout;
$(function () {
var previewWithNameTimeout;
var name_el = $(".main-pet-name");
name_el.val(PetQuery.name);
Preview.updateWithName(name_el);
var name_el = $(".main-pet-name");
name_el.val(PetQuery.name);
Preview.updateWithName(name_el);
name_el.keyup(function () {
if (previewWithNameTimeout && Preview.Job.current) {
clearTimeout(previewWithNameTimeout);
Preview.Job.current.loading = false;
}
var name_el = $(this);
previewWithNameTimeout = setTimeout(function () {
Preview.updateWithName(name_el);
}, 250);
});
name_el.keyup(function () {
if (previewWithNameTimeout && Preview.Job.current) {
clearTimeout(previewWithNameTimeout);
Preview.Job.current.loading = false;
}
var name_el = $(this);
previewWithNameTimeout = setTimeout(function () {
Preview.updateWithName(name_el);
}, 250);
});
img_el
.load(function () {
if (Preview.Job.current.loading) {
Preview.Job.loading = false;
Preview.Job.current.increaseQualityIfPossible();
preview_el
.removeClass("loading")
.removeClass("hidden")
.addClass("loaded");
response_el.text(Preview.Job.current.name);
}
})
.error(function () {
if (Preview.Job.current.loading) {
Preview.Job.loading = false;
Preview.Job.current.notFound();
}
});
img_el
.load(function () {
if (Preview.Job.current.loading) {
Preview.Job.loading = false;
Preview.Job.current.increaseQualityIfPossible();
preview_el
.removeClass("loading")
.removeClass("hidden")
.addClass("loaded");
response_el.text(Preview.Job.current.name);
}
})
.error(function () {
if (Preview.Job.current.loading) {
Preview.Job.loading = false;
Preview.Job.current.notFound();
}
});
$(".species, .color").change(function () {
var type = {},
nameComponents = {};
var form = $(this).closest("form");
form.find("select").each(function () {
var el = $(this),
selectedEl = el.children(":selected"),
key = el.attr("name");
type[key] = selectedEl.val();
nameComponents[key] = selectedEl.text();
});
name = nameComponents.color + " " + nameComponents.species;
Preview.displayLoading();
$.ajax({
url:
"/species/" +
type.species +
"/colors/" +
type.color +
"/pet_type.json",
dataType: "json",
success: function (data) {
var job;
if (data) {
job = new Preview.Job.Hash(data.image_hash, form);
job.name = name;
job.setAsCurrent();
} else {
Preview.notFound("pet-type-not-found", {
color_name: nameComponents.color,
species_name: nameComponents.species,
});
}
},
});
});
$(".species, .color").change(function () {
var type = {},
nameComponents = {};
var form = $(this).closest("form");
form.find("select").each(function () {
var el = $(this),
selectedEl = el.children(":selected"),
key = el.attr("name");
type[key] = selectedEl.val();
nameComponents[key] = selectedEl.text();
});
name = nameComponents.color + " " + nameComponents.species;
Preview.displayLoading();
$.ajax({
url:
"/species/" +
type.species +
"/colors/" +
type.color +
"/pet_type.json",
dataType: "json",
success: function (data) {
var job;
if (data) {
job = new Preview.Job.Hash(data.image_hash, form);
job.name = name;
job.setAsCurrent();
} else {
Preview.notFound("pet-type-not-found", {
color_name: nameComponents.color,
species_name: nameComponents.species,
});
}
},
});
});
$(".load-pet-to-wardrobe").submit(function (e) {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
e.preventDefault();
Preview.Job.current.visit();
}
});
});
$(".load-pet-to-wardrobe").submit(function (e) {
if ($(this).find(".main-pet-name").val() === "" && Preview.Job.current) {
e.preventDefault();
Preview.Job.current.visit();
}
});
});
$("#latest-contribution-created-at").timeago();
$("#latest-contribution-created-at").timeago();
})();

View file

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

View file

@ -1,203 +1,110 @@
var DEBUG = document.location.search.substr(0, 6) == "?debug";
/* Needed items form */
(function () {
var UI = {};
UI.form = $("#needed-items-form");
UI.alert = $("#needed-items-alert");
UI.pet_name_field = $("#needed-items-pet-name-field");
UI.pet_thumbnail = $("#needed-items-pet-thumbnail");
UI.pet_header = $("#needed-items-pet-header");
UI.reload = $("#needed-items-reload");
UI.pet_items = $("#needed-items-pet-items");
UI.item_template = $("#item-template");
var current_request = { abort: function () {} };
function sendRequest(options) {
current_request = $.ajax(options);
}
function cancelRequest() {
if (DEBUG) console.log("Canceling request", current_request);
current_request.abort();
}
/* Pet */
var last_successful_pet_name = null;
function loadPet(pet_name) {
// If there is a request in progress, kill it. Our new pet request takes
// priority, and, if I submit a name while the previous name is loading, I
// don't want to process both responses.
cancelRequest();
sendRequest({
url: UI.form.attr("action") + ".json",
dataType: "json",
data: { name: pet_name },
error: petError,
success: function (data) {
petSuccess(data, pet_name);
},
complete: petComplete,
});
UI.form.removeClass("failed").addClass("loading-pet");
}
function petComplete() {
UI.form.removeClass("loading-pet");
}
function petError(xhr) {
UI.alert.text(xhr.responseText);
UI.form.addClass("failed");
}
function petSuccess(data, pet_name) {
last_successful_pet_name = pet_name;
UI.pet_thumbnail.attr("src", petThumbnailUrl(pet_name));
UI.pet_header.empty();
$("#needed-items-pet-header-template")
.tmpl({ pet_name: pet_name })
.appendTo(UI.pet_header);
loadItems(data.query);
}
function petThumbnailUrl(pet_name) {
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
/* Items */
function loadItems(query) {
UI.form.addClass("loading-items");
sendRequest({
url: "/items/needed.json",
dataType: "json",
data: query,
success: itemsSuccess,
});
}
function itemsSuccess(items) {
if (DEBUG) {
// The dev server is missing lots of data, so sends me 2000+ needed
// items. We don't need that many for styling, so limit it to 100 to make
// my browser happier.
items = items.slice(0, 100);
}
UI.pet_items.empty();
UI.item_template.tmpl(items).appendTo(UI.pet_items);
UI.form.removeClass("loading-items").addClass("loaded");
}
UI.form.submit(function (e) {
e.preventDefault();
loadPet(UI.pet_name_field.val());
});
UI.reload.click(function (e) {
e.preventDefault();
loadPet(last_successful_pet_name);
});
})();
/* Bulk pets form */
(function () {
var form = $("#bulk-pets-form"),
queue_el = form.find("ul"),
names_el = form.find("textarea"),
add_el = $("#bulk-pets-form-add"),
clear_el = $("#bulk-pets-form-clear"),
bulk_load_queue;
var form = $("#bulk-pets-form"),
queue_el = form.find("ul"),
names_el = form.find("textarea"),
add_el = $("#bulk-pets-form-add"),
clear_el = $("#bulk-pets-form-clear"),
bulk_load_queue;
$(document.body).addClass("js");
$(document.body).addClass("js");
bulk_load_queue = new (function BulkLoadQueue() {
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
var RECENTLY_SENT_MAX = 3;
var pets = [],
url = form.attr("action") + ".json",
recently_sent_count = 0,
loading = false;
function 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";
}
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name })
.appendTo(queue_el);
return "https://pets.neopets.com/cpn/" + pet_name + "/1/1.png";
}
this.load = function () {
el.removeClass("waiting").addClass("loading");
var response_el = el.find("span.response");
pets.shift();
loading = true;
$.ajax({
complete: function (data) {
loading = false;
loadNextIfReady();
},
data: { name: name },
dataType: "json",
error: function (xhr) {
el.removeClass("loading").addClass("failed");
response_el.text(xhr.responseText);
},
success: function (data) {
var points = data.points;
el.removeClass("loading").addClass("loaded");
$("#bulk-pets-submission-success-template")
.tmpl({ points: points })
.appendTo(response_el);
},
type: "post",
url: url,
});
bulk_load_queue = new (function BulkLoadQueue() {
var RECENTLY_SENT_INTERVAL_IN_SECONDS = 30;
var RECENTLY_SENT_MAX = 3;
var pets = [],
url = form.attr("action") + ".json",
recently_sent_count = 0,
loading = false;
recently_sent_count++;
setTimeout(function () {
recently_sent_count--;
loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
function Pet(name) {
var el = $("#bulk-pets-submission-template")
.tmpl({ pet_name: name, pet_thumbnail: petThumbnailUrl(name) })
.appendTo(queue_el);
this.add = function (name) {
name = name.replace(/^\s+|\s+$/g, "");
if (name.length) {
var pet = new Pet(name);
pets.push(pet);
if (pets.length == 1) loadNextIfReady();
}
};
this.load = function () {
el.removeClass("waiting").addClass("loading");
var response_el = el.find("span.response");
pets.shift();
loading = true;
$.ajax({
beforeSend: (xhr) => {
const token = document
.querySelector('meta[name="csrf-token"]')
?.getAttribute("content");
xhr.setRequestHeader("X-CSRF-Token", token);
},
complete: function (data) {
loading = false;
loadNextIfReady();
},
data: { name: name },
dataType: "json",
error: function (xhr) {
el.removeClass("loading").addClass("failed");
response_el.text(xhr.responseText);
},
success: function (data) {
var points = data.points;
el.removeClass("loading").addClass("loaded");
$("#bulk-pets-submission-success-template")
.tmpl({ points: points })
.appendTo(response_el);
},
type: "post",
url: url,
});
function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load();
}
}
})();
recently_sent_count++;
setTimeout(function () {
recently_sent_count--;
loadNextIfReady();
}, RECENTLY_SENT_INTERVAL_IN_SECONDS * 1000);
};
}
names_el.keyup(function () {
var names = this.value.split("\n"),
x = names.length - 1,
i,
name;
for (i = 0; i < x; i++) {
bulk_load_queue.add(names[i]);
}
this.value = x >= 0 ? names[x] : "";
});
this.add = function (name) {
name = name.replace(/^\s+|\s+$/g, "");
if (name.length) {
var pet = new Pet(name);
pets.push(pet);
if (pets.length == 1) loadNextIfReady();
}
};
add_el.click(function () {
bulk_load_queue.add(names_el.val());
names_el.val("");
});
function loadNextIfReady() {
if (!loading && recently_sent_count < RECENTLY_SENT_MAX && pets.length) {
pets[0].load();
}
}
})();
clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove();
});
names_el.keyup(function () {
var names = this.value.split("\n"),
x = names.length - 1,
i,
name;
for (i = 0; i < x; i++) {
bulk_load_queue.add(names[i]);
}
this.value = x >= 0 ? names[x] : "";
});
add_el.click(function () {
bulk_load_queue.add(names_el.val());
names_el.val("");
});
clear_el.click(function () {
queue_el.children("li.loaded, li.failed").remove();
});
})();

View file

@ -25,6 +25,10 @@ let numFramesSinceLastLog = 0;
// State for error reporting.
let hasLoggedRenderError = false;
////////////////////////////////////////////////////
//////// Loading the library and its assets ////////
////////////////////////////////////////////////////
function loadImage(src) {
const image = new Image();
image.crossOrigin = "anonymous";
@ -64,8 +68,8 @@ async function 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?
// NOTE: We also read these from the manifest, and include them in the
// document as preload meta tags, to get them moving faster.
const librarySrcDir = libraryUrl.split("/").slice(0, -1).join("/");
const manifestImages = new Map(
library.properties.manifest.map(({ id, src }) => [
@ -96,6 +100,10 @@ async function getLibrary() {
return library;
}
/////////////////////////////////////
//////// Rendering the movie ////////
/////////////////////////////////////
function buildMovieClip(library) {
let constructorName;
try {
@ -151,6 +159,22 @@ function updateCanvasDimensions() {
movieClip.scaleY = internalHeight / library.properties.height;
}
window.addEventListener("resize", () => {
updateCanvasDimensions();
// Redraw the stage with the new dimensions - but with `tickOnUpdate` set
// to `false`, so that we don't advance by a frame. This keeps us
// really-paused if we're paused, and avoids skipping ahead by a frame if
// we're playing.
stage.tickOnUpdate = false;
updateStage();
stage.tickOnUpdate = true;
});
////////////////////////////////////////////////////
//// Monitoring and controlling animation state ////
////////////////////////////////////////////////////
async function startMovie() {
// Load the movie's library (from the JS file already run), and use it to
// build a movie clip.
@ -274,6 +298,10 @@ function getInitialPlayingStatus() {
}
}
//////////////////////////////////////////
//// Syncing with the parent document ////
//////////////////////////////////////////
/**
* Recursively scans the given MovieClip (or child createjs node), to see if
* there are any animated areas.
@ -312,18 +340,6 @@ 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
@ -339,6 +355,10 @@ window.addEventListener("message", ({ data }) => {
}
});
/////////////////////////////////
//// The actual entry point! ////
/////////////////////////////////
startMovie()
.then(() => {
sendStatus();

View file

@ -1,24 +0,0 @@
@import "partials/campaign-progress"
body.items-index, body.items-show, body.items-needed, body.item_trades
+campaign-progress
text-align: center
input[type=text]
font-size: 125%
width: 15em
h1
margin-bottom: 1em
img
height: 80px
margin-bottom: -0.5em
width: 80px
a
text-decoration: none
span
text-decoration: underline
&:hover span
text-decoration: none

View file

@ -1,10 +1,6 @@
@import "partials/icon"
@import "partials/clean/constants"
@import "partials/clean/mixins"
@import fonts
@import url("https://fonts.googleapis.com/css?family=Droid+Sans:400,700")
@import url("https://fonts.googleapis.com/css?family=Droid+Serif:400,700,400italic")
@import url("https://fonts.googleapis.com/css?family=Calligraffitti")
/* Reset
@ -36,9 +32,6 @@ body
a[href]
color: $link-color
p
font-family: $text-font
input, button, select
font:
family: inherit
@ -81,7 +74,7 @@ $container_width: 800px
input, button, select, label
cursor: pointer
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], select, textarea
input[type=text], input[type=password], input[type=search], input[type=number], input[type=email], input[type=url], select, textarea
border-radius: 3px
background: #fff
border: 1px solid $input-border-color
@ -90,6 +83,15 @@ input[type=text], input[type=password], input[type=search], input[type=number],
&:focus, &:active
color: inherit
select:has(option[value='']:checked)
color: #666
option[value='']
color: #666
option:not([value=''])
color: $text-color
textarea
font: inherit
@ -250,23 +252,3 @@ dd
margin: 0 .5em
.current
font-weight: bold
/* Fonts
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
@font-face
font-family: Delicious
src: local("Delicious"), font-url("Delicious-Roman.otf")
@font-face
font-family: Delicious
font-weight: bold
src: local("Delicious"), font-url("Delicious-Bold.otf")
@font-face
font-family: Delicious
font-style: italic
src: local("Delicious"), font-url("Delicious-Italic.otf")

View file

@ -0,0 +1,22 @@
body.use-responsive-design
#container
max-width: 100%
padding-inline: 1rem
box-sizing: border-box
padding-top: 0
#main-nav
display: flex
flex-wrap: wrap
#home-link, #userbar
position: static
#home-link
padding-inline: .5rem
margin-inline: -.5rem
margin-right: auto
#userbar
margin-left: auto
text-align: right

View file

@ -1,18 +0,0 @@
body.alt_styles-index
.alt-styles-header
margin-top: 1em
margin-bottom: .5em
.alt-styles-list
list-style: none
display: flex
flex-wrap: wrap
gap: 1.5em
.alt-style
text-align: center
width: 80px
.alt-style-thumbnail
width: 80px
height: 80px

View file

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

View file

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

View file

@ -4,19 +4,14 @@
@import partials/clean/mixins
@import layout
@import responsive
@import partials/jquery.jgrowl
@import alt_styles/index
@import closet_hangers/index
@import closet_hangers/petpage
@import closet_lists/form
@import neopets_page_import_tasks/new
@import contributions/index
@import items
@import items/index
@import items/show
@import item_trades/index
@import outfits/index
@import outfits/new
@import pets/bulk

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -31,11 +31,14 @@ body.closet_hangers-index
color: $soft-text-color
margin-bottom: 1em
margin-left: 2em
min-height: image-height("neomail.png")
min-height: $icon-height
display: flex
gap: .5em
align-items: center
a
color: inherit
margin-right: .5em
text-decoration: none
&:hover
text-decoration: underline
@ -44,13 +47,14 @@ body.closet_hangers-index
background:
position: left center
repeat: no-repeat
padding-left: image-width("neomail.png") + 4px
a.neomail, > form
background-image: image-url("neomail.png")
padding-left: $icon-width + 4px
a.lookup
background-image: image-url("lookup.png")
padding-left: $icon-width + 4px
select
width: 10em

View file

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

View file

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

View file

@ -0,0 +1,17 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl */
@font-face {
font-family: Delicious;
src: local("Delicious"), url("<%= font_path "Delicious-Roman.otf" %>");
}
@font-face {
font-family: Delicious;
font-weight: bold;
src: local("Delicious"), url("<%= font_path "Delicious-Bold.otf" %>");
}
@font-face {
font-family: Delicious;
font-style: italic;
src: local("Delicious"), url("<%= font_path "Delicious-Italic.otf" %>");
}

View file

@ -1,14 +0,0 @@
/* A font by Jos Buivenga (exljbris) -> www.exljbris.nl
@font-face
font-family: Delicious
src: local("Delicious"), font-url("Delicious-Roman.otf")
@font-face
font-family: Delicious
font-weight: bold
src: local("Delicious"), font-url("Delicious-Bold.otf")
@font-face
font-family: Delicious
font-style: italic
src: local("Delicious"), font-url("Delicious-Italic.otf")

View file

@ -1,29 +0,0 @@
@import "../partials/item_header"
body.item_trades-index
.item-header
+item-header
.item-subpage-title
text-align: left
margin-bottom: .5em
.trades-table
text-align: left
width: 100%
table-layout: fixed
th, td
&:nth-child(1), &:nth-child(2)
width: 15ch
overflow: hidden
text-overflow: ellipsis
.trade-list-names
list-style: none
li
display: inline
&:not(:last-child)::after
content: ", "

View file

@ -0,0 +1,28 @@
@import "../partials/item_header"
.item-header
+item-header
.item-subpage-title
text-align: left
margin-bottom: .5em
.trades-table
text-align: left
width: 100%
table-layout: fixed
th, td
&:nth-child(1), &:nth-child(2)
width: 15ch
overflow: hidden
text-overflow: ellipsis
.trade-list-names
list-style: none
li
display: inline
&:not(:last-child)::after
content: ", "

View file

@ -1,25 +0,0 @@
=main_unit
float: left
width: 49%
h2
font-size: 125%
body.items-index
form
margin-bottom: 2em
#search-info
+main_unit
padding-right: 1%
dl
text-align: left
dd
margin-bottom: 1em
#species-search-links
+main_unit
padding-left: 1%
img
height: 80px
width: 80px

View file

@ -1,162 +0,0 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/item_header"
body.items-show
.item-header
+item-header
#item-contributors
+subtle-banner
clear: both
margin:
bottom: 0
top: 2em
header
display: inline
font-weight: bold
margin-right: .25em
footer
display: inline
ul
display: inline
list-style: none
li
display: inline
&::after
content: ", "
&:last-child::after
content: "."
.nc-icon
height: 16px
width: 16px
outfit-viewer
display: block
position: relative
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
overflow: hidden
margin: 0 auto .75em
// 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 only
// apply the delay here, because fading *out* on load should be instant.)
// We are loading when the <turbo-frame> is busy, or when at least one layer
// is loading.
#item-preview[busy] outfit-viewer, outfit-viewer:has(outfit-layer:state(loading))
cursor: wait
.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
&[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color

View file

@ -0,0 +1,23 @@
=main_unit
float: left
width: 49%
h2
font-size: 125%
form
margin-bottom: 2em
#search-info
+main_unit
padding-right: 1%
dl
text-align: left
dd
margin-bottom: 1em
#species-search-links
+main_unit
padding-left: 1%
img
height: 80px
width: 80px

View file

@ -0,0 +1,309 @@
@import "../partials/clean/constants"
@import "../partials/clean/mixins"
@import "../partials/item_header"
@import "../application/outfit-viewer"
#container
width: 900px // A bit more generous to the preview area!
.item-header
+item-header
#item-contributors
+subtle-banner
clear: both
margin:
bottom: 0
top: 2em
header
display: inline
font-weight: bold
margin-right: .25em
footer
display: inline
ul
display: inline
list-style: none
li
display: inline
&::after
content: ", "
&:last-child::after
content: "."
.nc-icon
height: 16px
width: 16px
.preview-area
margin: 0 auto
position: relative
.customize-more
position: absolute
top: 1em
right: 1em
display: flex
align-items: center
text-decoration: none
background: #EDF2F7
padding-inline: .75em
border-radius: .375em
min-height: 2rem
min-width: 2rem
box-sizing: border-box
.customize-more-label
width: 0
overflow: hidden
transition: width .25s
white-space: nowrap
--natural-width: auto
measured-content
padding-right: .5em
&:hover, &:focus
// Expand the label to its natural width. If the JS ran to tell us
// what it is in px, we can use that for a smooth transition. If not,
// okay, we just pop out to `auto`, which CSS can't make smooth.
.customize-more-label
width: var(--natural-width)
outfit-viewer
width: 300px
height: 300px
border: 1px solid $module-border-color
border-radius: 1em
.error-indicator
font-size: 85%
color: $error-color
margin-top: .25em
margin-bottom: .5em
display: none
// When loading, fade in the loading spinner after a brief delay. We are
// loading when the <turbo-frame> is busy, or when at least one layer
// is loading.
//
// We only apply the delay here, not on the base styles, because fading
// *out* on load should be instant.
#item-preview[busy] outfit-viewer
+outfit-viewer-loading
#item-preview:has(outfit-layer:state(error))
outfit-viewer
border: 2px solid red
.error-indicator
display: block
species-color-picker
.error-icon
cursor: help
margin-right: .25em
form[data-is-valid="false"]
select
border-color: $error-border-color
color: $error-color
// If JS is enabled, but auto-loading isn't ready yet (script loading or
// failed?), hide the submit button for .75sec, to give it time to load.
@media (scripting: enabled)
input[type=submit]
position: absolute
margin-left: .5em
opacity: 0
animation: fade-in .25s forwards
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
input[type=submit]
display: none
species-face-picker
display: block
position: relative
margin-top: -10px
species-face-picker-options
display: flex
justify-content: center
flex-wrap: wrap
isolation: isolate // avoid z-index conflicts between pets and noscript
overflow: auto
max-height: 200px // 4 rows of 50px images, and padding will offer a hint of below
padding: 10px // leave enough room for the zoomed-in selected face
img
width: 54px
height: 54px
transition: all 0.2s
// Calm down the default color, just a smidge! There's a lot of color
// on this page already, y'know?
opacity: .9
filter: saturate(90%)
label
display: flex
overflow: hidden
transition: all 0.2s
position: relative
line-height: 1
// NOTE: The box-shadows here were copy-pasted from Impress 2020, which uses
// Chakra UI's styling system to generate them! (The colors are from their
// color palette, too.)
&:has(input:checked)
border-radius: 6px
z-index: 1
background: #9AE6B4
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #2F855A 0 0 2px 2px
transform: scale(1.1)
&:has(input:focus)
background: #BEE3F8
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1),0 10px 10px -5px rgba(0, 0, 0, 0.04), #4299e1 0 0 0 3px
transform: scale(1.2)
input[type=radio]
position: absolute
left: -10000px
top: auto
width: 1px
height: 1px
overflow: hidden
&:checked + img
opacity: 1
filter: saturate(110%)
&:disabled + img
opacity: .6
filter: saturate(0%)
label:has(input[type=radio]:disabled)
cursor: not-allowed
noscript
position: absolute
inset: 0
padding: 1em
background: rgba(white, .8)
z-index: 1
cursor: auto
display: flex
align-items: center
justify-content: center
text-align: center
&:has(species-face-picker-options[inert])
cursor: wait
.item-preview-meta-info
display: grid
grid-template-columns: 1fr auto
gap: .5em
align-items: center
.item-zones-info
h3
display: inline
font: inherit
font-weight: bold
&:after
content: ": "
ul
list-style-type: none
display: inline
li
display: inline
&:not(:last-of-type):after
content: ", "
.no-zones
font-style: italic
opacity: .85
.zone-species-info
font-style: italic
text-decoration: underline dotted
// Many of these styles copied from Impress 2020 and its Chakra UI styles!
.item-html5-info
display: flex
align-items: center
border: 1px solid
border-radius: .375em
padding: 4px 8px
min-height: 30px
box-sizing: border-box
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px
&[data-status=converted]
background: $module-bg-color
color: $text-color
svg:nth-of-type(2)
margin-right: -4px // spacing hacks!
&[data-status=unconverted]
background: $warning-bg-color
color: #975A16
gap: .25em // spacing hacks!
svg:first-of-type
width: 12px
height: 12px
svg:nth-of-type(2)
width: 20px
height: 20px
#item-preview
display: flex
flex-direction: column
gap: .75em
@media (min-width: 700px)
display: grid
grid-template-areas: "viewer faces" "picker meta"
gap: .5em
.preview-area
grid-area: viewer
outfit-viewer
width: 380px
height: 380px
species-color-picker
grid-area: picker
species-face-picker
grid-area: faces
species-face-picker-options
max-height: 380px
.item-preview-meta-info
grid-area: meta
@keyframes fade-in
from
opacity: 0
to
opacity: 1

View file

@ -0,0 +1,28 @@
@import "partials/campaign-progress"
body
+campaign-progress
text-align: center
.item-search-form
display: flex
gap: .5em
justify-content: center
input[type=text]
font-size: 125%
width: 15em
flex: 0 1 auto
h1
margin-bottom: 1em
img
height: 80px
margin-bottom: -0.5em
width: 80px
a
text-decoration: none
span
text-decoration: underline
&:hover span
text-decoration: none

View file

@ -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
@ -82,85 +78,57 @@ body.outfits-new
font-size: 175%
select
font-size: 120%
#description, #top-contributors
float: left
#description
margin-right: 2%
width: 64%
#top-contributors
border: 1px solid $input-border-color
margin-top: 1em
padding: 1%
width: 30%
ol
margin-left: 2em
padding-left: 1em
> a
font-size: 80%
display: block
text-align: right
#how-can-i-help, #i-found-something
+module
float: left
padding: 1%
width: 46%
h2
font-style: italic
input, button
font-size: 115%
input[type=text]
border-color: $module-border-color
width: 12em
#how-can-i-help
margin-right: 1%
#i-found-something
margin-left: 1%
a
float: right
font-size: 87.5%
margin-top: 1em
$section-count: 3
$section-border-width: 1px
$section-padding: 0.5em
$section-width: 100% / $section-count
// (A - (B-1)*C) / B
#sections
+clearfix
display: table
display: grid
grid-template-columns: 1fr 1fr 1fr
list-style: none
margin-top: 1em
h3
margin-bottom: .25em
li
border-left:
color: $module-border-color
style: solid
width: $section-border-width
display: table-cell
padding: $section-padding
position: relative
width: $section-width
&:first-child
border-left: 0
display: grid
grid-template-areas: "header image" "info image" "form form"
grid-template-rows: auto auto auto
row-gap: .5em
padding: 0.5em
&:not(:first-child)
border-left: 1px solid $module-border-color
h3
grid-area: header
margin-bottom: 0
div
grid-area: info
color: $soft-text-color
font-size: 75%
margin-left: 1em
z-index: 2
h4, input
strong
font-size: 116%
h4, input[type=text]
color: inherit
h4 a
background: #ffffc0
a:has(img)
grid-area: image
img
+opacity(0.75)
opacity: 0.75
float: right
margin-left: .5em
&:hover
+opacity(1)
opacity: 1
p
line-height: 1.5
min-height: 4.5em
margin-bottom: 0
form
grid-area: form
display: flex
align-items: center
gap: .5em
font-size: .85em
margin-left: 1em
margin-right: .5em
input[type=text], input[type=search]
// TODO: It doesn't make sense to me that this is the right style? I
// expected `flex: 1 0 0` to be right, but that grew *too* large, and
// forced the sections to grow wider too. I also tried `flex: 0 1 100%`,
// which I would have *thought* is the same as this, but isn't! Idk!
width: 100%
#whats-new
margin-bottom: 1em
@ -329,4 +297,3 @@ body.outfits-new
#latest-contribution-created-at
color: $soft-text-color
margin-left: .5em

View file

@ -1,29 +0,0 @@
// Used internally:
$background_color: #0b61a4
$module_border_color: #033e6b
$module_background_color: #66a3d2
$input_hover_border_color: #ff9200
$input_focus_border_color: #fff
$loud_button_background_color: #ff9200
$loud_button_border_color: #ffad40
$loud_button_color: #a65f00
$loud_button_focus_border_color: #000
// Used by Blueprint:
$font_color: #fff
$header_color: inherit
$link_color: inherit
$link_hover_color: inherit
$link_focus_color: inherit
$link_active_color: inherit
$link_visited_color: inherit
$error_color: inherit
$error_bg_color: #e14f1c
$error_border_color: #cd0a0a

View file

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

View file

@ -35,6 +35,7 @@
text-align: left
display: flex
align-items: center
flex-wrap: wrap
gap: 1em
abbr
@ -66,14 +67,21 @@
background: #FEEBC8
color: #7B341E
.support-form
grid-area: support
font-size: 85%
text-align: left
.user-lists-info
grid-area: lists
font-size: 85%
text-align: left
.user-lists-form-opener
&::after
content: " "
display: flex
gap: 1em
a::after
content: " "
.user-lists-form
background: $background-color
@ -127,6 +135,7 @@
.item-subpages-nav
display: flex
align-items: flex-end
gap: 1em
.preview-link
margin-right: auto
@ -167,4 +176,4 @@
background: $background-color
padding-bottom: calc(.5em + 1px)
font-weight: bold
margin-bottom: -1px
margin-bottom: -1px

View file

@ -18,9 +18,8 @@ $error-color: #8a1f11
$error-bg-color: #fbe3e4
$error-border-color: #fbc2c4
$header-font: Delicious, Helvetica, Arial, Verdana, sans-serif
$main-font: "Droid Sans", Helvetica, Arial, Verdana, sans-serif
$text-font: "Droid Serif", Georgia, "Times New Roman", Times, serif
$header-font: Delicious, system-ui, sans-serif
$main-font: system-ui, sans-serif
$object-img-size: 80px
$object-width: 100px

View file

@ -0,0 +1,15 @@
support-outfit-viewer
margin-block: 1em
.fields li[data-type=radio-grid]
--num-columns: 3
.reference-link
display: flex
align-items: center
gap: .5em
padding-inline: .5em
img
height: 2em
width: auto

View file

@ -0,0 +1,85 @@
@import "../partials/clean/constants"
support-outfit-viewer
display: flex
gap: 2em
flex-wrap: wrap
justify-content: center
outfit-viewer
flex: 0 0 auto
border: 1px solid $module-border-color
border-radius: 1em
.outfit-viewer-controls
margin-block: .5em
isolation: isolate // Avoid z-index weirdness with our buttons vs the lens
display: flex
align-items: center
justify-content: center
gap: .5em
font-size: .85em
fieldset
display: contents
legend
font-weight: bold
&::after
content: ":"
label
display: flex
align-items: center
gap: .25em
input[type=radio]
margin: 0
.outfit-viewer-area
> [data-format=png]
display: none
&:has(input[value=png]:checked)
.outfit-viewer-area
> [data-format=svg]
display: none
> [data-format=png]
display: block
> table
flex: 0 0 auto
border-collapse: collapse
table-layout: fixed
border-radius: .5em
th, td
border: 1px solid $module-border-color
font-size: .85em
padding: .25em .5em
text-align: left
> tbody
[data-field=links]
ul
list-style-type: none
display: flex
gap: .5em
// Once the component is ready, add some hints about potential interactions.
&:state(ready)
> table
> tbody > tr
cursor: zoom-in
&:hover
background: $module-bg-color
magic-magnifier
--magic-magnifier-lens-width: 100px
--magic-magnifier-lens-height: 100px
--magic-magnifier-scale: 2.5
magic-magnifier-lens
z-index: 2 // Be above things by default, but not by much!

View file

@ -0,0 +1,8 @@
@import "../partials/clean/constants"
.rainbow-pool-list
--preview-base-width: 200px
margin-bottom: 2em
.glitched
cursor: help

View file

@ -2,70 +2,8 @@
@import "../partials/clean/mixins"
body.pets-bulk
#needed-items-form, #bulk-pets-form
text-align: center
#needed-items-form
#needed-items-pet
border-top: 1px solid $soft-border-color
display: none
margin-top: 1em
padding-top: 1em
h4
font-size: 150%
margin-bottom: .5em
#needed-items-reload
+inline-block
font-size: 12px
margin-left: 1em
vertical-align: middle
#needed-items-alert
display: none
margin-top: .5em
#needed-items-pet-thumbnail
height: 50px
width: 50px
#needed-items-pet-items
li.owned
background: $module-bg-color
border: 1px solid $module-border-color
.object-owned
color: $soft-text-color
display: block
font-size: 75%
font-style: italic
padding-bottom: .25em
&.loading-pet, &.loading-items
#needed-items-pet-name-field
background:
image: image-url("loading.gif")
position: center right
repeat: no-repeat
#needed-items-pet-items
+opacity(.50)
&.loading-pet
#needed-items-pet h4
+opacity(.50)
&.loaded
#needed-items-pet
display: block
&.failed
#needed-items-alert
display: block
#bulk-pets-form
border-top: 1px solid $module-border-color
text-align: center
margin-top: 12px
padding-top: 12px

View file

@ -1,23 +1,35 @@
class AltStylesController < ApplicationController
before_action :support_staff_only, except: [:index]
def index
@alt_styles = AltStyle.includes(:species, :color, :swf_assets).
order(:species_id, :color_id)
@all_series_names = AltStyle.all_series_names
@all_color_names = AltStyle.all_supported_colors.map(&:human_name).sort
@all_species_names = AltStyle.all_supported_species.map(&:human_name).sort
if params[:species_id]
@species = Species.find(params[:species_id])
@alt_styles = @alt_styles.merge(@species.alt_styles)
end
@series_name = params[:series]
@color = find_color
@species = find_species
# We're going to link to the HTML5 image URL, so make sure we have all the
@alt_styles = AltStyle.includes(:color, :species, :swf_assets)
@alt_styles.where!(series_name: @series_name) if @series_name.present?
@alt_styles.merge!(@color.alt_styles) if @color
@alt_styles.merge!(@species.alt_styles) if @species
# We're using the HTML5 image for our preview, so make sure we have all the
# manifests ready!
SwfAsset.preload_manifests @alt_styles.map(&:swf_assets).flatten
respond_to do |format|
format.html { render }
format.html {
@alt_styles = @alt_styles.
by_creation_date.order(:color_id, :species_id, :series_name).
paginate(page: params[:page], per_page: 30)
render
}
format.json {
render json: @alt_styles.includes(swf_assets: [:zone]).as_json(
only: [:id, :species_id, :color_id, :body_id, :series_name,
:adjective_name, :thumbnail_url],
@alt_styles = @alt_styles.includes(swf_assets: [:zone]).by_name_grouped
render json: @alt_styles.as_json(
only: [:id, :species_id, :color_id, :body_id, :thumbnail_url],
include: {
swf_assets: {
only: [:id, :body_id],
@ -25,9 +37,62 @@ class AltStylesController < ApplicationController
methods: [:urls, :known_glitches],
}
},
methods: [:series_name, :adjective_name, :thumbnail_url],
methods: [:series_main_name, :adjective_name],
)
}
end
end
def edit
@alt_style = AltStyle.find params[:id]
end
def update
@alt_style = AltStyle.find params[:id]
if @alt_style.update(alt_style_params)
flash[:notice] = "\"#{@alt_style.full_name}\" successfully saved!"
redirect_to destination_after_save
else
render action: :edit, status: :bad_request
end
end
protected
def alt_style_params
params.require(:alt_style).
permit(:real_series_name, :real_full_name, :thumbnail_url)
end
def find_color
if params[:color]
Color.find_by(name: params[:color])
end
end
def find_species
if params[:species_id]
Species.find_by(id: params[:species_id])
elsif params[:species]
Species.find_by(name: params[:species])
end
end
def destination_after_save
if params[:next] == "unlabeled-style"
next_unlabeled_style_path
else
alt_styles_path
end
end
def next_unlabeled_style_path
unlabeled_style = AltStyle.unlabeled.newest.first
if unlabeled_style
edit_alt_style_path(unlabeled_style, next: "unlabeled-style")
else
alt_styles_path
end
end
end

View file

@ -2,12 +2,10 @@ require 'async'
require 'async/container'
class ApplicationController < ActionController::Base
include FragmentLocalization
protect_from_forgery
helper_method :current_user, :user_signed_in?
helper_method :current_user, :support_staff?, :user_signed_in?
before_action :set_locale
before_action :configure_permitted_parameters, if: :devise_controller?
@ -23,9 +21,12 @@ class ApplicationController < ActionController::Base
class AccessDenied < StandardError; end
rescue_from AccessDenied, with: :on_access_denied
rescue_from Async::Stop, Async::Container::Terminate,
with: :on_request_stopped
rescue_from ActiveRecord::ConnectionTimeoutError, with: :on_db_timeout
def authenticate_user!
redirect_to(new_auth_user_session_path) unless user_signed_in?
end
@ -45,15 +46,15 @@ class ApplicationController < ActionController::Base
def user_signed_in?
auth_user_signed_in?
end
def infer_locale
return params[:locale] if valid_locale?(params[:locale])
return cookies[:locale] if valid_locale?(cookies[:locale])
Rails.logger.debug "Preferred languages: #{http_accept_language.user_preferred_languages}"
http_accept_language.language_region_compatible_from(I18n.public_locales.map(&:to_s)) ||
http_accept_language.language_region_compatible_from(I18n.available_locales.map(&:to_s)) ||
I18n.default_locale
end
def not_found(record_name='record')
raise ActionController::RoutingError.new("#{record_name} not found")
end
@ -67,6 +68,11 @@ class ApplicationController < ActionController::Base
status: :internal_server_error
end
def on_db_timeout
render file: 'public/503.html', layout: false,
status: :service_unavailable
end
def redirect_back!(default=:back)
redirect_to(params[:return_to] || default)
end
@ -76,7 +82,7 @@ class ApplicationController < ActionController::Base
end
def valid_locale?(locale)
locale && I18n.usable_locales.include?(locale.to_sym)
locale && I18n.available_locales.include?(locale.to_sym)
end
def configure_permitted_parameters
@ -104,5 +110,13 @@ class ApplicationController < ActionController::Base
Rails.logger.debug "Using return_to path: #{return_to.inspect}"
return_to || root_path
end
def support_staff?
current_user&.support_staff?
end
def support_staff_only
raise AccessDenied, "Support staff only" unless support_staff?
end
end

View file

@ -218,8 +218,12 @@ class ClosetHangersController < ApplicationController
def enforce_shadowban
# If this user is shadowbanned, and this *doesn't* seem to be a request
# from that user, render the 404 page.
if @user.shadowbanned? && !@user.likely_is?(current_user, request.remote_ip)
render file: "public/404.html", layout: false, status: :not_found
if @user.shadowbanned?
can_see = support_staff? ||
@user.likely_is?(current_user, request.remote_ip)
if !can_see
render file: "public/404.html", layout: false, status: :not_found
end
end
end

View file

@ -1,5 +1,6 @@
class ItemsController < ApplicationController
before_action :set_query
before_action :support_staff_only, except: [:index, :show, :sources]
rescue_from Item::Search::Error, :with => :search_error
def index
@ -28,6 +29,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: {
@ -89,6 +96,14 @@ class ItemsController < ApplicationController
worn_items: [@item],
)
@preview_error = validate_preview
@all_appearances = @item.appearances
@appearances_by_occupied_zone_label =
@item.appearances_by_occupied_zone_label.sort_by { |l, a| l }
@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
@ -98,24 +113,18 @@ class ItemsController < ApplicationController
end
end
def needed
if params[:color] && params[:species]
@pet_type = PetType.find_by_color_id_and_species_id(
params[:color],
params[:species]
)
end
unless @pet_type
raise ActiveRecord::RecordNotFound, 'Pet type not found'
end
@items = @pet_type.needed_items.order(:name)
assign_closeted!(@items)
respond_to do |format|
format.html { @pet_name = params[:name] ; render :layout => 'application' }
format.json { render :json => @items }
def edit
@item = Item.find params[:id]
render layout: "application"
end
def update
@item = Item.find params[:id]
if @item.update(item_params)
flash[:notice] = "\"#{@item.name}\" successfully saved!"
redirect_to @item
else
render action: "edit", layout: "application", status: :bad_request
end
end
@ -171,6 +180,15 @@ class ItemsController < ApplicationController
protected
def item_params
params.require(:item).permit(
:name, :thumbnail_url, :description, :modeling_status_hint,
:is_manually_nc, :explicitly_body_specific,
).tap do |p|
p[:modeling_status_hint] = nil if p[:modeling_status_hint] == ""
end
end
def assign_closeted!(items)
current_user.assign_closeted_to_items!(items) if user_signed_in?
end
@ -222,7 +240,8 @@ class ItemsController < ApplicationController
@item.compatible_pet_types.
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
preferring_simple.first
preferring_simple.first ||
PetType.matching_name("Blue", "Acara").first!
end
def validate_preview

View file

@ -47,29 +47,24 @@ class OutfitsController < ApplicationController
end
def new
@colors = Color.funny.alphabetical
@colors = Color.alphabetical
@species = Species.alphabetical
# HACK: Skip this in development, because it's slow!
unless Rails.env.development?
newest_items = Item.newest.
select(:id, :name, :updated_at, :thumbnail_url, :rarity_index, :is_manually_nc)
.limit(18)
@newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?)
newest_items = Item.newest.limit(18)
@newest_modeled_items, @newest_unmodeled_items =
newest_items.partition(&:predicted_fully_modeled?)
@newest_unmodeled_items_predicted_missing_species_by_color = {}
@newest_unmodeled_items_predicted_modeled_ratio = {}
@newest_unmodeled_items.each do |item|
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
standard_body_ids_by_species = item.
predicted_missing_standard_body_ids_by_species
if standard_body_ids_by_species.present?
h[:standard] = standard_body_ids_by_species
end
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
@newest_unmodeled_items_predicted_missing_species_by_color = {}
@newest_unmodeled_items_predicted_modeled_ratio = {}
@newest_unmodeled_items.each do |item|
h = item.predicted_missing_nonstandard_body_ids_by_species_by_color
standard_body_ids_by_species = item.
predicted_missing_standard_body_ids_by_species
if standard_body_ids_by_species.present?
h[:standard] = standard_body_ids_by_species
end
@newest_unmodeled_items_predicted_missing_species_by_color[item] = h
@newest_unmodeled_items_predicted_modeled_ratio[item] = item.predicted_modeled_ratio
end
@species_count = Species.count

View file

@ -0,0 +1,56 @@
class PetStatesController < ApplicationController
before_action :support_staff_only
before_action :find_pet_state
before_action :preload_assets
def edit
end
def update
if @pet_state.update(pet_state_params)
flash[:notice] = "Pet appearance \##{@pet_state.id} successfully saved!"
redirect_to destination_after_save
else
render action: :edit, status: :bad_request
end
end
protected
def find_pet_state
@pet_type = PetType.find_by_param!(params[:pet_type_name])
@pet_state = @pet_type.pet_states.find(params[:id])
@reference_pet_type = @pet_type.reference
end
def preload_assets
SwfAsset.preload_manifests @pet_state.swf_assets
end
def pet_state_params
params.require(:pet_state).permit(:pose, :glitched)
end
def destination_after_save
if params[:next] == "unlabeled-appearance"
next_unlabeled_appearance_path
else
@pet_type
end
end
def next_unlabeled_appearance_path
unlabeled_appearance =
PetState.next_unlabeled_appearance(after_id: params[:after])
if unlabeled_appearance
edit_pet_type_pet_state_path(
unlabeled_appearance.pet_type,
unlabeled_appearance,
next: "unlabeled-appearance"
)
else
@pet_type
end
end
end

View file

@ -1,10 +1,111 @@
class PetTypesController < ApplicationController
def show
@pet_type = PetType.
where(species_id: params[:species_id]).
where(color_id: params[:color_id]).
first
def index
respond_to do |format|
format.html {
@species_names = Species.order(:name).map(&:human_name)
@color_names = Color.order(:name).map(&:human_name)
render json: @pet_type
if params[:species].present?
@selected_species = Species.find_by!(name: params[:species])
@selected_species_name = @selected_species.human_name
end
if params[:color].present?
@selected_color = Color.find_by!(name: params[:color])
@selected_color_name = @selected_color.human_name
end
@selected_order =
if @selected_species.present? || @selected_color.present?
:alphabetical
else
:newest
end
@pet_types = PetType.
includes(:color, :species, :pet_states).
paginate(page: params[:page], per_page: 30)
@pet_types.where!(species_id: @selected_species) if @selected_species
@pet_types.where!(color_id: @selected_color) if @selected_color
if @selected_order == :newest
@pet_types.order!(created_at: :desc)
elsif @selected_order == :alphabetical
@pet_types.merge!(Color.alphabetical).merge!(Species.alphabetical)
end
if @selected_species && @selected_color && @pet_types.size == 1
redirect_to @pet_types.first
end
if support_staff?
@counts = {
total: PetState.count,
glitched: PetState.glitched.count,
needs_labeling: PetState.needs_labeling.count,
usable: PetState.usable.count,
}
@unlabeled_appearance = PetState.next_unlabeled_appearance
end
}
format.json {
if stale?(etag: PetState.last_updated_key)
render json: {
species: Species.order(:name).all,
colors: Color.order(:name).all,
supported_poses: PetState.all_supported_poses,
}
end
}
end
end
def show
@pet_type = find_pet_type
respond_to do |format|
format.html do
@pet_states = group_pet_states @pet_type.pet_states
end
format.json { render json: @pet_type }
end
end
protected
# The API-ish route uses IDs, but the human-facing route uses names.
def find_pet_type
if params[:species_id] && params[:color_id]
PetType.find_by!(
species_id: params[:species_id],
color_id: params[:color_id],
)
elsif params[:name]
PetType.find_by_param!(params[:name])
else
raise "expected params: species_id and color_id, or name"
end
end
# The `canonical` pet states are the main ones we want to show: the most
# canonical state for each pose. The `other` pet states are, the others!
#
# If no main poses are available, then we just make all the poses
# "canonical", and show the whole mish-mash!
def group_pet_states(pet_states)
pose_groups = pet_states.emotion_order.group_by(&:pose)
main_groups =
pose_groups.select { |k| PetState::MAIN_POSES.include?(k) }.values
other_groups =
pose_groups.reject { |k| PetState::MAIN_POSES.include?(k) }.values
if main_groups.empty?
return {canonical: other_groups.flatten(1).sort_by(&:pose), other: []}
end
canonical = main_groups.map(&:first).sort_by(&:pose)
main_others = main_groups.map { |l| l.drop(1) }.flatten(1)
other = (main_others + other_groups.flatten(1)).sort_by(&:pose)
{canonical:, other:}
end
end

View file

@ -1,20 +1,17 @@
class PetsController < ApplicationController
rescue_from Pet::PetNotFound, with: :pet_not_found
rescue_from PetType::DownloadError, SwfAsset::DownloadError, with: :asset_download_error
rescue_from Pet::DownloadError, with: :pet_download_error
rescue_from Neopets::CustomPets::PetNotFound, with: :pet_not_found
rescue_from Neopets::CustomPets::DownloadError, with: :pet_download_error
rescue_from Pet::ModelingDisabled, with: :modeling_disabled
rescue_from Pet::UnexpectedDataFormat, with: :unexpected_data_format
def load
# Uncomment this to temporarily disable modeling for most users.
# return modeling_disabled unless user_signed_in? && current_user.admin?
raise Pet::PetNotFound unless params[:name]
raise Neopets::CustomPets::PetNotFound unless params[:name]
@pet = Pet.load(params[:name])
points = contribute(current_user, @pet)
respond_to do |format|
format.html do
path = destination + @pet.wardrobe_query
path = destination + "?" + @pet.wardrobe_query
redirect_to path
end
@ -38,9 +35,8 @@ class PetsController < ApplicationController
def destination
case (params[:destination] || params[:origin])
when 'wardrobe' then wardrobe_path + '?'
when 'needed_items' then needed_items_path + '?'
else root_path + '#'
when 'wardrobe' then wardrobe_path
else root_path
end
end
@ -49,12 +45,6 @@ class PetsController < ApplicationController
:status => :not_found
end
def asset_download_error(e)
Rails.logger.warn e.message
pet_load_error :long_message => t('pets.load.asset_download_error'),
:status => :gateway_timeout
end
def pet_download_error(e)
Rails.logger.warn e.message
Rails.logger.warn e.backtrace.join("\n")

View file

@ -12,13 +12,20 @@ class SwfAssetsController < ApplicationController
helpers.image_url("favicon.png"),
@swf_asset.image_url,
*@swf_asset.canvas_movie_sprite_urls,
# For images, `images.neopets.com` is a generally safe host to load
# from (shouldn't be a vulnerable site or exfiltration vector), and
# doing this can help make this header a *lot* shorter, which helps
# our nginx reverse proxy (and probably some clients) handle it. (For
# example, see asset `667993` for "Engulfed in Flames Effect".)
origins: ["https://images.neopets.com"],
)
}
policy.script_src -> {
src_list(
helpers.javascript_url("lib/easeljs.min"),
helpers.javascript_url("lib/tweenjs.min"),
helpers.javascript_url("easeljs.min"),
helpers.javascript_url("tweenjs.min"),
helpers.javascript_url("swf_assets/show"),
@swf_asset.canvas_movie_library_url,
)
@ -38,7 +45,23 @@ class SwfAssetsController < ApplicationController
private
def src_list(*urls)
urls.filter(&:present?).map { |url| url.sub(/\?.*\z/, "") }.join(" ")
def src_list(*urls, origins: [])
clean_urls = urls.
# Ignore any `nil`s that might arise
filter(&:present?).
# Parse the URL.
map { |url| Addressable::URI.parse(url) }.
# Remove query strings from URLs (they're invalid in CSPs)
each { |url| url.query = nil }.
# For the given `origins`, remove all their specific URLs, because
# we'll just include the entire origin anyway.
reject { |url| origins.include?(url.origin) }.
# Normalize the URLs. (This fixes issues like when the canonical
# Neopets version of the URL contains plain unescaped spaces.)
each(&:normalize!).
# Convert the URLs back into strings.
map(&:to_s)
clean_urls + origins
end
end

View file

@ -1,5 +1,6 @@
class UsersController < ApplicationController
before_action :find_and_authorize_user!, :only => [:update]
before_action :find_and_authorize_user!, only: [:edit, :update]
before_action :support_staff_only, only: [:edit]
def index # search, really
name = params[:name]
@ -16,6 +17,9 @@ class UsersController < ApplicationController
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
end
def edit
end
def update
@user.attributes = user_params
success = @user.save
@ -42,17 +46,24 @@ class UsersController < ApplicationController
protected
ALLOWED_ATTRS = [
:owned_closet_hangers_visibility,
:wanted_closet_hangers_visibility,
:contact_neopets_connection_id,
]
def user_params
params.require(:user).permit(:owned_closet_hangers_visibility,
:wanted_closet_hangers_visibility, :contact_neopets_connection_id)
if support_staff?
params.require(:user).permit(
*ALLOWED_ATTRS, :name, :shadowbanned, :support_staff
)
else
params.require(:user).permit(*ALLOWED_ATTRS)
end
end
def find_and_authorize_user!
if current_user.id == params[:id].to_i
@user = current_user
else
raise AccessDenied
end
@user = User.find(params[:id])
raise AccessDenied unless current_user == @user || support_staff?
end
end

View file

@ -0,0 +1,13 @@
module AltStylesHelper
def view_or_edit_alt_style_url(alt_style)
if support_staff?
edit_alt_style_path alt_style
else
wardrobe_path(
species: alt_style.species_id,
color: alt_style.color_id,
style: alt_style.id,
)
end
end
end

View file

@ -1,6 +1,4 @@
module ApplicationHelper
include FragmentLocalization
def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL
path_or_url
@ -101,6 +99,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
@ -123,10 +127,6 @@ module ApplicationHelper
!@hide_home_link
end
def support_staff?
user_signed_in? && current_user.support_staff?
end
def impress_2020_meta_tags
origin = Rails.configuration.impress_2020_origin
support_secret = Rails.application.credentials.dig(
@ -142,20 +142,9 @@ module ApplicationHelper
end
end
JAVASCRIPT_LIBRARIES = {
:jquery => 'https://ajax.googleapis.com/ajax/libs/jquery/1.4.3/jquery.min.js',
:jquery_tmpl => 'https://ajax.microsoft.com/ajax/jquery.templates/beta1/jquery.tmpl.min.js',
}
def include_javascript_libraries(*library_names)
raw(library_names.inject('') do |html, name|
html + javascript_include_tag(JAVASCRIPT_LIBRARIES[name], defer: true)
end)
end
def locale_options
current_locale_is_public = false
options = I18n.public_locales.map do |available_locale|
options = I18n.available_locales.map do |available_locale|
current_locale_is_public = true if I18n.locale == available_locale
# Include fallbacks data on the tag. Right now it's used in blog
# localization, but may conceivably be used for something else later.
@ -170,13 +159,6 @@ module ApplicationHelper
options
end
def localized_cache(key={}, &block)
localized_key = localize_fragment_key(key, locale)
# TODO: The digest feature is handy, but it's not compatible with how we
# check for fragments existence in the controller, so skip it for now.
cache(localized_key, skip_digest: true, &block)
end
def auth_user_sign_in_path_with_return_to
new_auth_user_session_path :return_to => request.fullpath
@ -231,6 +213,19 @@ module ApplicationHelper
@hide_title_header = true
end
def hide_after(last_day, &block)
yield if Date.today <= last_day
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

@ -14,28 +14,42 @@ module ItemsHelper
}
Sizes = {
face: 1,
thumb: 2,
zoom: 3,
full: 4,
face_2x: 6,
face: 1, # 50x50
face_3x: 6, # 150x150
thumb: 2, # 150x150
full: 4, # 300x300
large: 5, # 500x500
xlarge: 7, # 640x640
zoom: 3, # 80x80
autocrop: 9, # <varies>
}
SizeUpgrades = {
face: :face_3x,
thumb: :full,
full: :xlarge,
}
end
def pet_type_image_url(pet_type, emotion: :happy, size: :face)
PetTypeImage::Template.expand(
hash: pet_type.basic_image_hash || pet_type.image_hash,
emotion: PetTypeImage::Emotions[emotion],
size: PetTypeImage::Sizes[size],
emotion: PetTypeImage::Emotions.fetch(emotion),
size: PetTypeImage::Sizes.fetch(size),
).to_s
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 +126,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}"
)
@ -228,21 +235,35 @@ module ItemsHelper
cookies["DTIOutfitViewerIsPlaying"] == "true"
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 item_fits?(item, pet_type)
item.appearances.any? { |a| a.fits? pet_type }
end
def pet_type_image(pet_type, emotion, size)
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)
size_2x = PetTypeImage::SizeUpgrades[size]
srcset = if size_2x
[[pet_type_image_url(pet_type, emotion:, size: size_2x), "2x"]]
end
image_tag(src, srcset:, **options)
end
def item_header_user_lists_form_state

View file

@ -1,9 +1,4 @@
module OutfitsHelper
LAST_DAY_OF_NEOPASS_ANNOUNCEMENT = Date.parse("2024-05-05")
def show_neopass_announcement?
Date.today <= LAST_DAY_OF_NEOPASS_ANNOUNCEMENT
end
def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil
end
@ -69,5 +64,28 @@ module OutfitsHelper
options = {:spellcheck => false, :id => nil}.merge(options)
text_field_tag 'name', nil, options
end
end
def outfit_viewer(...)
render partial: "outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
def support_outfit_viewer(...)
render partial: "support_outfit_viewer",
locals: parse_outfit_viewer_options(...)
end
private
def parse_outfit_viewer_options(
outfit=nil, pet_state: nil, preferred_image_format: :png, **html_options
)
outfit = Outfit.new(pet_state:) if outfit.nil? && pet_state.present?
if outfit.nil?
raise ArgumentError, "outfit viewer must have outfit or pet state"
end
{outfit:, preferred_image_format:, html_options:}
end
end

View file

@ -0,0 +1,41 @@
module PetStatesHelper
def pose_name(pose)
case pose
when "HAPPY_FEM"
"Happy (Feminine)"
when "HAPPY_MASC"
"Happy (Masculine)"
when "SAD_FEM"
"Sad (Feminine)"
when "SAD_MASC"
"Sad (Masculine)"
when "SICK_FEM"
"Sick (Feminine)"
when "SICK_MASC"
"Sick (Masculine)"
when "UNCONVERTED"
"Unconverted"
else
"Not labeled yet"
end
end
POSE_OPTIONS = %w(HAPPY_FEM SAD_FEM SICK_FEM HAPPY_MASC SAD_MASC SICK_MASC
UNCONVERTED UNKNOWN)
def pose_options
POSE_OPTIONS
end
def useful_pet_state_path(pet_type, pet_state)
if support_staff?
edit_pet_type_pet_state_path(pet_type, pet_state)
else
wardrobe_path(
color: pet_type.color_id,
species: pet_type.species_id,
pose: pet_state.pose,
state: pet_state.id,
)
end
end
end

View file

@ -0,0 +1,16 @@
module PetTypesHelper
def moon_progress(num, total)
nearest_quarter = (4.0 * num / total).round / 4.0
if nearest_quarter >= 1
"🌕️"
elsif nearest_quarter >= 0.75
"🌔"
elsif nearest_quarter >= 0.5
"🌓"
elsif nearest_quarter >= 0.25
"🌒"
else
"🌑"
end
end
end

View file

@ -0,0 +1,64 @@
module SupportFormHelper
class SupportFormBuilder < ActionView::Helpers::FormBuilder
attr_reader :template
delegate :capture, :check_box_tag, :concat, :content_tag,
:hidden_field_tag, :params, :render,
to: :template, private: true
def errors
render partial: "application/support_form/errors", locals: {form: self}
end
def fields(&block)
content_tag(:ul, class: "fields", &block)
end
def field(**options, &block)
content_tag(:li, **options, &block)
end
def radio_fieldset(legend, **options, &block)
render partial: "application/support_form/radio_fieldset",
locals: {form: self, legend:, options:, content: capture(&block)}
end
def radio_field(**options, &block)
content_tag(:li) do
content_tag(:label, **options, &block)
end
end
def radio_grid_fieldset(*args, &block)
radio_fieldset(*args, "data-type": "radio-grid", &block)
end
def thumbnail_input(method)
render partial: "application/support_form/thumbnail_input",
locals: {form: self, method:}
end
def actions(&block)
content_tag(:section, class: "actions", &block)
end
def go_to_next_field(after: nil, **options, &block)
content_tag(:label, class: "go-to-next", **options) do
concat hidden_field_tag(:after, after) if after
yield
end
end
def go_to_next_check_box(value)
check_box_tag "next", value, checked: params[:next] == value
end
end
def support_form_with(**options, &block)
form_with(
builder: SupportFormBuilder,
**options,
class: ["support-form", options[:class]],
&block
)
end
end

View file

@ -1,5 +1,6 @@
import "@hotwired/turbo-rails";
document.getElementById("locale").addEventListener("change", function () {
document.getElementById("locale-form").submit();
document.addEventListener("change", (e) => {
if (!e.target.matches("#locale")) return;
document.getElementById("locale-form").submit();
});

View file

@ -7,8 +7,8 @@ const rootNode = document.querySelector("#wardrobe-2020-root");
// TODO: Use the new React 18 APIs instead!
// eslint-disable-next-line react/no-deprecated
ReactDOM.render(
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
<AppProvider>
<WardrobePage />
</AppProvider>,
rootNode,
);

View file

@ -2,12 +2,12 @@ import React from "react";
import * as Sentry from "@sentry/react";
import { Integrations } from "@sentry/tracing";
import {
ChakraProvider,
Box,
css as resolveCSS,
extendTheme,
useColorMode,
useTheme,
ChakraProvider,
Box,
css as resolveCSS,
extendTheme,
useColorMode,
useTheme,
} from "@chakra-ui/react";
import { mode } from "@chakra-ui/theme-tools";
import { ApolloProvider } from "@apollo/client";
@ -20,15 +20,15 @@ import apolloClient from "./apolloClient";
const reactQueryClient = new QueryClient();
let theme = extendTheme({
styles: {
global: (props) => ({
body: {
background: mode("gray.50", "gray.800")(props),
color: mode("green.800", "green.50")(props),
transition: "all 0.25s",
},
}),
},
styles: {
global: (props) => ({
body: {
background: mode("gray.50", "gray.800")(props),
color: mode("green.800", "green.50")(props),
transition: "all 0.25s",
},
}),
},
});
// Capture the global styles function from our theme, but remove it from the
@ -43,60 +43,60 @@ const globalStyles = theme.styles.global;
theme.styles.global = {};
export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []);
React.useEffect(() => setupLogging(), []);
return (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<ApolloProvider client={apolloClient}>
<ChakraProvider resetCSS={false} theme={theme}>
<ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider>
</ApolloProvider>
</QueryClientProvider>
</BrowserRouter>
);
return (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<ApolloProvider client={apolloClient}>
<ChakraProvider resetCSS={false} theme={theme}>
<ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider>
</ApolloProvider>
</QueryClientProvider>
</BrowserRouter>
);
}
function setupLogging() {
Sentry.init({
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
autoSessionTracking: true,
integrations: [
new Integrations.BrowserTracing({
beforeNavigate: (context) => ({
...context,
// Assume any path segment starting with a digit is an ID, and replace
// it with `:id`. This will help group related routes in Sentry stats.
// NOTE: I'm a bit uncertain about the timing on this for tracking
// client-side navs... but we now only track first-time
// pageloads, and it definitely works correctly for them!
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
}),
Sentry.init({
dsn: "https://c55875c3b0904264a1a99e5b741a221e@o506079.ingest.sentry.io/5595379",
autoSessionTracking: true,
integrations: [
new Integrations.BrowserTracing({
beforeNavigate: (context) => ({
...context,
// Assume any path segment starting with a digit is an ID, and replace
// it with `:id`. This will help group related routes in Sentry stats.
// NOTE: I'm a bit uncertain about the timing on this for tracking
// client-side navs... but we now only track first-time
// pageloads, and it definitely works correctly for them!
name: window.location.pathname.replaceAll(/\/[0-9][^/]*/g, "/:id"),
}),
// We have a _lot_ of location changes that don't actually signify useful
// navigations, like in the wardrobe page. It could be useful to trace
// them with better filtering someday, but frankly we don't use the perf
// features besides Web Vitals right now, and those only get tracked on
// first-time pageloads, anyway. So, don't track client-side navs!
startTransactionOnLocationChange: false,
}),
],
denyUrls: [
// Don't log errors that were probably triggered by extensions and not by
// our own app. (Apparently Sentry's setting to ignore browser extension
// errors doesn't do this anywhere near as consistently as I'd expect?)
//
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
/^chrome-extension:\/\//,
/^moz-extension:\/\//,
],
// We have a _lot_ of location changes that don't actually signify useful
// navigations, like in the wardrobe page. It could be useful to trace
// them with better filtering someday, but frankly we don't use the perf
// features besides Web Vitals right now, and those only get tracked on
// first-time pageloads, anyway. So, don't track client-side navs!
startTransactionOnLocationChange: false,
}),
],
denyUrls: [
// Don't log errors that were probably triggered by extensions and not by
// our own app. (Apparently Sentry's setting to ignore browser extension
// errors doesn't do this anywhere near as consistently as I'd expect?)
//
// Adapted from https://gist.github.com/impressiver/5092952, as linked in
// https://docs.sentry.io/platforms/javascript/configuration/filtering/.
/^chrome-extension:\/\//,
/^moz-extension:\/\//,
],
// Since we're only tracking first-page loads and not navigations, 100%
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
tracesSampleRate: 1.0,
});
// Since we're only tracking first-page loads and not navigations, 100%
// sampling isn't actually so much! Tune down if it becomes a problem, tho.
tracesSampleRate: 1.0,
});
}
/**
@ -112,308 +112,308 @@ function setupLogging() {
* the selector `:where(.chakra-css-reset) h1` is lower specificity.
*/
function ScopedCSSReset({ children }) {
// Get the current theme and color mode.
//
// NOTE: The theme object returned by `useTheme` has some extensions that are
// necessary for the code below, but aren't present in the theme config
// returned by `extendTheme`! That's why we use this here instead of `theme`.
const liveTheme = useTheme();
const colorMode = useColorMode();
// Get the current theme and color mode.
//
// NOTE: The theme object returned by `useTheme` has some extensions that are
// necessary for the code below, but aren't present in the theme config
// returned by `extendTheme`! That's why we use this here instead of `theme`.
const liveTheme = useTheme();
const colorMode = useColorMode();
// Resolve the theme's global styles into CSS objects for Emotion.
const globalStylesCSS = resolveCSS(
globalStyles({ theme: liveTheme, colorMode }),
)(liveTheme);
// Resolve the theme's global styles into CSS objects for Emotion.
const globalStylesCSS = resolveCSS(
globalStyles({ theme: liveTheme, colorMode }),
)(liveTheme);
// Prepend our special scope selector to the global styles.
const scopedGlobalStylesCSS = {};
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
// The `body` selector is where typography etc rules go, but `body` isn't
// actually *inside* our scoped element! Instead, ignore the `body` part,
// and just apply it to the scoping element itself.
if (selector.trim() === "body") {
selector = "";
}
// Prepend our special scope selector to the global styles.
const scopedGlobalStylesCSS = {};
for (let [selector, rules] of Object.entries(globalStylesCSS)) {
// The `body` selector is where typography etc rules go, but `body` isn't
// actually *inside* our scoped element! Instead, ignore the `body` part,
// and just apply it to the scoping element itself.
if (selector.trim() === "body") {
selector = "";
}
const scopedSelector =
":where(.chakra-css-reset, .chakra-portal) " + selector;
scopedGlobalStylesCSS[scopedSelector] = rules;
}
const scopedSelector =
":where(.chakra-css-reset, .chakra-portal) " + selector;
scopedGlobalStylesCSS[scopedSelector] = rules;
}
return (
<>
<Box className="chakra-css-reset">{children}</Box>
<Global
styles={css`
/* Chakra's default global styles, placed here so we can override
return (
<>
<Box className="chakra-css-reset">{children}</Box>
<Global
styles={css`
/* Chakra's default global styles, placed here so we can override
* the actual _global_ styles in the theme to be empty. That way,
* it only affects Chakra stuff, not all elements! */
${scopedGlobalStylesCSS}
${scopedGlobalStylesCSS}
/* Chakra's CSS reset, copy-pasted and rescoped! */
/* Chakra's CSS reset, copy-pasted and rescoped! */
:where(.chakra-css-reset, .chakra-portal) {
*,
*::before,
*::after {
border-width: 0;
border-style: solid;
box-sizing: border-box;
}
*,
*::before,
*::after {
border-width: 0;
border-style: solid;
box-sizing: border-box;
}
main {
display: block;
}
main {
display: block;
}
hr {
border-top-width: 1px;
box-sizing: content-box;
height: 0;
overflow: visible;
}
hr {
border-top-width: 1px;
box-sizing: content-box;
height: 0;
overflow: visible;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em;
}
pre,
code,
kbd,
samp {
font-family: SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 1em;
}
a {
background-color: transparent;
color: inherit;
text-decoration: inherit;
}
a {
background-color: transparent;
color: inherit;
text-decoration: inherit;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bold;
}
b,
strong {
font-weight: bold;
}
small {
font-size: 80%;
}
small {
font-size: 80%;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
sup {
top: -0.5em;
}
img {
border-style: none;
}
img {
border-style: none;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
button,
select {
text-transform: none;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
button::-moz-focus-inner,
[type="button"]::-moz-focus-inner,
[type="reset"]::-moz-focus-inner,
[type="submit"]::-moz-focus-inner {
border-style: none;
padding: 0;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
progress {
vertical-align: baseline;
}
progress {
vertical-align: baseline;
}
textarea {
overflow: auto;
}
textarea {
overflow: auto;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="checkbox"],
[type="radio"] {
box-sizing: border-box;
padding: 0;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important;
}
[type="number"]::-webkit-inner-spin-button,
[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none !important;
}
input[type="number"] {
-moz-appearance: textfield;
}
input[type="number"] {
-moz-appearance: textfield;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"] {
-webkit-appearance: textfield;
outline-offset: -2px;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none !important;
}
[type="search"]::-webkit-search-decoration {
-webkit-appearance: none !important;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
font: inherit;
}
details {
display: block;
}
details {
display: block;
}
summary {
display: list-item;
}
summary {
display: list-item;
}
template {
display: none;
}
template {
display: none;
}
[hidden] {
display: none !important;
}
[hidden] {
display: none !important;
}
body,
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
body,
blockquote,
dl,
dd,
h1,
h2,
h3,
h4,
h5,
h6,
hr,
figure,
p,
pre {
margin: 0;
}
button {
background: transparent;
padding: 0;
}
button {
background: transparent;
padding: 0;
}
fieldset {
margin: 0;
padding: 0;
}
fieldset {
margin: 0;
padding: 0;
}
ol,
ul {
margin: 0;
padding: 0;
}
ol,
ul {
margin: 0;
padding: 0;
}
textarea {
resize: vertical;
}
textarea {
resize: vertical;
}
button,
[role="button"] {
cursor: pointer;
}
button,
[role="button"] {
cursor: pointer;
}
button::-moz-focus-inner {
border: 0 !important;
}
button::-moz-focus-inner {
border: 0 !important;
}
table {
border-collapse: collapse;
}
table {
border-collapse: collapse;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-size: inherit;
font-weight: inherit;
}
button,
input,
optgroup,
select,
textarea {
padding: 0;
line-height: inherit;
color: inherit;
}
button,
input,
optgroup,
select,
textarea {
padding: 0;
line-height: inherit;
color: inherit;
}
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
}
img,
svg,
video,
canvas,
audio,
iframe,
embed,
object {
display: block;
}
img,
video {
max-width: 100%;
height: auto;
}
img,
video {
max-width: 100%;
height: auto;
}
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
outline: none;
box-shadow: none;
}
[data-js-focus-visible] :focus:not([data-focus-visible-added]) {
outline: none;
box-shadow: none;
}
select::-ms-expand {
display: none;
}
}
`}
/>
</>
);
select::-ms-expand {
display: none;
}
}
`}
/>
</>
);
}

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,31 +1,31 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Flex,
IconButton,
Skeleton,
Tooltip,
useColorModeValue,
useTheme,
Box,
Flex,
IconButton,
Skeleton,
Tooltip,
useColorModeValue,
useTheme,
} from "@chakra-ui/react";
import { EditIcon, DeleteIcon, InfoIcon } from "@chakra-ui/icons";
import { loadable } from "../util";
import {
ItemCardContent,
ItemBadgeList,
ItemKindBadge,
MaybeAnimatedBadge,
YouOwnThisBadge,
YouWantThisBadge,
getZoneBadges,
ItemCardContent,
ItemBadgeList,
ItemKindBadge,
MaybeAnimatedBadge,
YouOwnThisBadge,
YouWantThisBadge,
getZoneBadges,
} from "../components/ItemCard";
import SupportOnly from "./support/SupportOnly";
import useSupport from "./support/useSupport";
const LoadableItemSupportDrawer = loadable(() =>
import("./support/ItemSupportDrawer"),
const LoadableItemSupportDrawer = loadable(
() => import("./support/ItemSupportDrawer"),
);
/**
@ -48,79 +48,79 @@ const LoadableItemSupportDrawer = loadable(() =>
* devices.
*/
function Item({
item,
itemNameId,
isWorn,
isInOutfit,
onRemove,
isDisabled = false,
item,
itemNameId,
isWorn,
isInOutfit,
onRemove,
isDisabled = false,
}) {
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
const [supportDrawerIsOpen, setSupportDrawerIsOpen] = React.useState(false);
return (
<>
<ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0">
<ItemCardContent
item={item}
badges={<ItemBadges item={item} />}
itemNameId={itemNameId}
isWorn={isWorn}
isDiabled={isDisabled}
focusSelector={containerHasFocus}
/>
</Box>
<Box flex="0 0 auto" marginTop="5px">
{isInOutfit && (
<ItemActionButton
icon={<DeleteIcon />}
label="Remove"
onClick={(e) => {
onRemove(item.id);
e.preventDefault();
}}
/>
)}
<SupportOnly>
<ItemActionButton
icon={<EditIcon />}
label="Support"
onClick={(e) => {
setSupportDrawerIsOpen(true);
e.preventDefault();
}}
/>
</SupportOnly>
<ItemActionButton
icon={<InfoIcon />}
label="More info"
to={`/items/${item.id}`}
target="_blank"
/>
</Box>
</ItemContainer>
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
</SupportOnly>
</>
);
return (
<>
<ItemContainer isDisabled={isDisabled}>
<Box flex="1 1 0" minWidth="0">
<ItemCardContent
item={item}
badges={<ItemBadges item={item} />}
itemNameId={itemNameId}
isWorn={isWorn}
isDiabled={isDisabled}
focusSelector={containerHasFocus}
/>
</Box>
<Box flex="0 0 auto" marginTop="5px">
{isInOutfit && (
<ItemActionButton
icon={<DeleteIcon />}
label="Remove"
onClick={(e) => {
onRemove(item.id);
e.preventDefault();
}}
/>
)}
<SupportOnly>
<ItemActionButton
icon={<EditIcon />}
label="Support"
onClick={(e) => {
setSupportDrawerIsOpen(true);
e.preventDefault();
}}
/>
</SupportOnly>
<ItemActionButton
icon={<InfoIcon />}
label="More info"
to={`/items/${item.id}`}
target="_blank"
/>
</Box>
</ItemContainer>
<SupportOnly>
<LoadableItemSupportDrawer
item={item}
isOpen={supportDrawerIsOpen}
onClose={() => setSupportDrawerIsOpen(false)}
/>
</SupportOnly>
</>
);
}
/**
* ItemSkeleton is a placeholder for when an Item is loading.
*/
function ItemSkeleton() {
return (
<ItemContainer isDisabled>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer>
);
return (
<ItemContainer isDisabled>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" alignSelf="center" />
</ItemContainer>
);
}
/**
@ -131,152 +131,152 @@ function ItemSkeleton() {
* .item-container parent!
*/
function ItemContainer({ children, isDisabled = false }) {
const theme = useTheme();
const theme = useTheme();
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["100"],
theme.colors.gray["700"],
);
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["100"],
theme.colors.gray["700"],
);
const activeBorderColor = useColorModeValue(
theme.colors.green["400"],
theme.colors.green["500"],
);
const activeBorderColor = useColorModeValue(
theme.colors.green["400"],
theme.colors.green["500"],
);
const focusCheckedBorderColor = useColorModeValue(
theme.colors.green["800"],
theme.colors.green["300"],
);
const focusCheckedBorderColor = useColorModeValue(
theme.colors.green["800"],
theme.colors.green["300"],
);
return (
<ClassNames>
{({ css, cx }) => (
<Box
p="1"
my="1"
borderRadius="lg"
d="flex"
cursor={isDisabled ? undefined : "pointer"}
border="1px"
borderColor="transparent"
className={cx([
"item-container",
!isDisabled &&
css`
&:hover,
input:focus + & {
background-color: ${focusBackgroundColor};
}
return (
<ClassNames>
{({ css, cx }) => (
<Box
p="1"
my="1"
borderRadius="lg"
d="flex"
cursor={isDisabled ? undefined : "pointer"}
border="1px"
borderColor="transparent"
className={cx([
"item-container",
!isDisabled &&
css`
&:hover,
input:focus + & {
background-color: ${focusBackgroundColor};
}
input:active + & {
border-color: ${activeBorderColor};
}
input:active + & {
border-color: ${activeBorderColor};
}
input:checked:focus + & {
border-color: ${focusCheckedBorderColor};
}
`,
])}
>
{children}
</Box>
)}
</ClassNames>
);
input:checked:focus + & {
border-color: ${focusCheckedBorderColor};
}
`,
])}
>
{children}
</Box>
)}
</ClassNames>
);
}
function ItemBadges({ item }) {
const { isSupportUser } = useSupport();
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
const restrictedZones = item.appearanceOn.restrictedZones.filter(
(z) => z.isCommonlyUsedByItems,
);
const isMaybeAnimated = item.appearanceOn.layers.some(
(l) => l.canvasMovieLibraryUrl,
);
const { isSupportUser } = useSupport();
const occupiedZones = item.appearanceOn.layers.map((l) => l.zone);
const restrictedZones = item.appearanceOn.restrictedZones.filter(
(z) => z.isCommonlyUsedByItems,
);
const isMaybeAnimated = item.appearanceOn.layers.some(
(l) => l.canvasMovieLibraryUrl,
);
return (
<ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{
// This badge is unreliable, but it's helpful for looking for animated
// items to test, so we show it only to support. We use this form
// instead of <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
}
{getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList>
);
return (
<ItemBadgeList>
<ItemKindBadge isNc={item.isNc} isPb={item.isPb} />
{
// This badge is unreliable, but it's helpful for looking for animated
// items to test, so we show it only to support. We use this form
// instead of <SupportOnly />, to avoid adding extra badge list spacing
// on the additional empty child.
isMaybeAnimated && isSupportUser && <MaybeAnimatedBadge />
}
{getZoneBadges(occupiedZones, { variant: "occupies" })}
{getZoneBadges(restrictedZones, { variant: "restricts" })}
{item.currentUserOwnsThis && <YouOwnThisBadge variant="medium" />}
{item.currentUserWantsThis && <YouWantThisBadge variant="medium" />}
</ItemBadgeList>
);
}
/**
* ItemActionButton is one of a list of actions a user can take for this item.
*/
function ItemActionButton({ icon, label, to, onClick, ...props }) {
const theme = useTheme();
const theme = useTheme();
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["300"],
theme.colors.gray["800"],
);
const focusColor = useColorModeValue(
theme.colors.gray["700"],
theme.colors.gray["200"],
);
const focusBackgroundColor = useColorModeValue(
theme.colors.gray["300"],
theme.colors.gray["800"],
);
const focusColor = useColorModeValue(
theme.colors.gray["700"],
theme.colors.gray["200"],
);
return (
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<LinkOrButton
{...props}
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
onClick={onClick}
className={css`
opacity: 0;
transition: all 0.2s;
return (
<ClassNames>
{({ css }) => (
<Tooltip label={label} placement="top">
<LinkOrButton
{...props}
component={IconButton}
href={to}
icon={icon}
aria-label={label}
variant="ghost"
color="gray.400"
onClick={onClick}
className={css`
opacity: 0;
transition: all 0.2s;
${containerHasFocus} {
opacity: 1;
}
${containerHasFocus} {
opacity: 1;
}
&:focus,
&:hover {
opacity: 1;
background-color: ${focusBackgroundColor};
color: ${focusColor};
}
&:focus,
&:hover {
opacity: 1;
background-color: ${focusBackgroundColor};
color: ${focusColor};
}
/* On touch devices, always show the buttons! This avoids having to
/* On touch devices, always show the buttons! This avoids having to
* tap to reveal them (which toggles the item), or worse,
* accidentally tapping a hidden button without realizing! */
@media (hover: none) {
opacity: 1;
}
`}
/>
</Tooltip>
)}
</ClassNames>
);
@media (hover: none) {
opacity: 1;
}
`}
/>
</Tooltip>
)}
</ClassNames>
);
}
function LinkOrButton({ href, component, ...props }) {
const ButtonComponent = component;
if (href != null) {
return <ButtonComponent as="a" href={href} {...props} />;
} else {
return <ButtonComponent {...props} />;
}
const ButtonComponent = component;
if (href != null) {
return <ButtonComponent as="a" href={href} {...props} />;
} else {
return <ButtonComponent {...props} />;
}
}
/**
@ -284,11 +284,11 @@ function LinkOrButton({ href, component, ...props }) {
* components in this to ensure a consistent list layout.
*/
export function ItemListContainer({ children, ...props }) {
return (
<Flex direction="column" {...props}>
{children}
</Flex>
);
return (
<Flex direction="column" {...props}>
{children}
</Flex>
);
}
/**
@ -296,13 +296,13 @@ export function ItemListContainer({ children, ...props }) {
* Items are loading.
*/
export function ItemListSkeleton({ count, ...props }) {
return (
<ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} />
))}
</ItemListContainer>
);
return (
<ItemListContainer {...props}>
{Array.from({ length: count }).map((_, i) => (
<ItemSkeleton key={i} />
))}
</ItemListContainer>
);
}
/**
@ -311,6 +311,6 @@ export function ItemListSkeleton({ count, ...props }) {
* focused.
*/
const containerHasFocus =
".item-container:hover &, input:focus + .item-container &";
".item-container:hover &, input:focus + .item-container &";
export default React.memo(Item);

View file

@ -21,72 +21,72 @@ import { MajorErrorMessage, TestErrorSender, useLocalStorage } from "../util";
* state and refs.
*/
function ItemsAndSearchPanels({
loading,
searchQuery,
onChangeSearchQuery,
outfitState,
outfitSaving,
dispatchToOutfit,
loading,
searchQuery,
onChangeSearchQuery,
outfitState,
outfitSaving,
dispatchToOutfit,
}) {
const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
const [canUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter",
false,
);
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
const hasRoomForSearchFooter = useBreakpointValue({ base: false, md: true });
const [canUseSearchFooter] = useLocalStorage(
"DTIFeatureFlagCanUseSearchFooter",
false,
);
const isShowingSearchFooter = canUseSearchFooter && hasRoomForSearchFooter;
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery}
/>
</Box>
)}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box
key="search-panel"
flex="1 0 0"
position="relative"
overflowY="scroll"
ref={scrollContainerRef}
data-test-id="search-panel-scroll-container"
>
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
) : (
<Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2">
<ItemsPanel
loading={loading}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
</Sentry.ErrorBoundary>
);
return (
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
<TestErrorSender />
<Flex direction="column" height="100%">
{isShowingSearchFooter && <Box height="2" />}
{!isShowingSearchFooter && (
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={onChangeSearchQuery}
/>
</Box>
)}
{!isShowingSearchFooter && !searchQueryIsEmpty(searchQuery) ? (
<Box
key="search-panel"
flex="1 0 0"
position="relative"
overflowY="scroll"
ref={scrollContainerRef}
data-test-id="search-panel-scroll-container"
>
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
) : (
<Box position="relative" overflow="auto" key="items-panel">
<Box px="4" py="2">
<ItemsPanel
loading={loading}
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
</Sentry.ErrorBoundary>
);
}
export default ItemsAndSearchPanels;

View file

@ -1,38 +1,38 @@
import React from "react";
import { ClassNames } from "@emotion/react";
import {
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
Skeleton,
Tooltip,
VisuallyHidden,
Menu,
MenuButton,
MenuList,
MenuItem,
Portal,
Button,
Spinner,
useColorModeValue,
Modal,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
ModalCloseButton,
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
Skeleton,
Tooltip,
VisuallyHidden,
Menu,
MenuButton,
MenuList,
MenuItem,
Portal,
Button,
Spinner,
useColorModeValue,
Modal,
ModalContent,
ModalOverlay,
ModalHeader,
ModalBody,
ModalFooter,
useDisclosure,
ModalCloseButton,
} from "@chakra-ui/react";
import {
CheckIcon,
DeleteIcon,
EditIcon,
QuestionIcon,
WarningTwoIcon,
CheckIcon,
DeleteIcon,
EditIcon,
QuestionIcon,
WarningTwoIcon,
} from "@chakra-ui/icons";
import { IoBagCheck } from "react-icons/io5";
import { CSSTransition, TransitionGroup } from "react-transition-group";
@ -59,70 +59,70 @@ import { useDeleteOutfitMutation } from "../loaders/outfits";
* full width of the container, it doesn't look like it!
*/
function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
const { altStyleId, zonesAndItems, incompatibleItems } = outfitState;
return (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<>
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition
key={zoneId}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
</TransitionGroup>
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label={
altStyleId != null
? "Many items don't fit Alt Style pets"
: "These items don't fit this pet"
}
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</>
)}
</Flex>
</Box>
)}
</ClassNames>
);
return (
<ClassNames>
{({ css }) => (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
outfitSaving={outfitSaving}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading ? (
<ItemZoneGroupsSkeleton
itemCount={outfitState.allItemIds.length}
/>
) : (
<>
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneId, zoneLabel, items }) => (
<CSSTransition
key={zoneId}
{...fadeOutAndRollUpTransition(css)}
>
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
</TransitionGroup>
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label={
altStyleId != null
? "Many items don't fit Alt Style pets"
: "These items don't fit this pet"
}
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</>
)}
</Flex>
</Box>
)}
</ClassNames>
);
}
/**
@ -134,102 +134,102 @@ function ItemsPanel({ outfitState, outfitSaving, loading, dispatchToOutfit }) {
* makes the list screen-reader- and keyboard-accessible!
*/
function ItemZoneGroup({
zoneLabel,
items,
outfitState,
dispatchToOutfit,
isDisabled = false,
afterHeader = null,
zoneLabel,
items,
outfitState,
dispatchToOutfit,
isDisabled = false,
afterHeader = null,
}) {
// onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => {
const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId });
};
// onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => {
const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId });
};
// Clicking the radio button when already selected deselects it - this is how
// you can select none!
const onClick = (e) => {
const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated
// events don't just come back around and undo it - but we can't just
// solve that with `preventDefault`, because it breaks the radio's
// intended visual updates when we unwear. So, we `setTimeout` to do it
// after all event handlers resolve!
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
}
};
// Clicking the radio button when already selected deselects it - this is how
// you can select none!
const onClick = (e) => {
const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated
// events don't just come back around and undo it - but we can't just
// solve that with `preventDefault`, because it breaks the radio's
// intended visual updates when we unwear. So, we `setTimeout` to do it
// after all event handlers resolve!
setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0);
}
};
const onRemove = React.useCallback(
(itemId) => {
dispatchToOutfit({ type: "removeItem", itemId });
},
[dispatchToOutfit],
);
const onRemove = React.useCallback(
(itemId) => {
dispatchToOutfit({ type: "removeItem", itemId });
},
[dispatchToOutfit],
);
return (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<ClassNames>
{({ css }) => (
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
return (
<CSSTransition
key={item.id}
{...fadeOutAndRollUpTransition(css)}
>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
</TransitionGroup>
</ItemListContainer>
</Box>
)}
</ClassNames>
);
}
/**
@ -240,35 +240,35 @@ function ItemZoneGroup({
* we don't show skeleton items that just clear away!
*/
function ItemZoneGroupsSkeleton({ itemCount }) {
const groups = [];
for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for
// outfit shares I think, but looks bad for pet loading... once shares
// become a more common use case, it might be useful to figure out how
// to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
const groups = [];
for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for
// outfit shares I think, but looks bad for pet loading... once shares
// become a more common use case, it might be useful to figure out how
// to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
}
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
</Delay>
</Box>
);
}
/**
@ -277,36 +277,36 @@ function ItemZoneGroupSkeleton({ itemCount }) {
* this is disabled.
*/
function ShoppingListButton({ outfitState }) {
const itemIds = [...outfitState.wornItemIds].sort();
const isDisabled = itemIds.length === 0;
const itemIds = [...outfitState.wornItemIds].sort();
const isDisabled = itemIds.length === 0;
let targetUrl = `/items/sources/${itemIds.join(",")}`;
if (outfitState.name != null && outfitState.name.trim().length > 0) {
const params = new URLSearchParams();
params.append("for", outfitState.name);
targetUrl += "?" + params.toString();
}
let targetUrl = `/items/sources/${itemIds.join(",")}`;
if (outfitState.name != null && outfitState.name.trim().length > 0) {
const params = new URLSearchParams();
params.append("for", outfitState.name);
targetUrl += "?" + params.toString();
}
return (
<Tooltip
label="Shopping list"
placement="top"
background="purple.500"
color="white"
>
<IconButton
aria-label="Shopping list"
as={isDisabled ? "button" : "a"}
href={isDisabled ? undefined : targetUrl}
target={isDisabled ? undefined : "_blank"}
icon={<IoBagCheck />}
colorScheme="purple"
size="sm"
isRound
isDisabled={isDisabled}
/>
</Tooltip>
);
return (
<Tooltip
label="Shopping list"
placement="top"
background="purple.500"
color="white"
>
<IconButton
aria-label="Shopping list"
as={isDisabled ? "button" : "a"}
href={isDisabled ? undefined : targetUrl}
target={isDisabled ? undefined : "_blank"}
icon={<IoBagCheck />}
colorScheme="purple"
size="sm"
isRound
isDisabled={isDisabled}
/>
</Tooltip>
);
}
/**
@ -314,100 +314,100 @@ function ShoppingListButton({ outfitState }) {
* if the user can save this outfit. If not, this is empty!
*/
function OutfitSavingIndicator({ outfitSaving }) {
const {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
} = outfitSaving;
const {
canSaveOutfit,
isNewOutfit,
isSaving,
latestVersionIsSaved,
saveError,
saveOutfit,
} = outfitSaving;
const errorTextColor = useColorModeValue("red.600", "red.400");
const errorTextColor = useColorModeValue("red.600", "red.400");
if (!canSaveOutfit) {
return null;
}
if (!canSaveOutfit) {
return null;
}
if (isNewOutfit) {
return (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (isNewOutfit) {
return (
<Button
variant="outline"
size="sm"
isLoading={isSaving}
loadingText="Saving…"
leftIcon={
<Box
// Adjust the visual balance toward the cloud
marginBottom="-2px"
>
<IoCloudUploadOutline />
</Box>
}
onClick={saveOutfit}
data-test-id="wardrobe-save-outfit-button"
>
Save
</Button>
);
}
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (isSaving) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saving-indicator"
>
<Spinner
size="xs"
marginRight="1.5"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saving
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (latestVersionIsSaved) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-is-saved-indicator"
>
<CheckIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Saved
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
if (saveError) {
return (
<Flex
align="center"
fontSize="xs"
data-test-id="wardrobe-outfit-save-error-indicator"
color={errorTextColor}
>
<WarningTwoIcon
marginRight="1"
// HACK: Not sure why my various centering things always feel wrong...
marginBottom="-2px"
/>
Error saving
</Flex>
);
}
// The most common way we'll hit this null is when the outfit is changing,
// but the debouncing isn't done yet, so it's not saving yet.
return null;
// The most common way we'll hit this null is when the outfit is changing,
// but the debouncing isn't done yet, so it's not saving yet.
return null;
}
/**
@ -415,133 +415,133 @@ function OutfitSavingIndicator({ outfitSaving }) {
* It also contains the outfit menu, for saving etc.
*/
function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) {
const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
const { canDeleteOutfit } = outfitSaving;
const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true });
return (
// The Editable wraps everything, including the menu, because the menu has
// a Rename option.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<OutfitSavingIndicator outfitSaving={outfitSaving} />
<Box width="3" flex="0 0 auto" />
<ShoppingListButton outfitState={outfitState} />
<Box width="2" flex="0 0 auto" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
isRound
size="sm"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
return (
// The Editable wraps everything, including the menu, because the menu has
// a Rename option.
<Editable
// Make sure not to ever pass `undefined` into here, or else the
// component enters uncontrolled mode, and changing the value
// later won't fix it!
value={outfitState.name || ""}
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ onEdit }) => (
<Flex align="center" marginBottom="6">
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1>
<EditablePreview lineHeight="48px" data-test-id="outfit-name" />
<EditableInput lineHeight="48px" />
</Heading1>
</Box>
</Box>
<Box width="4" flex="1 0 auto" />
<OutfitSavingIndicator outfitSaving={outfitSaving} />
<Box width="3" flex="0 0 auto" />
<ShoppingListButton outfitState={outfitState} />
<Box width="2" flex="0 0 auto" />
<Menu placement="bottom-end">
<MenuButton
as={IconButton}
variant="ghost"
icon={<MdMoreVert />}
aria-label="Outfit menu"
isRound
size="sm"
fontSize="24px"
opacity="0.8"
/>
<Portal>
<MenuList>
{outfitState.id && (
<MenuItem
icon={<EditIcon />}
as="a"
href={outfitCopyUrl}
target="_blank"
>
Edit a copy
</MenuItem>
)}
<MenuItem
icon={<BiRename />}
onClick={() => {
// Start the rename after a tick, so finishing up the click
// won't just immediately remove focus from the Editable.
setTimeout(onEdit, 0);
}}
>
Rename
</MenuItem>
{canDeleteOutfit && (
<DeleteOutfitMenuItem outfitState={outfitState} />
)}
</MenuList>
</Portal>
</Menu>
</Flex>
)}
</Editable>
);
}
function DeleteOutfitMenuItem({ outfitState }) {
const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure();
const { id, name } = outfitState;
const { isOpen, onOpen, onClose } = useDisclosure();
const { status, error, mutateAsync } = useDeleteOutfitMutation();
const { status, error, mutateAsync } = useDeleteOutfitMutation();
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
We'll delete this data and remove it from your list of outfits.
Links and image embeds pointing to this outfit will break. Is that
okay?
{status === "error" && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
mutateAsync(id)
.then(() => {
window.location = "/your-outfits";
})
.catch((e) => {
/* handled in error UI */
})
}
// We continue to show the loading spinner in the success case,
// while we redirect away!
isLoading={status === "pending" || status === "success"}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
return (
<>
<MenuItem icon={<DeleteIcon />} onClick={onOpen}>
Delete
</MenuItem>
<Modal isOpen={isOpen} onClose={onClose}>
<ModalOverlay />
<ModalContent>
<ModalHeader>Delete outfit "{name}"?</ModalHeader>
<ModalCloseButton />
<ModalBody>
We'll delete this data and remove it from your list of outfits.
Links and image embeds pointing to this outfit will break. Is that
okay?
{status === "error" && (
<ErrorMessage marginTop="1em">
Error deleting outfit: "{error.message}". Try again?
</ErrorMessage>
)}
</ModalBody>
<ModalFooter>
<Button onClick={onClose}>No, keep this outfit</Button>
<Box flex="1 0 auto" width="2" />
<Button
colorScheme="red"
onClick={() =>
mutateAsync(id)
.then(() => {
window.location = "/your-outfits";
})
.catch((e) => {
/* handled in error UI */
})
}
// We continue to show the loading spinner in the success case,
// while we redirect away!
isLoading={status === "pending" || status === "success"}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
}
/**
@ -555,24 +555,24 @@ function DeleteOutfitMenuItem({ outfitState }) {
* See react-transition-group docs for more info!
*/
const fadeOutAndRollUpTransition = (css) => ({
classNames: css`
&-exit {
opacity: 1;
height: auto;
}
classNames: css`
&-exit {
opacity: 1;
height: auto;
}
&-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}
`,
timeout: 500,
onExit: (e) => {
e.style.height = e.offsetHeight + "px";
},
&-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}
`,
timeout: 500,
onExit: (e) => {
e.style.height = e.offsetHeight + "px";
},
});
export default ItemsPanel;

View file

@ -1,92 +1,92 @@
import React from "react";
import {
Box,
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
Box,
Button,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalHeader,
ModalOverlay,
Table,
Tbody,
Td,
Th,
Thead,
Tr,
} from "@chakra-ui/react";
function LayersInfoModal({ isOpen, onClose, visibleLayers }) {
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay>
<ModalContent maxWidth="800px">
<ModalHeader>Outfit layers</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LayerTable layers={visibleLayers} />
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
return (
<Modal isOpen={isOpen} onClose={onClose} size="xl">
<ModalOverlay>
<ModalContent maxWidth="800px">
<ModalHeader>Outfit layers</ModalHeader>
<ModalCloseButton />
<ModalBody>
<LayerTable layers={visibleLayers} />
</ModalBody>
</ModalContent>
</ModalOverlay>
</Modal>
);
}
function LayerTable({ layers }) {
return (
<Table>
<Thead>
<Tr>
<Th>Preview</Th>
<Th>DTI ID</Th>
<Th>Zone</Th>
<Th>Links</Th>
</Tr>
</Thead>
<Tbody>
{layers.map((layer) => (
<LayerTableRow key={layer.id} layer={layer} />
))}
</Tbody>
</Table>
);
return (
<Table>
<Thead>
<Tr>
<Th>Preview</Th>
<Th>DTI ID</Th>
<Th>Zone</Th>
<Th>Links</Th>
</Tr>
</Thead>
<Tbody>
{layers.map((layer) => (
<LayerTableRow key={layer.id} layer={layer} />
))}
</Tbody>
</Table>
);
}
function LayerTableRow({ layer, ...props }) {
return (
<Tr {...props}>
<Td>
<Box
as="img"
src={layer.imageUrl}
width="60px"
height="60px"
boxShadow="md"
/>
</Td>
<Td>{layer.id}</Td>
<Td>{layer.zone.label}</Td>
<Td>
<Box display="flex" gap=".5em">
{layer.imageUrl && (
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
PNG
</Button>
)}
{layer.swfUrl && (
<Button as="a" href={layer.swfUrl} size="sm" download>
SWF
</Button>
)}
{layer.svgUrl && (
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
SVG
</Button>
)}
</Box>
</Td>
</Tr>
);
return (
<Tr {...props}>
<Td>
<Box
as="img"
src={layer.imageUrl}
width="60px"
height="60px"
boxShadow="md"
/>
</Td>
<Td>{layer.id}</Td>
<Td>{layer.zone.label}</Td>
<Td>
<Box display="flex" gap=".5em">
{layer.imageUrl && (
<Button as="a" href={layer.imageUrl} target="_blank" size="sm">
PNG
</Button>
)}
{layer.swfUrl && (
<Button as="a" href={layer.swfUrl} size="sm" download>
SWF
</Button>
)}
{layer.svgUrl && (
<Button as="a" href={layer.svgUrl} target="_blank" size="sm">
SVG
</Button>
)}
</Box>
</Td>
</Tr>
);
}
export default LayersInfoModal;

File diff suppressed because it is too large Load diff

View file

@ -7,310 +7,310 @@ import getVisibleLayers from "../components/getVisibleLayers";
import { useLocalStorage } from "../util";
function OutfitKnownGlitchesBadge({ appearance }) {
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const { petAppearance, items } = appearance;
const [hiResMode] = useLocalStorage("DTIHiResMode", false);
const { petAppearance, items } = appearance;
const glitchMessages = [];
const glitchMessages = [];
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
// just mark Incompatible someday instead; or with correctly partially-hidden
// art.
//
// NOTE: This particular glitch is checking for the *absence* of layers, so
// we skip it if we're still loading!
if (!appearance.loading) {
for (const item of items) {
// HACK: We use `getVisibleLayers` with just this pet appearance and item
// appearance, to run the logic for which layers are compatible with
// this pet. But `getVisibleLayers` does other things too, so it's
// plausible that this could do not quite what we want in some cases!
const allItemLayers = item.appearance.layers;
const compatibleItemLayers = getVisibleLayers(petAppearance, [
item.appearance,
]).filter((l) => l.source === "item");
// Look for UC/Invisible/etc incompatibilities that we hid, that we should
// just mark Incompatible someday instead; or with correctly partially-hidden
// art.
//
// NOTE: This particular glitch is checking for the *absence* of layers, so
// we skip it if we're still loading!
if (!appearance.loading) {
for (const item of items) {
// HACK: We use `getVisibleLayers` with just this pet appearance and item
// appearance, to run the logic for which layers are compatible with
// this pet. But `getVisibleLayers` does other things too, so it's
// plausible that this could do not quite what we want in some cases!
const allItemLayers = item.appearance.layers;
const compatibleItemLayers = getVisibleLayers(petAppearance, [
item.appearance,
]).filter((l) => l.source === "item");
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
glitchMessages.push(
<Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> isn't actually compatible with this special pet.
We're hiding the item art, which is outdated behavior, and we should
instead be treating it as entirely incompatible. Fixing this is in
our todo list, sorry for the confusing UI!
</Box>,
);
} else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'s compatibility with this pet is complicated, but
we believe this is how it looks: some zones are visible, and some
zones are hidden. If this isn't quite right, please email me at
matchu@openneo.net and let me know!
</Box>,
);
}
}
}
if (compatibleItemLayers.length === 0 && allItemLayers.length > 0) {
glitchMessages.push(
<Box key={`total-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i> isn't actually compatible with this special pet.
We're hiding the item art, which is outdated behavior, and we should
instead be treating it as entirely incompatible. Fixing this is in
our todo list, sorry for the confusing UI!
</Box>,
);
} else if (compatibleItemLayers.length < allItemLayers.length) {
glitchMessages.push(
<Box key={`partial-uc-conflict-for-item-${item.id}`}>
<i>{item.name}</i>'s compatibility with this pet is complicated, but
we believe this is how it looks: some zones are visible, and some
zones are hidden. If this isn't quite right, please email me at
matchu@openneo.net and let me know!
</Box>,
);
}
}
}
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const item of items) {
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
);
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
(l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
!layerUsesHTML5(l),
);
if (itemHasBrokenOnNeopetsDotCom) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? (
<>
We're aware of a glitch affecting the art for <i>{item.name}</i>.
Last time we checked, this glitch affected its appearance on
Neopets.com, too. Hopefully this will be fixed once it's converted
to HTML5!
</>
) : (
<>
We're aware of a previous glitch affecting the art for{" "}
<i>{item.name}</i>, but it might have been resolved during HTML5
conversion. Please use the feedback form on the homepage to let us
know if it looks right, or still looks wrong! Thank you!
</>
)}
</Box>,
);
}
}
// Look for items with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const item of items) {
const itemHasBrokenOnNeopetsDotCom = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT"),
);
const itemHasBrokenUnconvertedLayers = item.appearance.layers.some(
(l) =>
(l.knownGlitches || []).includes("OFFICIAL_SWF_IS_INCORRECT") &&
!layerUsesHTML5(l),
);
if (itemHasBrokenOnNeopetsDotCom) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-item-${item.id}`}>
{itemHasBrokenUnconvertedLayers ? (
<>
We're aware of a glitch affecting the art for <i>{item.name}</i>.
Last time we checked, this glitch affected its appearance on
Neopets.com, too. Hopefully this will be fixed once it's converted
to HTML5!
</>
) : (
<>
We're aware of a previous glitch affecting the art for{" "}
<i>{item.name}</i>, but it might have been resolved during HTML5
conversion. Please use the feedback form on the homepage to let us
know if it looks right, or still looks wrong! Thank you!
</>
)}
</Box>,
);
}
}
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i>, and we believe it
looks this way on-site, too. But our version might be out of date! If
you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>,
);
}
}
// Look for items with the OFFICIAL_MOVIE_IS_INCORRECT glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_MOVIE_IS_INCORRECT"),
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`official-movie-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i>, and we believe it
looks this way on-site, too. But our version might be out of date! If
you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>,
);
}
}
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
if (hiResMode) {
for (const item of items) {
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
);
if (itemHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that prevents us
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
PNG, which might look a bit blurry on larger screens.
</Box>,
);
}
}
}
// Look for items with the OFFICIAL_SVG_IS_INCORRECT glitch. Only show this
// if hi-res mode is on, because otherwise it doesn't affect the user anyway!
if (hiResMode) {
for (const item of items) {
const itemHasOfficialSvgIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_SVG_IS_INCORRECT"),
);
if (itemHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that prevents us
from showing the SVG image for Hi-Res Mode. Instead, we're showing a
PNG, which might look a bit blurry on larger screens.
</Box>,
);
}
}
}
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
),
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that causes it to
display incorrectlybut we're not sure if it's on our end, or TNT's.
If you own this item, please email me at matchu@openneo.net to let us
know how it looks in the on-site customizer!
</Box>,
);
}
}
// Look for items with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const item of items) {
const itemHasGlitch = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
),
);
if (itemHasGlitch) {
glitchMessages.push(
<Box key={`displays-incorrectly-but-cause-unknown-for-item-${item.id}`}>
There's a glitch in the art for <i>{item.name}</i> that causes it to
display incorrectlybut we're not sure if it's on our end, or TNT's.
If you own this item, please email me at matchu@openneo.net to let us
know how it looks in the on-site customizer!
</Box>,
);
}
}
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
for (const item of items) {
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
);
if (itemHasOfficialBodyIdIsIncorrect) {
glitchMessages.push(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> actually is compatible with this
pet, even though it seems like it shouldn't be. But TNT might change
this at any time, so be careful!
</Box>,
);
}
}
// Look for items with the OFFICIAL_BODY_ID_IS_INCORRECT glitch.
for (const item of items) {
const itemHasOfficialBodyIdIsIncorrect = item.appearance.layers.some((l) =>
(l.knownGlitches || []).includes("OFFICIAL_BODY_ID_IS_INCORRECT"),
);
if (itemHasOfficialBodyIdIsIncorrect) {
glitchMessages.push(
<Box key={`official-body-id-is-incorrect-for-item-${item.id}`}>
Last we checked, <i>{item.name}</i> actually is compatible with this
pet, even though it seems like it shouldn't be. But TNT might change
this at any time, so be careful!
</Box>,
);
}
}
// Look for Dyeworks items that aren't converted yet.
for (const item of items) {
const itemIsDyeworks = item.name.includes("Dyeworks");
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
// Look for Dyeworks items that aren't converted yet.
for (const item of items) {
const itemIsDyeworks = item.name.includes("Dyeworks");
const itemIsConverted = item.appearance.layers.every(layerUsesHTML5);
if (itemIsDyeworks && !itemIsConverted) {
glitchMessages.push(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
code often shows old Dyeworks items in the wrong color. Once it's
converted, we'll display it correctly!
</Box>,
);
}
}
if (itemIsDyeworks && !itemIsConverted) {
glitchMessages.push(
<Box key={`unconverted-dyeworks-warning-for-item-${item.id}`}>
<i>{item.name}</i> isn't converted to HTML5 yet, and our Classic DTI
code often shows old Dyeworks items in the wrong color. Once it's
converted, we'll display it correctly!
</Box>,
);
}
}
// Look for Baby Body Paint items.
for (const item of items) {
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
if (itemIsBabyBodyPaint) {
glitchMessages.push(
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
<i>{item.name}</i> seems to have new zone restriction rules that our
system doesn't support yet, whuh oh! This might require major changes
to how we handle zones. Until then, this item will be very buggy,
sorry!
</Box>,
);
}
}
// Look for Baby Body Paint items.
for (const item of items) {
const itemIsBabyBodyPaint = item.name.includes("Baby Body Paint");
if (itemIsBabyBodyPaint) {
glitchMessages.push(
<Box key={`baby-body-paint-warning-for-item-${item.id}`}>
<i>{item.name}</i> seems to have new zone restriction rules that our
system doesn't support yet, whuh oh! This might require major changes
to how we handle zones. Until then, this item will be very buggy,
sorry!
</Box>,
);
}
}
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") {
glitchMessages.push(
<Box key={`invisible-pet-warning`}>
Invisible pets are affected by a number of glitches, including faces
sometimes being visible on-site, and errors in the HTML5 conversion. If
this pose looks incorrect, you can try another by clicking the emoji
face next to the species/color picker. But be aware that Neopets.com
might look different!
</Box>,
);
}
// Check whether the pet is Invisible. If so, we'll show a blanket warning.
if (petAppearance?.color?.id === "38") {
glitchMessages.push(
<Box key={`invisible-pet-warning`}>
Invisible pets are affected by a number of glitches, including faces
sometimes being visible on-site, and errors in the HTML5 conversion. If
this pose looks incorrect, you can try another by clicking the emoji
face next to the species/color picker. But be aware that Neopets.com
might look different!
</Box>,
);
}
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
if (
petAppearance?.color?.id === "26" &&
petAppearance?.species?.id === "49"
) {
glitchMessages.push(
<Box key={`faerie-uni-dithering-horn-warning`}>
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
sometimes yellow. To help you design for both cases, we show the blue
horn with the feminine design, and the yellow horn with the masculine
designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time!
</Box>,
);
}
// Check if this is a Faerie Uni. If so, we'll explain the dithering horns.
if (
petAppearance?.color?.id === "26" &&
petAppearance?.species?.id === "49"
) {
glitchMessages.push(
<Box key={`faerie-uni-dithering-horn-warning`}>
The Faerie Uni is a "dithering" pet: its horn is sometimes blue, and
sometimes yellow. To help you design for both cases, we show the blue
horn with the feminine design, and the yellow horn with the masculine
designbut the pet's gender does not actually affect which horn you'll
get, and it will often change over time!
</Box>,
);
}
// Check whether the pet appearance is marked as Glitched.
if (petAppearance?.isGlitched) {
glitchMessages.push(
// NOTE: This message assumes that the current pet appearance is the
// best canonical one, but it's _possible_ to view Glitched
// appearances even if we _do_ have a better one saved... but
// only the Support UI ever takes you there.
<Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't
seen a <em>correct</em> model for this pose yet. Once someone models the
correct data, we'll use that instead. For now, you could also try
switching to another pose, by clicking the emoji face next to the
species/color picker!
</Box>,
);
}
// Check whether the pet appearance is marked as Glitched.
if (petAppearance?.isGlitched) {
glitchMessages.push(
// NOTE: This message assumes that the current pet appearance is the
// best canonical one, but it's _possible_ to view Glitched
// appearances even if we _do_ have a better one saved... but
// only the Support UI ever takes you there.
<Box key={`pet-appearance-is-glitched`}>
We know that the art for this pet is incorrect, but we still haven't
seen a <em>correct</em> model for this pose yet. Once someone models the
correct data, we'll use that instead. For now, you could also try
switching to another pose, by clicking the emoji face next to the
species/color picker!
</Box>,
);
}
const petLayers = petAppearance?.layers || [];
const petLayers = petAppearance?.layers || [];
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"OFFICIAL_SWF_IS_INCORRECT",
);
if (layerHasGlitch) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "}
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
affected its appearance on Neopets.com, too. But our version might be
out of date! If you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>,
);
}
}
// Look for pet layers with the OFFICIAL_SWF_IS_INCORRECT glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"OFFICIAL_SWF_IS_INCORRECT",
);
if (layerHasGlitch) {
glitchMessages.push(
<Box key={`official-swf-is-incorrect-for-pet-layer-${layer.id}`}>
We're aware of a glitch affecting the art for this pet's{" "}
<i>{layer.zone.label}</i> zone. Last time we checked, this glitch
affected its appearance on Neopets.com, too. But our version might be
out of date! If you've seen it look better on-site, please email me at
matchu@openneo.net so we can fix it!
</Box>,
);
}
}
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
if (hiResMode) {
for (const layer of petLayers) {
const layerHasOfficialSvgIsIncorrect = (
layer.knownGlitches || []
).includes("OFFICIAL_SVG_IS_INCORRECT");
if (layerHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that prevents us from showing the SVG image for Hi-Res Mode.
Instead, we're showing a PNG, which might look a bit blurry on
larger screens.
</Box>,
);
}
}
}
// Look for pet layers with the OFFICIAL_SVG_IS_INCORRECT glitch.
if (hiResMode) {
for (const layer of petLayers) {
const layerHasOfficialSvgIsIncorrect = (
layer.knownGlitches || []
).includes("OFFICIAL_SVG_IS_INCORRECT");
if (layerHasOfficialSvgIsIncorrect) {
glitchMessages.push(
<Box key={`official-svg-is-incorrect-for-pet-layer-${layer.id}`}>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that prevents us from showing the SVG image for Hi-Res Mode.
Instead, we're showing a PNG, which might look a bit blurry on
larger screens.
</Box>,
);
}
}
}
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
);
if (layerHasGlitch) {
glitchMessages.push(
<Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that causes it to display incorrectlybut we're not sure if it's
on our end, or TNT's. If you have this pet, please email me at
matchu@openneo.net to let us know how it looks in the on-site
customizer!
</Box>,
);
}
}
// Look for pet layers with the DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN glitch.
for (const layer of petLayers) {
const layerHasGlitch = (layer.knownGlitches || []).includes(
"DISPLAYS_INCORRECTLY_BUT_CAUSE_UNKNOWN",
);
if (layerHasGlitch) {
glitchMessages.push(
<Box
key={`displays-incorrectly-but-cause-unknown-for-pet-layer-${layer.id}`}
>
There's a glitch in the art for this pet's <i>{layer.zone.label}</i>{" "}
zone that causes it to display incorrectlybut we're not sure if it's
on our end, or TNT's. If you have this pet, please email me at
matchu@openneo.net to let us know how it looks in the on-site
customizer!
</Box>,
);
}
}
if (glitchMessages.length === 0) {
return null;
}
if (glitchMessages.length === 0) {
return null;
}
return (
<GlitchBadgeLayout
aria-label="Has known glitches"
tooltipLabel={
<Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches
</Box>
<VStack spacing="1em">{glitchMessages}</VStack>
</Box>
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug />
</GlitchBadgeLayout>
);
return (
<GlitchBadgeLayout
aria-label="Has known glitches"
tooltipLabel={
<Box>
<Box as="header" fontWeight="bold" fontSize="sm" marginBottom="1">
Known glitches
</Box>
<VStack spacing="1em">{glitchMessages}</VStack>
</Box>
}
>
<WarningTwoIcon fontSize="xs" marginRight="1" />
<FaBug />
</GlitchBadgeLayout>
);
}
export default OutfitKnownGlitchesBadge;

File diff suppressed because it is too large Load diff

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