diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index c2e2d6ee..e640dc9c 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -30,8 +30,22 @@ class ItemsController < ApplicationController items: @items.as_json( methods: [:nc?, :pb?, :owned?, :wanted?], ), - appearances: load_appearances, + appearances: load_appearances.as_json( + include: { + swf_assets: { + only: [:id, :remote_id, :body_id], + include: { + zone: { + only: [:id, :depth, :label], + methods: [:is_commonly_used_by_items], + } + }, + methods: [:urls, :known_glitches], + }, + } + ), total_pages: @items.total_pages, + total_count: @items.count, query: @query.to_s, } } @@ -108,7 +122,7 @@ class ItemsController < ApplicationController return {} if pet_type_name.blank? pet_type = Item::Search::Query.load_pet_type_by_name(pet_type_name) - pet_type.appearances_for(@items.map(&:id)) + pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]) end def search_error(e) diff --git a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js index 0c52b7cf..0bd63e16 100644 --- a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js +++ b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js @@ -41,6 +41,9 @@ export function useSearchResults( const currentPageIndex = currentPageNumber - 1; const offset = currentPageIndex * SEARCH_PER_PAGE; + // const filters = buildSearchFilters(/* TODO */); + // const { loading, error, data } = useItemSearch(filters); + // Here's the actual GQL query! At the bottom we have more config than usual! const { loading: loadingGQL, diff --git a/app/javascript/wardrobe-2020/loaders/alt-styles.js b/app/javascript/wardrobe-2020/loaders/alt-styles.js index a66f81db..4b761bbe 100644 --- a/app/javascript/wardrobe-2020/loaders/alt-styles.js +++ b/app/javascript/wardrobe-2020/loaders/alt-styles.js @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { normalizeSwfAssetToLayer } from "./shared-types"; + export function useAltStylesForSpecies(speciesId, options = {}) { return useQuery({ ...options, @@ -63,21 +65,3 @@ function normalizeAltStyle(altStyleData) { }, }; } - -function normalizeSwfAssetToLayer(swfAssetData) { - return { - id: String(swfAssetData.id), - zone: { - id: String(swfAssetData.zone.id), - depth: swfAssetData.zone.depth, - label: swfAssetData.zone.label, - }, - bodyId: swfAssetData.body_id, - knownGlitches: swfAssetData.known_glitches, - - svgUrl: swfAssetData.urls.svg, - canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, - imageUrl: swfAssetData.urls.png, - swfUrl: swfAssetData.urls.swf, - }; -} diff --git a/app/javascript/wardrobe-2020/loaders/items.js b/app/javascript/wardrobe-2020/loaders/items.js index 8b193c2c..ffc3a7f3 100644 --- a/app/javascript/wardrobe-2020/loaders/items.js +++ b/app/javascript/wardrobe-2020/loaders/items.js @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { normalizeSwfAssetToLayer } from "./shared-types"; + export function useItemAppearances(id, options = {}) { return useQuery({ ...options, @@ -9,7 +11,9 @@ export function useItemAppearances(id, options = {}) { } async function loadItemAppearancesData(id) { - const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`); + const res = await fetch( + `/items/${encodeURIComponent(id)}/appearances.json`, + ); if (!res.ok) { throw new Error( @@ -20,27 +24,95 @@ async function loadItemAppearancesData(id) { return res.json().then(normalizeItemAppearancesData); } +export function useItemSearch(searchOptions, queryOptions = {}) { + return useQuery({ + ...queryOptions, + queryKey: ["itemSearch", buildItemSearchParams(searchOptions)], + queryFn: () => loadItemSearch(searchOptions), + }); +} + +function buildItemSearchParams({ + filters = [], + withAppearancesFor = null, + page = 1, + perPage = 30, +}) { + const params = new URLSearchParams(); + for (const [i, filter] of filters.entries()) { + params.append(`q[${i}][key]`, filter.key); + params.append(`q[${i}][value]`, filter.value); + if (params.isPositive == false) { + params.append(`q[${i}][is_positive]`, "false"); + } + } + if (withAppearancesFor != null) { + params.append("with_appearances_for", withAppearancesFor); + } + params.append("page", page); + params.append("per_page", perPage); + return params.toString(); +} + +async function loadItemSearch(searchOptions) { + const params = buildItemSearchParams(searchOptions); + + const res = await fetch(`/items.json?${params}`); + + if (!res.ok) { + throw new Error( + `loading item search failed: ${res.status} ${res.statusText}`, + ); + } + + return res + .json() + .then((data) => normalizeItemSearchData(data, searchOptions)); +} +window.loadItemSearch = loadItemSearch; + 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, - }, - })), + swfAssets: appearance.swf_assets.map(normalizeSwfAssetToLayer), })), restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)), }; } +function normalizeItemSearchData(data, searchOptions) { + return { + id: buildItemSearchParams(searchOptions), + numTotalItems: data.total_count, + items: data.items.map((item) => ({ + id: String(item.id), + name: item.name, + thumbnailUrl: item.thumbnail_url, + isNc: item["nc?"], + isPb: item["pb?"], + currentUserOwnsThis: item["owned?"], + currentUserWantsThis: item["wanted?"], + appearanceOn: normalizeItemSearchAppearance( + data.appearances[item.id], + item, + ), + })), + }; +} + +function normalizeItemSearchAppearance(data, item) { + if (data == null) { + return null; + } + + return { + id: `item-${item.id}-body-${data.body.id}`, + layers: data.swf_assets.map(normalizeSwfAssetToLayer), + }; +} + function normalizeBody(body) { if (String(body.id) === "0") { return { id: "0" }; diff --git a/app/javascript/wardrobe-2020/loaders/shared-types.js b/app/javascript/wardrobe-2020/loaders/shared-types.js new file mode 100644 index 00000000..55d585e9 --- /dev/null +++ b/app/javascript/wardrobe-2020/loaders/shared-types.js @@ -0,0 +1,19 @@ +export function normalizeSwfAssetToLayer(swfAssetData) { + return { + id: String(swfAssetData.id), + remoteId: String(swfAssetData.remote_id), + zone: { + id: String(swfAssetData.zone.id), + depth: swfAssetData.zone.depth, + label: swfAssetData.zone.label, + isCommonlyUsedByItems: swfAssetData.zone.is_commonly_used_by_items, + }, + bodyId: swfAssetData.body_id, + knownGlitches: swfAssetData.known_glitches, + + svgUrl: swfAssetData.urls.svg, + canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, + imageUrl: swfAssetData.urls.png, + swfUrl: swfAssetData.urls.swf, + }; +} diff --git a/app/models/item.rb b/app/models/item.rb index 772e04b0..ea223afc 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -445,8 +445,23 @@ class Item < ApplicationRecord @parent_swf_asset_relationships_to_update = rels end - Appearance = Struct.new(:body, :swf_assets) - Appearance::Body = Struct.new(:id, :species) + # NOTE: Adding the JSON serializer makes `as_json` treat this like a model + # instead of like a hash, so you can target its children with things like + # the `include` option. This feels clunky though, I wish I had something a + # bit more suited to it! + Appearance = Struct.new(:body, :swf_assets) do + include ActiveModel::Serializers::JSON + def attributes + {body: body, swf_assets: swf_assets} + end + end + Appearance::Body = Struct.new(:id, :species) do + include ActiveModel::Serializers::JSON + def attributes + {id: id, species: species} + end + end + def appearances all_swf_assets = swf_assets.to_a diff --git a/app/models/pet_type.rb b/app/models/pet_type.rb index 361b05f2..54686fec 100644 --- a/app/models/pet_type.rb +++ b/app/models/pet_type.rb @@ -169,16 +169,27 @@ class PetType < ApplicationRecord }.first end - def appearances_for(item_ids) + def appearances_for(item_ids, swf_asset_includes: []) # First, load all the relationships for these items that also fit this # body. - relationships = ParentSwfAssetRelationship.includes(:swf_asset). + relationships = ParentSwfAssetRelationship. + includes(swf_asset: swf_asset_includes). where(parent_type: "Item", parent_id: item_ids). where(swf_asset: {body_id: [body_id, 0]}) - # Then, convert this into a hash from item ID to SWF assets. - assets_by_item_id = relationships.group_by(&:parent_id). - transform_values { |rels| rels.map(&:swf_asset) } + pet_type_body = Item::Appearance::Body.new(body_id, species) + all_pets_body = Item::Appearance::Body.new(0, nil) + + # Then, convert this into a hash from item ID to appearances. + relationships.group_by(&:parent_id). + transform_values do |rels| + assets = rels.map(&:swf_asset) + if assets.all? { |a| a.body_id == 0 } + Item::Appearance.new all_pets_body, assets + else + Item::Appearance.new pet_type_body, assets + end + end end def self.all_by_ids_or_children(ids, pet_states) diff --git a/app/models/zone.rb b/app/models/zone.rb index 602f80f4..92e6ad93 100644 --- a/app/models/zone.rb +++ b/app/models/zone.rb @@ -1,4 +1,4 @@ -class Zone < ActiveRecord::Base +class Zone < ActiveRecord::Base # When selecting zones that an asset occupies, we allow the zone to set # whether or not the zone is "sometimes" occupied. This is false by default. attr_writer :sometimes @@ -16,6 +16,14 @@ class Zone < ActiveRecord::Base def uncertain_label @sometimes ? "#{label} sometimes" : label end + + def is_commonly_used_by_items + # Zone metadata marks item zones with types 2, 3, and 4. But also, in + # practice, the Biology Effects zone (type 1, ID 4) has been used for a few + # items too. So, that's what we return true for! + # TODO: It'd probably be better to make this a database field? + [2, 3, 4].include?(type_id) || id == 4 + end def self.plainify_label(label) label.delete('\- /').parameterize