Compare commits

...

8 commits

Author SHA1 Message Date
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
14 changed files with 154 additions and 38 deletions

View file

@ -3,17 +3,36 @@ document.addEventListener("change", (e) => {
if (!e.target.matches("species-face-picker")) return; if (!e.target.matches("species-face-picker")) return;
try { try {
const mainPicker = document.querySelector("#item-preview .species-color-picker"); const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form");
const mainSpeciesField = const mainSpeciesField =
mainPicker.querySelector("[name='preview[species_id]']"); mainPickerForm.querySelector("[name='preview[species_id]']");
mainSpeciesField.value = e.target.value; mainSpeciesField.value = e.target.value;
mainPicker.requestSubmit(); // `submit` doesn't get captured by Turbo! mainPickerForm.requestSubmit(); // `submit` doesn't get captured by Turbo!
} catch (error) { } catch (error) {
e.preventDefault();
console.error("Couldn't update species picker: ", error); console.error("Couldn't update species picker: ", error);
} }
}); });
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 { class SpeciesFacePicker extends HTMLElement {
connectedCallback() { connectedCallback() {
this.addEventListener("click", this.#handleClick); this.addEventListener("click", this.#handleClick);
@ -52,5 +71,6 @@ class SpeciesFacePickerOptions extends HTMLElement {
} }
} }
customElements.define("species-color-picker", SpeciesColorPicker);
customElements.define("species-face-picker", SpeciesFacePicker); customElements.define("species-face-picker", SpeciesFacePicker);
customElements.define("species-face-picker-options", SpeciesFacePickerOptions); customElements.define("species-face-picker-options", SpeciesFacePickerOptions);

View file

@ -165,9 +165,12 @@ class OutfitLayer extends HTMLElement {
#sendMessageToIframe(message) { #sendMessageToIframe(message) {
// If we have no frame or it hasn't loaded, ignore this message. // If we have no frame or it hasn't loaded, ignore this message.
if (this.iframe?.contentWindow == null) { if (this.iframe == null) {
return;
}
if (this.iframe.contentWindow == null) {
console.debug( console.debug(
`Ignoring message, frame not loaded: `, `Ignoring message, frame not loaded yet: `,
this.iframe, this.iframe,
message, message,
); );

View file

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

View file

@ -0,0 +1,12 @@
body.use-responsive-design
#container
max-width: 100%
padding-inline: 1rem
box-sizing: border-box
#home-link
margin-left: 1rem
padding-inline: 0
#userbar
margin-right: 1rem

View file

@ -4,6 +4,7 @@
@import partials/clean/mixins @import partials/clean/mixins
@import layout @import layout
@import responsive
@import partials/jquery.jgrowl @import partials/jquery.jgrowl

View file

@ -46,7 +46,7 @@ body.items-show
border: 1px solid $module-border-color border: 1px solid $module-border-color
border-radius: 1em border-radius: 1em
overflow: hidden overflow: hidden
margin: 0 auto .75em margin: 0 auto
// There's no useful text in here, but double-clicking the play/pause // There's no useful text in here, but double-clicking the play/pause
// button can cause a weird selection state. Disable text selection. // button can cause a weird selection state. Disable text selection.
@ -151,22 +151,42 @@ body.items-show
.error-indicator .error-indicator
display: block display: block
.species-color-picker species-color-picker
.error-icon .error-icon
cursor: help cursor: help
margin-right: .25em margin-right: .25em
&[data-is-valid="false"] form[data-is-valid="false"]
select select
border-color: $error-border-color border-color: $error-border-color
color: $error-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 species-face-picker
display: block display: block
position: relative position: relative
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
margin-top: -10px
overflow: auto
species-face-picker-options species-face-picker-options
display: flex display: flex
justify-content: center
flex-wrap: wrap flex-wrap: wrap
img img
@ -236,8 +256,6 @@ body.items-show
cursor: wait cursor: wait
.item-zones-info .item-zones-info
margin-top: .5em
h3 h3
display: inline display: inline
font: inherit font: inherit
@ -261,3 +279,35 @@ body.items-show
.zone-species-info .zone-species-info
font-style: italic font-style: italic
text-decoration: underline dotted text-decoration: underline dotted
#item-preview
display: flex
flex-direction: column
gap: .75em
@media (min-width: 600px)
display: grid
grid-template-areas: "viewer faces" "picker zones"
gap: .5em
outfit-viewer
grid-area: viewer
width: 350px
height: 350px
species-color-picker
grid-area: picker
species-face-picker
grid-area: faces
max-height: 350px
margin: -10px
.item-zones-info
grid-area: zones
@keyframes fade-in
from
opacity: 0
to
opacity: 1

View file

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

View file

@ -95,7 +95,7 @@ class ItemsController < ApplicationController
sort_by { |z, a| z.label } sort_by { |z, a| z.label }
@preview_pet_type_options = PetType.where(color: @preview_outfit.color). @preview_pet_type_options = PetType.where(color: @preview_outfit.color).
joins(:species).merge(Species.alphabetical) includes(:species).merge(Species.alphabetical)
end end
format.gif do format.gif do

View file

@ -1,6 +1,6 @@
module ApplicationHelper module ApplicationHelper
include FragmentLocalization include FragmentLocalization
def absolute_url(path_or_url) def absolute_url(path_or_url)
if path_or_url.include?('://') # already an absolute URL if path_or_url.include?('://') # already an absolute URL
path_or_url path_or_url
@ -231,6 +231,15 @@ module ApplicationHelper
@hide_title_header = true @hide_title_header = true
end end
def use_responsive_design
@use_responsive_design = true
add_body_class "use-responsive-design"
end
def use_responsive_design?
@use_responsive_design || false
end
def signed_in_meta_tag def signed_in_meta_tag
%(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe %(<meta name="user-signed-in" content="#{user_signed_in?}">).html_safe
end end

View file

@ -533,6 +533,10 @@ class Item < ApplicationRecord
end end
def appearances def appearances
@appearances ||= build_appearances
end
def build_appearances
all_swf_assets = swf_assets.to_a all_swf_assets = swf_assets.to_a
# If there are no assets yet, there are no appearances. # If there are no assets yet, there are no appearances.
@ -551,10 +555,10 @@ class Item < ApplicationRecord
# Otherwise, create an appearance for each real (nonzero) body ID. We don't # Otherwise, create an appearance for each real (nonzero) body ID. We don't
# generally expect body_id = 0 and body_id != 0 to mix, but if they do, # generally expect body_id = 0 and body_id != 0 to mix, but if they do,
# uhh, let's merge the body_id = 0 ones in? # uhh, let's merge the body_id = 0 ones in?
species_by_body_id = Species.with_body_ids(swf_assets_by_body_id.keys)
swf_assets_by_body_id.map do |body_id, body_specific_assets| swf_assets_by_body_id.map do |body_id, body_specific_assets|
swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies
species = Species.with_body_id(body_id).first! body = Appearance::Body.new(body_id, species_by_body_id[body_id])
body = Appearance::Body.new(body_id, species)
Appearance.new(self, body, swf_assets_for_body) Appearance.new(self, body, swf_assets_for_body)
end end
end end

View file

@ -3,11 +3,6 @@ class Species < ApplicationRecord
has_many :alt_styles has_many :alt_styles
scope :alphabetical, -> { order(:name) } scope :alphabetical, -> { order(:name) }
scope :with_body_id, -> body_id {
pt = PetType.arel_table
joins(:pet_types).where(pt[:body_id].eq(body_id)).limit(1)
}
def as_json(options={}) def as_json(options={})
super({only: [:id, :name], methods: [:human_name]}.merge(options)) super({only: [:id, :name], methods: [:human_name]}.merge(options))
@ -20,4 +15,15 @@ class Species < ApplicationRecord
I18n.translate('species.default_human_name') I18n.translate('species.default_human_name')
end end
end end
# Given a list of body IDs, return a hash from body ID to Species.
# (We assume that each body ID belongs to just one species; if not, which
# species we return for that body ID is undefined.)
def self.with_body_ids(body_ids)
species_ids_by_body_id = PetType.where(body_id: body_ids).distinct.
pluck(:body_id, :species_id).to_h
species_by_id = Species.where(id: species_ids_by_body_id.values).
to_h { |s| [s.id, s] }
species_ids_by_body_id.transform_values { |id| species_by_id[id] }
end
end end

View file

@ -1,5 +1,6 @@
- title @item.name - title @item.name
- canonical_path @item - canonical_path @item
- use_responsive_design
= render partial: "item_header", = render partial: "item_header",
locals: {item: @item, trades: @trades, current_subpage: "preview", locals: {item: @item, trades: @trades, current_subpage: "preview",
@ -18,20 +19,20 @@
.error-indicator .error-indicator
💥 We couldn't load all of this outfit. Try again? 💥 We couldn't load all of this outfit. Try again?
= form_for item_path(@item), method: :get, class: "species-color-picker", %species-color-picker
data: {"is-valid": @preview_error.nil?} do |f| = form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist - if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️ %span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data - elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️ %span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
= select_tag "preview[color_id]", = select_tag "preview[color_id]",
options_from_collection_for_select(Color.funny.alphabetical, options_from_collection_for_select(Color.funny.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id) "id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]", = select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical, options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id) "id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil = submit_tag "Go", name: nil
%species-face-picker %species-face-picker
%noscript %noscript

View file

@ -13,6 +13,8 @@
%link{href: image_path('favicon.png'), rel: 'icon'} %link{href: image_path('favicon.png'), rel: 'icon'}
= yield :stylesheets = yield :stylesheets
= stylesheet_link_tag "application" = stylesheet_link_tag "application"
- if use_responsive_design?
%meta{name: "viewport", content: "width=device-width, initial-scale=1"}
= yield :meta = yield :meta
= open_graph_tags = open_graph_tags
= csrf_meta_tag = csrf_meta_tag

View file

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