diff --git a/app/controllers/item_appearances_controller.rb b/app/controllers/item_appearances_controller.rb new file mode 100644 index 00000000..2c72f9fc --- /dev/null +++ b/app/controllers/item_appearances_controller.rb @@ -0,0 +1,8 @@ +class ItemAppearancesController < ApplicationController + def index + @item = Item.find(params[:item_id]) + render json: @item.as_json( + only: [:id], methods: [:appearances, :restricted_zones] + ) + end +end diff --git a/app/controllers/swf_assets_controller.rb b/app/controllers/swf_assets_controller.rb index 8713837f..9229c207 100644 --- a/app/controllers/swf_assets_controller.rb +++ b/app/controllers/swf_assets_controller.rb @@ -1,60 +1,4 @@ class SwfAssetsController < ApplicationController - def index - if params[:item_id] - item = Item.find(params[:item_id]) - @swf_assets = item.swf_assets.includes_depth - if params[:body_id] - @swf_assets = @swf_assets.fitting_body_id(params[:body_id]) - else - if item.special_color - @swf_assets = @swf_assets.fitting_color(item.special_color) - else - @swf_assets = @swf_assets.fitting_standard_body_ids - end - json = @swf_assets.all.group_by(&:body_id) - end - elsif params[:pet_type_id] && params[:item_ids] - pet_type = PetType.find(params[:pet_type_id]) - - @swf_assets = SwfAsset.object_assets.includes_depth. - fitting_body_id(pet_type.body_id). - for_item_ids(params[:item_ids]). - with_parent_ids - json = @swf_assets.map { |a| a.as_json(:parent_id => a.parent_id.to_i, :for => 'wardrobe') } - elsif params[:pet_state_id] - @swf_assets = PetState.find(params[:pet_state_id]).swf_assets. - includes_depth.all - pet_state_id = params[:pet_state_id].to_i - json = @swf_assets.map { |a| a.as_json(:parent_id => pet_state_id, :for => 'wardrobe') } - elsif params[:pet_type_id] - @swf_assets = PetType.find(params[:pet_type_id]).pet_states.emotion_order - .first.swf_assets.includes_depth - elsif params[:ids] - @swf_assets = [] - if params[:ids][:biology] - @swf_assets += SwfAsset.includes_depth.biology_assets.where(:remote_id => params[:ids][:biology]).all - end - if params[:ids][:object] - @swf_assets += SwfAsset.includes_depth.object_assets.where(:remote_id => params[:ids][:object]).all - end - elsif params[:body_id] && params[:item_ids] - # DEPRECATED in favor of pet_type_id and item_ids - swf_assets = SwfAsset.arel_table - @swf_assets = SwfAsset.includes_depth.object_assets. - select('swf_assets.*, parents_swf_assets.parent_id'). - fitting_body_id(params[:body_id]). - for_item_ids(params[:item_ids]) - json = @swf_assets.map { |a| a.as_json(:parent_id => a.parent_id.to_i, :for => 'wardrobe') } - end - if @swf_assets - @swf_assets = @swf_assets.all unless @swf_assets.is_a? Array - json = @swf_assets unless json - else - json = nil - end - render :json => json - end - def show @swf_asset = SwfAsset.find params[:id] render :json => @swf_asset diff --git a/app/helpers/contribution_helper.rb b/app/helpers/contribution_helper.rb index da8a77d7..eb01776f 100644 --- a/app/helpers/contribution_helper.rb +++ b/app/helpers/contribution_helper.rb @@ -19,7 +19,7 @@ module ContributionHelper :item_link => link) output = translate("contributions.contributed_description.main.#{main_key}_html", :item_description => description) - output << image_tag(item.thumbnail.secure_url) if show_image + output << image_tag(item.thumbnail_url) if show_image output else translate('contributions.contributed_description.parents.item.blank') diff --git a/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js b/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js index 57496ee0..8838ed68 100644 --- a/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js +++ b/app/javascript/wardrobe-2020/ItemPage/SpeciesFacesPicker.js @@ -60,7 +60,7 @@ function SpeciesFacesPicker({ ); const allBodiesAreCompatible = compatibleBodies.some( - (body) => body.representsAllBodies, + (body) => body.id === "0", ); const compatibleBodyIds = compatibleBodies.map((body) => body.id); diff --git a/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js b/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js index 5e83ea20..9d6e5b47 100644 --- a/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js +++ b/app/javascript/wardrobe-2020/ItemPageOutfitPreview.js @@ -30,6 +30,7 @@ import { } 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( @@ -99,6 +100,15 @@ function ItemPageOutfitPreview({ itemId }) { 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. // @@ -123,25 +133,6 @@ function ItemPageOutfitPreview({ itemId }) { ) { item(id: $itemId) { id - name - restrictedZones { - id - label - } - compatibleBodiesAndTheirZones { - body { - id - representsAllBodies - species { - id - name - } - } - zones { - id - label - } - } canonicalAppearance( preferredSpeciesId: $preferredSpeciesId preferredColorId: $preferredColorId @@ -192,21 +183,19 @@ function ItemPageOutfitPreview({ itemId }) { }, ); - const compatibleBodies = - data?.item?.compatibleBodiesAndTheirZones?.map(({ body }) => body) || []; - const compatibleBodiesAndTheirZones = - data?.item?.compatibleBodiesAndTheirZones || []; + 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].representsAllBodies && - (data?.item?.name || "").includes( - data?.item?.canonicalAppearance?.body?.canonicalAppearance?.species?.name, - ); + compatibleBodies[0] !== "all" && + itemName.toLowerCase().includes(speciesName.toLowerCase()); const couldProbablyModelMoreData = !isProbablySpeciesSpecific; // TODO: Does this double-trigger the HTTP request with SpeciesColorPicker? @@ -257,7 +246,7 @@ function ItemPageOutfitPreview({ itemId }) { const borderColor = useColorModeValue("green.700", "green.400"); const errorColor = useColorModeValue("red.600", "red.400"); - const error = errorGQL || errorValids; + const error = errorGQL || errorAppearances || errorValids; if (error) { return {error.message}; } @@ -350,6 +339,7 @@ function ItemPageOutfitPreview({ itemId }) { // 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 && ( @@ -386,14 +376,14 @@ function ItemPageOutfitPreview({ itemId }) { compatibleBodies={compatibleBodies} couldProbablyModelMoreData={couldProbablyModelMoreData} onChange={onChange} - isLoading={loadingGQL || loadingValids} + isLoading={loadingGQL || loadingAppearances || loadingValids} /> - {compatibleBodiesAndTheirZones.length > 0 && ( + {itemAppearances.length > 0 && ( )} @@ -526,13 +516,13 @@ function PlayPauseButton({ isPaused, onClick }) { ); } -function ItemZonesInfo({ compatibleBodiesAndTheirZones, restrictedZones }) { +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, zones } of compatibleBodiesAndTheirZones) { - for (const zone of zones) { + for (const { body, swfAssets } of itemAppearances) { + for (const { zone } of swfAssets) { if (!zoneLabelsAndTheirBodiesMap[zone.label]) { zoneLabelsAndTheirBodiesMap[zone.label] = { zoneLabel: zone.label, diff --git a/app/javascript/wardrobe-2020/loaders/items.js b/app/javascript/wardrobe-2020/loaders/items.js new file mode 100644 index 00000000..4b796bce --- /dev/null +++ b/app/javascript/wardrobe-2020/loaders/items.js @@ -0,0 +1,61 @@ +import { useQuery } from "@tanstack/react-query"; + +export function useItemAppearances(id, options = {}) { + return useQuery({ + ...options, + queryKey: ["items", String(id)], + queryFn: () => loadItemAppearancesData(id), + }); +} + +async function loadItemAppearancesData(id) { + const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`); + + if (!res.ok) { + throw new Error( + `loading item appearances failed: ${res.status} ${res.statusText}`, + ); + } + + return res.json().then(normalizeItemAppearancesData); +} + +function normalizeItemAppearancesData(data) { + return { + name: data.name, + appearances: data.appearances.map((appearance) => ({ + body: normalizeBody(appearance.body), + swfAssets: appearance.swf_assets.map((asset) => ({ + id: String(asset.id), + knownGlitches: asset.known_glitches, + zone: normalizeZone(asset.zone), + restrictedZones: asset.restricted_zones.map((z) => normalizeZone(z)), + urls: { + swf: asset.urls.swf, + png: asset.urls.png, + manifest: asset.urls.manifest, + }, + })), + })), + restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)), + }; +} + +function normalizeBody(body) { + if (String(body.id) === "0") { + return { id: "0" }; + } + + return { + id: String(body.id), + species: { + id: String(body.species.id), + name: body.species.name, + humanName: body.species.humanName, + }, + }; +} + +function normalizeZone(zone) { + return { id: String(zone.id), label: zone.label, depth: zone.depth }; +} diff --git a/app/javascript/wardrobe-2020/loaders/outfits.js b/app/javascript/wardrobe-2020/loaders/outfits.js index ac73ab6c..2063e7c3 100644 --- a/app/javascript/wardrobe-2020/loaders/outfits.js +++ b/app/javascript/wardrobe-2020/loaders/outfits.js @@ -1,6 +1,6 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; -export function useSavedOutfit(id, options) { +export function useSavedOutfit(id, options = {}) { return useQuery({ ...options, queryKey: ["outfits", String(id)], diff --git a/app/models/image.rb b/app/models/image.rb deleted file mode 100644 index 75f027aa..00000000 --- a/app/models/image.rb +++ /dev/null @@ -1,14 +0,0 @@ -class Image - attr_reader :insecure_url, :secure_url - - def initialize(insecure_url, secure_url) - @insecure_url = insecure_url - @secure_url = secure_url - end - - def self.from_insecure_url(insecure_url) - # TODO: We used to use a "Camo" server for this, but we don't anymore. - # Replace this with actual logic to actually secure the URLs! - Image.new insecure_url, insecure_url - end -end diff --git a/app/models/item.rb b/app/models/item.rb index a842e53e..8e8aae06 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -413,38 +413,11 @@ class Item < ApplicationRecord modeled_body_ids.size.to_f / predicted_body_ids.size end - def thumbnail - if thumbnail_url.present? - url = thumbnail_url - else - url = ActionController::Base.helpers.asset_path( - "broken_item_thumbnail.gif") - end - @thumbnail ||= Image.from_insecure_url(url) - end - def as_json(options={}) - json = { - :description => description, - :id => id, - :name => name, - :thumbnail_url => thumbnail.secure_url, - :zones_restrict => zones_restrict, - :rarity_index => rarity_index, - :nc => nc? - } - - # Set owned and wanted keys, unless explicitly told not to. (For example, - # item proxies don't want us to bother, since they'll override.) - unless options.has_key?(:include_hanger_status) - options[:include_hanger_status] = true - end - if options[:include_hanger_status] - json[:owned] = owned? - json[:wanted] = wanted? - end - - json + super({ + only: [:id, :name, :description, :thumbnail_url, :rarity_index], + methods: [:zones_restrict], + }.merge(options)) end before_create do @@ -511,17 +484,34 @@ class Item < ApplicationRecord def parent_swf_asset_relationships_to_update=(rels) @parent_swf_asset_relationships_to_update = rels end - - def needed_translations - translatable_locales = Set.new(I18n.locales_with_neopets_language_code) - translated_locales = Set.new(translations.map(&:locale)) - translatable_locales - translated_locales - end - def method_cached?(method_name) - # No methods are cached on a full item. This is for duck typing with item - # proxies. - false + Body = Struct.new(:id, :species) + Appearance = Struct.new(:body, :swf_assets) + def appearances + all_swf_assets = swf_assets.to_a + + # If there are no assets yet, there are no appearances. + return [] if all_swf_assets.empty? + + # Get all SWF assets, and separate the ones that fit everyone (body_id=0). + swf_assets_by_body_id = all_swf_assets.group_by(&:body_id) + swf_assets_for_all_bodies = swf_assets_by_body_id.delete(0) || [] + + # If there are no body-specific assets, return one appearance for them all. + if swf_assets_by_body_id.empty? + body = Body.new(0, nil) + return [Appearance.new(body, swf_assets_for_all_bodies)] + end + + # Otherwise, create an appearance for each real (nonzero) body ID. We don't + # generally expect body_id = 0 and body_id != 0 to mix, but if they do, + # uhh, let's merge the body_id = 0 ones in? + swf_assets_by_body_id.map do |body_id, body_specific_assets| + swf_assets_for_body = body_specific_assets + swf_assets_for_all_bodies + species = Species.with_body_id(body_id).first! + body = Body.new(body_id, species) + Appearance.new(body, swf_assets_for_body) + end end def self.all_by_ids_or_children(ids, swf_assets) diff --git a/app/models/species.rb b/app/models/species.rb index 2bbbbbc0..d20e1726 100644 --- a/app/models/species.rb +++ b/app/models/species.rb @@ -1,5 +1,6 @@ class Species < ApplicationRecord translates :name + has_many :pet_types scope :alphabetical, -> { st = Species::Translation.arel_table @@ -12,6 +13,11 @@ class Species < ApplicationRecord where(st[:name].matches(sanitize_sql_like(name))) } + scope :with_body_id, -> body_id { + pt = PetType.arel_table + joins(:pet_types).where(pt[:body_id].eq(body_id)).limit(1) + } + # TODO: Should we consider replacing this at call sites? This used to be # built into the globalize gem but isn't anymore! def self.find_by_name(name) @@ -19,7 +25,7 @@ class Species < ApplicationRecord end def as_json(options={}) - {:id => id, :name => human_name} + super({only: [:id, :name], methods: [:human_name]}.merge(options)) end def human_name diff --git a/app/models/swf_asset.rb b/app/models/swf_asset.rb index dc8a763c..67af14a6 100644 --- a/app/models/swf_asset.rb +++ b/app/models/swf_asset.rb @@ -96,22 +96,41 @@ class SwfAsset < ApplicationRecord end def as_json(options={}) - json = { - :id => remote_id, - :type => type, - :depth => depth, - :body_id => body_id, - :zone_id => zone_id, - :zones_restrict => zones_restrict, - :is_body_specific => body_specific?, - # Now that we don't proactively convert images anymore, let's just always - # say `has_image: true` when sending data to the frontend, so it'll use the - # new URLs anyway! - :has_image => true, - :images => images + super({ + only: [:id, :known_glitches], + methods: [:zone, :restricted_zones, :urls] + }.merge(options)) + end + + def urls + { + swf: url, + png: image_url, + manifest: manifest_url, } - json[:parent_id] = options[:parent_id] if options[:parent_id] - json + end + + def known_glitches + self[:known_glitches].split(',') + end + + def known_glitches=(new_known_glitches) + if new_known_glitches.is_a? Array + new_known_glitches = new_known_glitches.join(',') + end + self[:known_glitches] = new_known_glitches + end + + def restricted_zone_ids + [].tap do |ids| + zones_restrict.chars.each_with_index do |bit, index| + ids << index + 1 if bit == "1" + end + end + end + + def restricted_zones + Zone.where(id: restricted_zone_ids) end def body_specific? diff --git a/app/models/zone.rb b/app/models/zone.rb index 887fba03..cf4050d1 100644 --- a/app/models/zone.rb +++ b/app/models/zone.rb @@ -18,6 +18,10 @@ class Zone < ActiveRecord::Base } scope :for_items, -> { where(arel_table[:type_id].gt(1)) } + def as_json(options={}) + super({only: [:id, :depth, :label]}.merge(options)) + end + def uncertain_label @sometimes ? "#{label} sometimes" : label end diff --git a/app/views/items/_item_link.html.haml b/app/views/items/_item_link.html.haml index 27fb65ef..e50aafd3 100644 --- a/app/views/items/_item_link.html.haml +++ b/app/views/items/_item_link.html.haml @@ -1,4 +1,4 @@ = link_to item_path(item) do - = image_tag item.thumbnail.secure_url, :alt => item.description, :title => item.description + = image_tag item.thumbnail_url, :alt => item.description, :title => item.description %span.name= item.name = nc_icon_for(item) diff --git a/app/views/items/show.html.haml b/app/views/items/show.html.haml index 8dfc521b..4cb64ce9 100644 --- a/app/views/items/show.html.haml +++ b/app/views/items/show.html.haml @@ -2,7 +2,7 @@ - canonical_path @item %header#item-header - = image_tag @item.thumbnail.secure_url, :id => 'item-thumbnail' + = image_tag @item.thumbnail_url, :id => 'item-thumbnail' %div %h2#item-name= @item.name = nc_icon_for(@item) diff --git a/app/views/outfits/new.html.haml b/app/views/outfits/new.html.haml index f49c7e83..3031052d 100644 --- a/app/views/outfits/new.html.haml +++ b/app/views/outfits/new.html.haml @@ -88,7 +88,7 @@ - @newest_unmodeled_items.each do |item| - localized_cache "items/#{item.id} modeling_progress updated_at=#{item.updated_at.to_i}" do %li{'data-item-id' => item.id} - = link_to image_tag(item.thumbnail.secure_url), item, :class => 'image-link' + = link_to image_tag(item.thumbnail_url), item, :class => 'image-link' = link_to item, :class => 'header' do %h2= item.name %span.meter{style: "width: #{@newest_unmodeled_items_predicted_modeled_ratio[item]*100}%"} @@ -101,7 +101,7 @@ - @newest_modeled_items.each do |item| %li.object = link_to item, title: item.name, alt: item.name do - = image_tag item.thumbnail.secure_url + = image_tag item.thumbnail_url = nc_icon_for(item) diff --git a/config/routes.rb b/config/routes.rb index 0ed310c7..3d02b1e6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -7,25 +7,18 @@ OpenneoImpressItems::Application.routes.draw do get '/wardrobe' => redirect('/outfits/new') get '/start/:color_name/:species_name' => 'outfits#start' - # DEPRECATED - get '/bodies/:body_id/swf_assets.json' => 'swf_assets#index', :as => :body_swf_assets - - get '/items/:item_id/swf_assets.json' => 'swf_assets#index', :as => :item_swf_assets - get '/items/:item_id/bodies/:body_id/swf_assets.json' => 'swf_assets#index', :as => :item_swf_assets_for_body_id - get '/pet_types/:pet_type_id/swf_assets.json' => 'swf_assets#index', :as => :pet_type_swf_assets - get '/pet_types/:pet_type_id/items/swf_assets.json' => 'swf_assets#index', :as => :item_swf_assets_for_pet_type - get '/pet_states/:pet_state_id/swf_assets.json' => 'swf_assets#index', :as => :pet_state_swf_assets get '/species/:species_id/color/:color_id/pet_type.json' => 'pet_types#show' resources :contributions, :only => [:index] resources :items, :only => [:index, :show] do + resources :appearances, controller: 'item_appearances', only: [:index] collection do get :needed end end resources :outfits, :only => [:show, :create, :update, :destroy] resources :pet_attributes, :only => [:index] - resources :swf_assets, :only => [:index, :show] do + resources :swf_assets, :only => [:show] do collection do get :links end