diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb
index 22f591e9..ec4b1e32 100644
--- a/app/controllers/outfits_controller.rb
+++ b/app/controllers/outfits_controller.rb
@@ -2,7 +2,9 @@ class OutfitsController < ApplicationController
before_action :find_authorized_outfit, :only => [:update, :destroy]
def create
- @outfit = Outfit.build_for_user(current_user, outfit_params)
+ @outfit = Outfit.new(outfit_params)
+ @outfit.user = current_user
+
if @outfit.save
render :json => @outfit
else
@@ -81,7 +83,11 @@ class OutfitsController < ApplicationController
def show
@outfit = Outfit.find(params[:id])
- render "outfits/edit", layout: false
+
+ respond_to do |format|
+ format.html { render "outfits/edit", layout: false }
+ format.json { render json: @outfit }
+ end
end
def start
@@ -100,8 +106,7 @@ class OutfitsController < ApplicationController
end
def update
- @outfit.attributes = outfit_params
- if @outfit.save
+ if @outfit.update(outfit_params)
render :json => @outfit
else
render_outfit_errors
@@ -112,7 +117,8 @@ class OutfitsController < ApplicationController
def outfit_params
params.require(:outfit).permit(
- :name, :pet_state_id, :starred, :worn_and_unworn_item_ids, :anonymous)
+ :name, :starred, item_ids: {worn: [], closeted: []},
+ biology: [:species_id, :color_id, :pose])
end
def find_authorized_outfit
diff --git a/app/javascript/wardrobe-2020/AppProvider.js b/app/javascript/wardrobe-2020/AppProvider.js
index a47a20d4..90d48f4c 100644
--- a/app/javascript/wardrobe-2020/AppProvider.js
+++ b/app/javascript/wardrobe-2020/AppProvider.js
@@ -5,19 +5,24 @@ import { ChakraProvider, Box, useColorModeValue } from "@chakra-ui/react";
import { ApolloProvider } from "@apollo/client";
import { BrowserRouter } from "react-router-dom";
import { Global } from "@emotion/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import buildApolloClient from "./apolloClient";
+const reactQueryClient = new QueryClient();
+
export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []);
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
}
diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
index cc35a19f..4bc61618 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js
@@ -6,6 +6,7 @@ import useCurrentUser from "../components/useCurrentUser";
import gql from "graphql-tag";
import { useMutation } from "@apollo/client";
import { outfitStatesAreEqual } from "./useOutfitState";
+import { useSaveOutfitMutation } from "../loaders/outfits";
function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser();
@@ -52,100 +53,25 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
- const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation(
- gql`
- mutation UseOutfitSaving_SaveOutfit(
- $id: ID # Optional, is null when saving new outfits.
- $name: String # Optional, server may fill in a placeholder.
- $speciesId: ID!
- $colorId: ID!
- $pose: Pose!
- $wornItemIds: [ID!]!
- $closetedItemIds: [ID!]!
+ const saveOutfitMutation = useSaveOutfitMutation({
+ onSuccess: (outfit) => {
+ if (
+ String(outfit.id) === String(outfitState.id) &&
+ outfit.name !== outfitState.name
) {
- outfit: saveOutfit(
- id: $id
- name: $name
- speciesId: $speciesId
- colorId: $colorId
- pose: $pose
- wornItemIds: $wornItemIds
- closetedItemIds: $closetedItemIds
- ) {
- id
- name
- petAppearance {
- id
- species {
- id
- }
- color {
- id
- }
- pose
- }
- wornItems {
- id
- }
- closetedItems {
- id
- }
- creator {
- id
- }
- }
- }
- `,
- {
- context: { sendAuth: true },
- update: (cache, { data: { outfit } }) => {
- // After save, add this outfit to the current user's outfit list. This
- // will help when navigating back to Your Outfits, to force a refresh.
- // https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
- cache.modify({
- id: cache.identify(outfit.creator),
- fields: {
- outfits: (existingOutfitRefs = [], { readField }) => {
- const isAlreadyInList = existingOutfitRefs.some(
- (ref) => readField("id", ref) === outfit.id,
- );
- if (isAlreadyInList) {
- return existingOutfitRefs;
- }
-
- const newOutfitRef = cache.writeFragment({
- data: outfit,
- fragment: gql`
- fragment NewOutfit on Outfit {
- id
- }
- `,
- });
-
- return [...existingOutfitRefs, newOutfitRef];
- },
- },
+ dispatchToOutfit({
+ type: "rename",
+ outfitName: outfit.name,
});
-
- // Also, send a `rename` action, if this is still the current outfit,
- // and the server renamed it (e.g. "Untitled outfit (1)"). (It's
- // tempting to do a full reset, in case the server knows something we
- // don't, but we don't want to clobber changes the user made since
- // starting the save!)
- if (outfit.id === outfitState.id && outfit.name !== outfitState.name) {
- dispatchToOutfit({
- type: "rename",
- outfitName: outfit.name,
- });
- }
- },
+ }
},
- );
+ });
+ const isSaving = saveOutfitMutation.isPending;
const saveOutfitFromProvidedState = React.useCallback(
(outfitState) => {
- sendSaveOutfitMutation({
- variables: {
+ saveOutfitMutation
+ .mutateAsync({
id: outfitState.id,
name: outfitState.name,
speciesId: outfitState.speciesId,
@@ -153,9 +79,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
pose: outfitState.pose,
wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: [...outfitState.closetedItemIds],
- },
- })
- .then(({ data: { outfit } }) => {
+ })
+ .then((outfit) => {
// Navigate to the new saved outfit URL. Our Apollo cache should pick
// up the data from this mutation response, and combine it with the
// existing cached data, to make this smooth without any loading UI.
@@ -176,7 +101,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the
// debounced state!
- [sendSaveOutfitMutation, pathname, navigate, toast],
+ [saveOutfitMutation.mutateAsync, pathname, navigate, toast],
);
const saveOutfit = React.useCallback(
diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
index 791ada95..25533b50 100644
--- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
+++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js
@@ -5,6 +5,7 @@ import { useQuery, useApolloClient } from "@apollo/client";
import { useNavigate, useLocation, useSearchParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
+import { useSavedOutfit } from "../loaders/outfits";
enableMapSet();
@@ -23,64 +24,36 @@ function useOutfitState() {
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
// about the outfit. We'll use it to initialize the local state.
const {
- loading: outfitLoading,
+ isLoading: outfitLoading,
error: outfitError,
data: outfitData,
- } = useQuery(
- gql`
- query OutfitStateSavedOutfit($id: ID!) {
- outfit(id: $id) {
- id
- name
- updatedAt
- creator {
- id
- }
- petAppearance {
- id
- species {
- id
- }
- color {
- id
- }
- pose
- }
- wornItems {
- id
- }
- closetedItems {
- id
- }
+ status: outfitStatus,
+ } = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null });
- # TODO: Consider pre-loading some fields, instead of doing them in
- # follow-up queries?
- }
- }
- `,
- {
- variables: { id: urlOutfitState.id },
- skip: urlOutfitState.id == null,
- returnPartialData: true,
- onCompleted: (outfitData) => {
- dispatchToOutfit({
- type: "resetToSavedOutfitData",
- savedOutfitData: outfitData.outfit,
- });
- },
- },
- );
-
- const creator = outfitData?.outfit?.creator;
- const updatedAt = outfitData?.outfit?.updatedAt;
+ const creator = outfitData?.user;
+ const updatedAt = outfitData?.updated_at;
// We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object!
const savedOutfitState = React.useMemo(
- () => getOutfitStateFromOutfitData(outfitData?.outfit),
- [outfitData?.outfit],
+ () => getOutfitStateFromOutfitData(outfitData),
+ [outfitData],
);
+ // When the saved outfit data comes in, we reset the local outfit state to
+ // match.
+ // TODO: I forget the details of why we have both resetting the local state,
+ // and a thing where we fallback between the different kinds of outfit state.
+ // Probably something about SSR when we were on Next.js? Could be simplified?`
+ React.useEffect(() => {
+ if (outfitStatus === "success") {
+ dispatchToOutfit({
+ type: "resetToSavedOutfitData",
+ savedOutfitData: outfitData,
+ });
+ }
+ }, [outfitStatus, outfitData]);
+
// Choose which customization state to use. We want it to match the outfit in
// the URL immediately, without having to wait for any effects, to avoid race
// conditions!
@@ -405,7 +378,7 @@ function useParseOutfitUrl() {
// has historically used both!
const location = useLocation();
const [justSearchParams] = useSearchParams();
- const hashParams = new URLSearchParams(location.hash.substr(1));
+ const hashParams = new URLSearchParams(location.hash.slice(1));
// Merge them into one URLSearchParams object.
const mergedParams = new URLSearchParams();
@@ -429,7 +402,7 @@ function useParseOutfitUrl() {
function readOutfitStateFromSearchParams(pathname, searchParams) {
// For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in!
- const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/)
+ const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/);
if (pathnameMatch) {
return {
...EMPTY_CUSTOMIZATION_STATE,
@@ -457,17 +430,14 @@ function getOutfitStateFromOutfitData(outfit) {
}
return {
- id: outfit.id,
+ id: String(outfit.id),
name: outfit.name,
- // Note that these fields are intentionally null if loading, rather than
- // falling back to a default appearance like Blue Acara.
- speciesId: outfit.petAppearance?.species?.id,
- colorId: outfit.petAppearance?.color?.id,
- pose: outfit.petAppearance?.pose,
- // Whereas the items are more convenient to just leave as empty lists!
- wornItemIds: new Set((outfit.wornItems || []).map((item) => item.id)),
+ speciesId: String(outfit.species_id),
+ colorId: String(outfit.color_id),
+ pose: outfit.pose,
+ wornItemIds: new Set((outfit.item_ids?.worn || []).map((id) => String(id))),
closetedItemIds: new Set(
- (outfit.closetedItems || []).map((item) => item.id),
+ (outfit.item_ids?.closeted || []).map((id) => String(id)),
),
};
}
diff --git a/app/javascript/wardrobe-2020/components/useCurrentUser.js b/app/javascript/wardrobe-2020/components/useCurrentUser.js
index 19b3fc90..8e04c2db 100644
--- a/app/javascript/wardrobe-2020/components/useCurrentUser.js
+++ b/app/javascript/wardrobe-2020/components/useCurrentUser.js
@@ -1,75 +1,30 @@
-import { gql, useMutation, useQuery } from "@apollo/client";
-import { useLocalStorage } from "../util";
-
-const NOT_LOGGED_IN_USER = {
- isLoading: false,
- isLoggedIn: false,
- id: null,
- username: null,
-};
+// Read the current user ID once from the tags, and use that forever!
+const currentUserId = readCurrentUserId();
function useCurrentUser() {
- const currentUser = useCurrentUserQuery();
-
- // In development, you can start the server with
- // `IMPRESS_LOG_IN_AS=12345 vc dev` to simulate logging in as user 12345.
- //
- // This flag shouldn't be present in prod anyway, but the dev check is an
- // extra safety precaution!
- //
- // NOTE: In package.json, we forward the flag to REACT_APP_IMPRESS_LOG_IN_AS,
- // because create-react-app only forwards flags with that prefix.
- if (
- process.env["NODE_ENV"] === "development" &&
- process.env["REACT_APP_IMPRESS_LOG_IN_AS"]
- ) {
- const id = process.env["REACT_APP_IMPRESS_LOG_IN_AS"];
+ if (currentUserId == null) {
return {
- isLoading: false,
- isLoggedIn: true,
- id,
- username: ``,
+ isLoggedIn: false,
+ id: null,
};
}
- return currentUser;
+ return {
+ isLoggedIn: true,
+ id: currentUserId,
+ };
}
-function useCurrentUserQuery() {
- const { loading, data } = useQuery(
- gql`
- query useCurrentUser {
- currentUser {
- id
- username
- }
- }
- `,
- {
- onError: (error) => {
- // On error, we don't report anything to the user, but we do keep a
- // record in the console. We figure that most errors are likely to be
- // solvable by retrying the login button and creating a new session,
- // which the user would do without an error prompt anyway; and if not,
- // they'll either get an error when they try, or they'll see their
- // login state continue to not work, which should be a clear hint that
- // something is wrong and they need to reach out.
- console.error("[useCurrentUser] Couldn't get current user:", error);
- },
- },
- );
-
- if (loading) {
- return { ...NOT_LOGGED_IN_USER, isLoading: true };
- } else if (data?.currentUser == null) {
- return NOT_LOGGED_IN_USER;
- } else {
- return {
- isLoading: false,
- isLoggedIn: true,
- id: data.currentUser.id,
- username: data.currentUser.username,
- };
+function readCurrentUserId() {
+ try {
+ const element = document.querySelector("meta[name=dti-current-user-id]");
+ return JSON.parse(element.getAttribute("content"));
+ } catch (error) {
+ console.error(
+ `[readCurrentUserId] Couldn't read user ID, using null instead`,
+ error,
+ );
+ return null;
}
}
diff --git a/app/javascript/wardrobe-2020/loaders/outfits.js b/app/javascript/wardrobe-2020/loaders/outfits.js
new file mode 100644
index 00000000..6612cfc6
--- /dev/null
+++ b/app/javascript/wardrobe-2020/loaders/outfits.js
@@ -0,0 +1,85 @@
+import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
+
+export function useSavedOutfit(id, options) {
+ return useQuery({
+ ...options,
+ queryKey: ["outfits", String(id)],
+ queryFn: () => loadSavedOutfit(id),
+ });
+}
+
+export function useSaveOutfitMutation(options) {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ ...options,
+ mutationFn: saveOutfit,
+ onSuccess: (outfit) => {
+ queryClient.setQueryData(["outfits", String(outfit.id)], outfit);
+ options.onSuccess(outfit);
+ },
+ });
+}
+
+async function loadSavedOutfit(id) {
+ const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
+
+ if (!res.ok) {
+ throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
+ }
+
+ return res.json();
+}
+
+async function saveOutfit({
+ id, // optional, null when creating a new outfit
+ name, // optional, server may fill in a placeholder
+ speciesId,
+ colorId,
+ pose,
+ wornItemIds,
+ closetedItemIds,
+}) {
+ const params = {
+ outfit: {
+ name: name,
+ biology: {
+ species_id: speciesId,
+ color_id: colorId,
+ pose: pose,
+ },
+ item_ids: { worn: wornItemIds, closeted: closetedItemIds },
+ },
+ };
+
+ let res;
+ if (id == null) {
+ res = await fetch(`/outfits.json`, {
+ method: "POST",
+ body: JSON.stringify(params),
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": getCSRFToken(),
+ },
+ });
+ } else {
+ res = await fetch(`/outfits/${encodeURIComponent(id)}.json`, {
+ method: "PUT",
+ body: JSON.stringify(params),
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": getCSRFToken(),
+ },
+ });
+ }
+
+ if (!res.ok) {
+ throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
+ }
+
+ return res.json();
+}
+
+function getCSRFToken() {
+ return document.querySelector("meta[name=csrf-token]")?.content;
+}
diff --git a/app/models/outfit.rb b/app/models/outfit.rb
index ab8dda05..5dfe4eda 100644
--- a/app/models/outfit.rb
+++ b/app/models/outfit.rb
@@ -1,14 +1,29 @@
class Outfit < ApplicationRecord
has_many :item_outfit_relationships, :dependent => :destroy
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
- :class_name => 'ItemOutfitRelationship'
- has_many :worn_items, :through => :worn_item_outfit_relationships, :source => :item
- belongs_to :pet_state
+ class_name: 'ItemOutfitRelationship'
+ has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
+
+ belongs_to :pet_state, optional: true # We validate presence below!
belongs_to :user, optional: true
validates :name, :presence => {:if => :user_id}, :uniqueness => {:scope => :user_id, :if => :user_id}
- validates :pet_state, :presence => true
+ validates :pet_state, presence: {
+ message: ->(object, _) do
+ if object.biology
+ "does not exist for " +
+ "species ##{object.biology[:species_id]}, " +
+ "color ##{object.biology[:color_id]}, " +
+ "pose #{object.biology[:pose]}"
+ else
+ "must exist"
+ end
+ end
+ }
+ before_validation :ensure_unique_name, if: :user_id?
+
+ attr_reader :biology
delegate :color, to: :pet_state
scope :wardrobe_order, -> { order('starred DESC', :name) }
@@ -66,13 +81,10 @@ class Outfit < ApplicationRecord
end
def as_json(more_options={})
- serializable_hash :only => [:id, :name, :pet_state_id, :starred],
- :methods => [:color_id, :species_id, :worn_and_unworn_item_ids,
- :image_versions, :image_enqueued, :image_layers_hash]
- end
-
- def closet_item_ids
- item_outfit_relationships.map(&:item_id)
+ serializable_hash(
+ only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at],
+ methods: [:color_id, :species_id, :pose, :item_ids, :user]
+ )
end
def color_id
@@ -83,42 +95,76 @@ class Outfit < ApplicationRecord
pet_state.pet_type.species_id
end
- def worn_and_unworn_item_ids
- {:worn => [], :unworn => []}.tap do |output|
- item_outfit_relationships.each do |rel|
- key = rel.is_worn? ? :worn : :unworn
- output[key] << rel.item_id
- end
+ def pose
+ pet_state.pose
+ end
+
+ def biology=(biology)
+ @biology = biology.slice(:species_id, :color_id, :pose)
+
+ begin
+ pet_type = PetType.where(
+ species_id: @biology[:species_id],
+ color_id: @biology[:color_id],
+ ).first!
+ self.pet_state = pet_type.pet_states.with_pose(@biology[:pose]).
+ emotion_order.first!
+ rescue ActiveRecord::RecordNotFound
+ # If there's no such pet state (which shouldn't happen normally in-app),
+ # we don't set `pet_state` but we keep `@biology` for validation.
end
end
- def worn_and_unworn_item_ids=(all_item_ids)
- new_rels = []
- all_item_ids.each do |key, item_ids|
- worn = key == 'worn'
- unless item_ids.blank?
- item_ids.each do |item_id|
- rel = ItemOutfitRelationship.new
- rel.item_id = item_id
- rel.is_worn = worn
- new_rels << rel
- end
- end
- end
- self.item_outfit_relationships = new_rels
+ def item_ids
+ rels = item_outfit_relationships
+ {
+ worn: rels.filter { |r| r.is_worn? }.map { |r| r.item_id },
+ closeted: rels.filter { |r| !r.is_worn? }.map { |r| r.item_id }
+ }
end
- def self.build_for_user(user, params)
- Outfit.new.tap do |outfit|
- name = params.delete(:name)
- starred = params.delete(:starred)
- anonymous = params.delete(:anonymous) == "true"
- if user && !anonymous
- outfit.user = user
- outfit.name = name
- outfit.starred = starred
- end
- outfit.attributes = params
+ def item_ids=(item_ids)
+ # Ensure there are no duplicates between the worn/closeted IDs. If an ID is
+ # present in both, it's kept in `worn` and removed from `closeted`.
+ worn_item_ids = item_ids.fetch(:worn, []).uniq
+ closeted_item_ids = item_ids.fetch(:closeted, []).uniq
+ closeted_item_ids.reject! { |id| worn_item_ids.include?(id) }
+
+ # Set the worn and closeted item outfit relationships. If there are any
+ # others attached to this outfit, they are implicitly deleted.
+ new_relationships = []
+ new_relationships += worn_item_ids.map do |item_id|
+ ItemOutfitRelationship.new(item_id: item_id, is_worn: true)
+ end
+ new_relationships += closeted_item_ids.map do |item_id|
+ ItemOutfitRelationship.new(item_id: item_id, is_worn: false)
+ end
+ self.item_outfit_relationships = new_relationships
+ end
+
+ def ensure_unique_name
+ # If no name was provided, start with "Untitled outfit".
+ self.name = "Untitled outfit" if name.blank?
+
+ # Strip whitespace from the name.
+ self.name.strip!
+
+ # Get the base name of the provided name, without any "(1)" suffixes.
+ base_name = name.sub(/\s*\([0-9]+\)$/, '')
+
+ # Find the user's other outfits that start with the same base name, and get
+ # *their* names, with whitespace stripped.
+ existing_outfits = self.user.outfits.
+ where("name LIKE ?", Outfit.sanitize_sql_like(base_name) + "%")
+ existing_outfits = existing_outfits.where("id != ?", id) unless id.nil?
+ existing_names = existing_outfits.map(&:name).map(&:strip)
+
+ # Try the provided name first, but if it's taken, add a "(1)" suffix and
+ # keep incrementing it until it's not.
+ i = 1
+ while existing_names.include?(name)
+ self.name = "#{base_name} (#{i})"
+ i += 1
end
end
end
diff --git a/app/models/pet_state.rb b/app/models/pet_state.rb
index 69b390f0..650fe919 100644
--- a/app/models/pet_state.rb
+++ b/app/models/pet_state.rb
@@ -16,31 +16,61 @@ class PetState < ApplicationRecord
attr_writer :parent_swf_asset_relationships_to_update
- # Our ideal order is: happy, sad, sick, UC, any+effects, glitched, with male
- # before female within those groups for consistency. We therefore order as
- # follows, listed in order of priority:
- # * Send glitched states to the back
- # * Bring known happy states to the front (we don't want to sort by mood_id
- # DESC first because then labeled sad will appear before unlabeled happy)
- # * Send states with effect assets to the back
- # * Bring state with more assets forward (that is, send UC near the back)
- # * Bring males forward
- # * Bring states with a lower asset ID sum forward (the idea being that
- # sad/female states are usually created after a happy/male base, but that's
- # becoming increasingly untrue over time - this is a very last resort)
- #
- # Maybe someday, when most states are labeled, we can depend exclusively on
- # their labels - or at least use more than is-happy and is-female. For now,
- # though, this strikes a good balance of bringing default to the front for
- # many pet types (the highest priority!) and otherwise doing decent sorting.
- bio_effect_zone_id = 4
+ # A simple ordering that tries to bring reliable pet states to the front.
scope :emotion_order, -> {
- joins(:parent_swf_asset_relationships).
- joins("LEFT JOIN swf_assets effect_assets ON effect_assets.id = parents_swf_assets.swf_asset_id AND effect_assets.zone_id = #{bio_effect_zone_id}").
- group("pet_states.id").
- order(Arel.sql("glitched ASC, (mood_id = 1) DESC, COUNT(effect_assets.remote_id) ASC, COUNT(parents_swf_assets.swf_asset_id) DESC, female ASC, SUM(parents_swf_assets.swf_asset_id) ASC"))
+ order(Arel.sql(
+ "(mood_id IS NULL) ASC, mood_id ASC, female DESC, unconverted DESC, " +
+ "glitched ASC, id DESC"
+ ))
}
+ # Filter pet states using the "pose" concept we use in the editor.
+ scope :with_pose, ->(pose) {
+ case pose
+ when "UNCONVERTED"
+ where(unconverted: true)
+ when "HAPPY_MASC"
+ where(mood_id: 1, female: false)
+ when "HAPPY_FEM"
+ where(mood_id: 1, female: true)
+ when "SAD_MASC"
+ where(mood_id: 2, female: false)
+ when "SAD_FEM"
+ where(mood_id: 2, female: true)
+ when "SICK_MASC"
+ where(mood_id: 4, female: false)
+ when "SICK_FEM"
+ where(mood_id: 4, female: true)
+ when "UNKNOWN"
+ where(mood_id: nil).or(where(female: nil))
+ else
+ raise ArgumentError, "unexpected pose value #{pose}"
+ end
+ }
+
+ def pose
+ if unconverted?
+ "UNCONVERTED"
+ elsif mood_id.nil? || female.nil?
+ "UNKNOWN"
+ elsif mood_id == 1 && !female?
+ "HAPPY_MASC"
+ elsif mood_id == 1 && female?
+ "HAPPY_FEM"
+ elsif mood_id == 2 && !female?
+ "SAD_MASC"
+ elsif mood_id == 2 && female?
+ "SAD_FEM"
+ elsif mood_id == 4 && !female?
+ "SICK_MASC"
+ elsif mood_id == 4 && female?
+ "SICK_FEM"
+ else
+ raise "could not identify pose: moodId=#{mood_id}, female=#{female}, " +
+ "unconverted=#{unconverted}"
+ end
+ end
+
def as_json(options={})
{
id: id,
diff --git a/app/models/user.rb b/app/models/user.rb
index 9b94b7b5..557ccc12 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -35,6 +35,10 @@ class User < ApplicationRecord
name == 'matchu' # you know that's right.
end
+ def as_json
+ serializable_hash only: [:id, :name]
+ end
+
def unowned_items
# Join all items against our owned closet hangers, group by item ID, then
# only return those with zero matching hangers.
diff --git a/app/views/outfits/edit.html.haml b/app/views/outfits/edit.html.haml
index 67eb5646..807c8734 100644
--- a/app/views/outfits/edit.html.haml
+++ b/app/views/outfits/edit.html.haml
@@ -18,5 +18,7 @@
= stylesheet_link_tag 'fonts'
= javascript_include_tag 'wardrobe-2020-page', defer: true
= open_graph_tags
+ = csrf_meta_tags
+ %meta{name: 'dti-current-user-id', content: user_signed_in? ? current_user.id : "null"}
%body
#wardrobe-2020-root
diff --git a/package.json b/package.json
index 5b311b59..ae14497f 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"@loadable/component": "^5.12.0",
"@sentry/react": "^5.30.0",
"@sentry/tracing": "^5.30.0",
+ "@tanstack/react-query": "^5.4.3",
"apollo-link-persisted-queries": "^0.2.2",
"easeljs": "^1.0.2",
"esbuild": "^0.19.0",
diff --git a/yarn.lock b/yarn.lock
index 8071b00b..b07a5db5 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -989,6 +989,18 @@
"@sentry/types" "5.30.0"
tslib "^1.9.3"
+"@tanstack/query-core@5.4.3":
+ version "5.4.3"
+ resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.4.3.tgz#fbdd36ccf1acf70579980f2e7cf16d2c2aa2a5e9"
+ integrity sha512-fnI9ORjcuLGm1sNrKatKIosRQUpuqcD4SV7RqRSVmj8JSicX2aoMyKryHEBpVQvf6N4PaBVgBxQomjsbsGPssQ==
+
+"@tanstack/react-query@^5.4.3":
+ version "5.4.3"
+ resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.4.3.tgz#cf59120690032e44b8c1c4c463cfb43aaad2fc5f"
+ integrity sha512-4aSOrRNa6yEmf7mws5QPTVMn8Lp7L38tFoTZ0c1ZmhIvbr8GIA0WT7X5N3yz/nuK8hUtjw9cAzBr4BPDZZ+tzA==
+ dependencies:
+ "@tanstack/query-core" "5.4.3"
+
"@types/lodash.mergewith@4.6.6":
version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10"