Compare commits
6 commits
054c809052
...
0a9193aed7
Author | SHA1 | Date | |
---|---|---|---|
0a9193aed7 | |||
ac002f3151 | |||
fe6035d438 | |||
21a8a49f50 | |||
3f38fbd1b0 | |||
74748acaaf |
10 changed files with 180 additions and 58 deletions
|
@ -38,7 +38,7 @@ body.items-show
|
||||||
height: 16px
|
height: 16px
|
||||||
width: 16px
|
width: 16px
|
||||||
|
|
||||||
outfit-viewer
|
.outfit-viewer
|
||||||
position: relative
|
position: relative
|
||||||
display: block
|
display: block
|
||||||
width: 300px
|
width: 300px
|
||||||
|
@ -49,7 +49,7 @@ body.items-show
|
||||||
|
|
||||||
margin: 0 auto .75em
|
margin: 0 auto .75em
|
||||||
|
|
||||||
outfit-layer
|
.outfit-layer
|
||||||
display: block
|
display: block
|
||||||
position: absolute
|
position: absolute
|
||||||
inset: 0
|
inset: 0
|
||||||
|
@ -58,6 +58,9 @@ body.items-show
|
||||||
width: 100%
|
width: 100%
|
||||||
height: 100%
|
height: 100%
|
||||||
|
|
||||||
|
&:has(.outfit-layer[data-status="loading"])
|
||||||
|
background: gray
|
||||||
|
|
||||||
.species-color-picker
|
.species-color-picker
|
||||||
.error-icon
|
.error-icon
|
||||||
cursor: help
|
cursor: help
|
||||||
|
|
|
@ -84,14 +84,10 @@ class ItemsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
@selected_preview_pet_type = load_selected_preview_pet_type
|
@selected_preview_pet_type = load_selected_preview_pet_type
|
||||||
@preview_pet_type = load_preview_pet_type
|
@preview_outfit = Outfit.new(
|
||||||
|
pet_state: load_preview_pet_type.canonical_pet_state,
|
||||||
@item_layers = @item.appearance_for(
|
worn_items: [@item],
|
||||||
@preview_pet_type, swf_asset_includes: [:zone]
|
)
|
||||||
).swf_assets
|
|
||||||
@pet_layers = @preview_pet_type.canonical_pet_state.swf_assets.
|
|
||||||
includes(:zone)
|
|
||||||
|
|
||||||
@preview_error = validate_preview
|
@preview_error = validate_preview
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -191,7 +187,7 @@ class ItemsController < ApplicationController
|
||||||
appearance_params[:color_id], appearance_params[:species_id])
|
appearance_params[:color_id], appearance_params[:species_id])
|
||||||
end
|
end
|
||||||
|
|
||||||
target.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]).
|
target.appearances_for(@items, swf_asset_includes: [:zone]).
|
||||||
tap do |appearances|
|
tap do |appearances|
|
||||||
# Preload the manifests for these SWF assets concurrently, rather than
|
# Preload the manifests for these SWF assets concurrently, rather than
|
||||||
# loading them in sequence when we generate the JSON.
|
# loading them in sequence when we generate the JSON.
|
||||||
|
@ -206,7 +202,12 @@ class ItemsController < ApplicationController
|
||||||
|
|
||||||
return load_default_preview_pet_type if color_id.nil? || species_id.nil?
|
return load_default_preview_pet_type if color_id.nil? || species_id.nil?
|
||||||
|
|
||||||
PetType.find_or_initialize_by(color_id:, species_id:)
|
PetType.find_or_initialize_by(color_id:, species_id:).tap do |pet_type|
|
||||||
|
if pet_type.persisted?
|
||||||
|
cookies["preferred-preview-color-id"] = color_id
|
||||||
|
cookies["preferred-preview-species-id"] = species_id
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_preview_pet_type
|
def load_preview_pet_type
|
||||||
|
@ -218,16 +219,16 @@ class ItemsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def load_default_preview_pet_type
|
def load_default_preview_pet_type
|
||||||
PetType.find_by_color_id_and_species_id(
|
@item.compatible_pet_types.
|
||||||
Color.find_by_name("Blue"),
|
preferring_species(cookies["preferred-preview-species-id"] || "<ignore>").
|
||||||
Species.find_by_name("Acara"),
|
preferring_color(cookies["preferred-preview-color-id"] || "<ignore>").
|
||||||
)
|
preferring_simple.first
|
||||||
end
|
end
|
||||||
|
|
||||||
def validate_preview
|
def validate_preview
|
||||||
if @selected_preview_pet_type.new_record?
|
if @selected_preview_pet_type.new_record?
|
||||||
:pet_type_does_not_exist
|
:pet_type_does_not_exist
|
||||||
elsif @item_layers.empty?
|
elsif @preview_outfit.item_appearances.any?(&:empty?)
|
||||||
:no_item_data
|
:no_item_data
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -250,7 +250,7 @@ module ItemsHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
def outfit_viewer_layer(swf_asset)
|
def outfit_viewer_layer(swf_asset)
|
||||||
content_tag "outfit-layer", style: "z-index: #{swf_asset.zone.depth}" do
|
content_tag :div, class: "outfit-layer" do
|
||||||
image_tag swf_asset.image_url, alt: ""
|
image_tag swf_asset.image_url, alt: ""
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -1,2 +1,32 @@
|
||||||
// eslint-disable-next-line no-console
|
// When we load in a new preview, set the status on the images. We use this in
|
||||||
console.log("OwO!");
|
// our CSS to show a loading state when needed.
|
||||||
|
function setImageStatuses() {
|
||||||
|
for (const layer of document.querySelectorAll(".outfit-layer")) {
|
||||||
|
const isLoaded = layer.querySelector("img").complete;
|
||||||
|
layer.setAttribute("data-status", isLoaded ? "loaded" : "loading");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener("turbo:frame-render", () => setImageStatuses());
|
||||||
|
document.addEventListener("turbo:load", () => setImageStatuses());
|
||||||
|
|
||||||
|
// When a preview image loads or fails, update its status. (Note that `load`
|
||||||
|
// does not fire for images that were loaded from cache, which is why we need
|
||||||
|
// both this and `setImageStatuses` when rendering new images!)
|
||||||
|
document.addEventListener(
|
||||||
|
"load",
|
||||||
|
({ target }) => {
|
||||||
|
if (target.matches(".outfit-layer img")) {
|
||||||
|
target.closest(".outfit-layer").setAttribute("data-status", "loaded");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
"error",
|
||||||
|
({ target }) => {
|
||||||
|
if (target.matches(".outfit-layer img")) {
|
||||||
|
target.closest(".outfit-layer").setAttribute("data-status", "error");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ capture: true },
|
||||||
|
);
|
||||||
|
|
|
@ -49,9 +49,9 @@ class AltStyle < ApplicationRecord
|
||||||
swf_asset.image_url
|
swf_asset.image_url
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of item IDs, return how they look on this alt style.
|
# Given a list of items, return how they look on this alt style.
|
||||||
def appearances_for(item_ids, ...)
|
def appearances_for(items, ...)
|
||||||
Item.appearances_for(item_ids, self, ...)
|
Item.appearances_for(items, self, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
def biology=(biology)
|
def biology=(biology)
|
||||||
|
|
|
@ -489,6 +489,15 @@ class Item < ApplicationRecord
|
||||||
}.merge(options))
|
}.merge(options))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def compatible_body_ids
|
||||||
|
swf_assets.map(&:body_id).uniq
|
||||||
|
end
|
||||||
|
|
||||||
|
def compatible_pet_types
|
||||||
|
return PetType.all if compatible_body_ids.include?(0)
|
||||||
|
PetType.where(body_id: compatible_body_ids)
|
||||||
|
end
|
||||||
|
|
||||||
def handle_assets!
|
def handle_assets!
|
||||||
if @parent_swf_asset_relationships_to_update && @current_body_id
|
if @parent_swf_asset_relationships_to_update && @current_body_id
|
||||||
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
|
new_swf_asset_ids = @parent_swf_asset_relationships_to_update.map(&:swf_asset_id)
|
||||||
|
@ -555,16 +564,23 @@ class Item < ApplicationRecord
|
||||||
# instead of like a hash, so you can target its children with things like
|
# 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
|
# the `include` option. This feels clunky though, I wish I had something a
|
||||||
# bit more suited to it!
|
# bit more suited to it!
|
||||||
Appearance = Struct.new(:body, :swf_assets) do
|
Appearance = Struct.new(:item, :body, :swf_assets) do
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
|
delegate :present?, :empty?, to: :swf_assets
|
||||||
|
|
||||||
def attributes
|
def attributes
|
||||||
{body: body, swf_assets: swf_assets}
|
{item:, body:, swf_assets:}
|
||||||
|
end
|
||||||
|
|
||||||
|
def restricted_zone_ids
|
||||||
|
return [] if empty?
|
||||||
|
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
Appearance::Body = Struct.new(:id, :species) do
|
Appearance::Body = Struct.new(:id, :species) do
|
||||||
include ActiveModel::Serializers::JSON
|
include ActiveModel::Serializers::JSON
|
||||||
def attributes
|
def attributes
|
||||||
{id: id, species: species}
|
{id:, species:}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@ -581,7 +597,7 @@ class Item < ApplicationRecord
|
||||||
# If there are no body-specific assets, return one appearance for them all.
|
# If there are no body-specific assets, return one appearance for them all.
|
||||||
if swf_assets_by_body_id.empty?
|
if swf_assets_by_body_id.empty?
|
||||||
body = Appearance::Body.new(0, nil)
|
body = Appearance::Body.new(0, nil)
|
||||||
return [Appearance.new(body, swf_assets_for_all_bodies)]
|
return [Appearance.new(self, body, swf_assets_for_all_bodies)]
|
||||||
end
|
end
|
||||||
|
|
||||||
# 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
|
||||||
|
@ -591,22 +607,22 @@ class Item < ApplicationRecord
|
||||||
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!
|
species = Species.with_body_id(body_id).first!
|
||||||
body = Appearance::Body.new(body_id, species)
|
body = Appearance::Body.new(body_id, species)
|
||||||
Appearance.new(body, swf_assets_for_body)
|
Appearance.new(self, body, swf_assets_for_body)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def appearance_for(target, ...)
|
def appearance_for(target, ...)
|
||||||
Item.appearances_for([id], target, ...)[id]
|
Item.appearances_for([self], target, ...)[id]
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of item IDs, return how they look on the given target (either
|
# Given a list of items, return how they look on the given target (either a
|
||||||
# a pet type or an alt style).
|
# pet type or an alt style).
|
||||||
def self.appearances_for(item_ids, target, swf_asset_includes: [])
|
def self.appearances_for(items, target, swf_asset_includes: [])
|
||||||
# First, load all the relationships for these items that also fit this
|
# First, load all the relationships for these items that also fit this
|
||||||
# body.
|
# body.
|
||||||
relationships = ParentSwfAssetRelationship.
|
relationships = ParentSwfAssetRelationship.
|
||||||
includes(swf_asset: swf_asset_includes).
|
includes(swf_asset: swf_asset_includes).
|
||||||
where(parent_type: "Item", parent_id: item_ids).
|
where(parent_type: "Item", parent_id: items.map(&:id)).
|
||||||
where(swf_asset: {body_id: [target.body_id, 0]})
|
where(swf_asset: {body_id: [target.body_id, 0]})
|
||||||
|
|
||||||
pet_type_body = Appearance::Body.new(target.body_id, target.species)
|
pet_type_body = Appearance::Body.new(target.body_id, target.species)
|
||||||
|
@ -617,13 +633,13 @@ class Item < ApplicationRecord
|
||||||
transform_values { |rels| rels.map(&:swf_asset) }
|
transform_values { |rels| rels.map(&:swf_asset) }
|
||||||
|
|
||||||
# Finally, for each item, return an appearance—even if it's empty!
|
# Finally, for each item, return an appearance—even if it's empty!
|
||||||
item_ids.to_h do |item_id|
|
items.to_h do |item|
|
||||||
assets = assets_by_item_id.fetch(item_id, [])
|
assets = assets_by_item_id.fetch(item.id, [])
|
||||||
|
|
||||||
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
|
fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
|
||||||
body = fits_all_pets ? all_pets_body : pet_type_body
|
body = fits_all_pets ? all_pets_body : pet_type_body
|
||||||
|
|
||||||
[item_id, Appearance.new(body, assets)]
|
[item.id, Appearance.new(item, body, assets)]
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,12 @@ class Outfit < ApplicationRecord
|
||||||
before_validation :ensure_unique_name, if: :user_id?
|
before_validation :ensure_unique_name, if: :user_id?
|
||||||
|
|
||||||
attr_reader :biology
|
attr_reader :biology
|
||||||
delegate :color, to: :pet_state
|
delegate :pose, to: :pet_state
|
||||||
|
delegate :pet_type, to: :pet_state
|
||||||
|
delegate :color, to: :pet_type
|
||||||
|
delegate :color_id, to: :pet_type
|
||||||
|
delegate :species, to: :pet_type
|
||||||
|
delegate :species_id, to: :pet_type
|
||||||
|
|
||||||
scope :wardrobe_order, -> { order('starred DESC', :name) }
|
scope :wardrobe_order, -> { order('starred DESC', :name) }
|
||||||
|
|
||||||
|
@ -107,18 +112,6 @@ class Outfit < ApplicationRecord
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
def color_id
|
|
||||||
pet_state.pet_type.color_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def species_id
|
|
||||||
pet_state.pet_type.species_id
|
|
||||||
end
|
|
||||||
|
|
||||||
def pose
|
|
||||||
pet_state.pose
|
|
||||||
end
|
|
||||||
|
|
||||||
def biology=(biology)
|
def biology=(biology)
|
||||||
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
|
@biology = biology.slice(:species_id, :color_id, :pose, :pet_state_id)
|
||||||
|
|
||||||
|
@ -166,6 +159,78 @@ class Outfit < ApplicationRecord
|
||||||
self.item_outfit_relationships = new_relationships
|
self.item_outfit_relationships = new_relationships
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def item_appearances(...)
|
||||||
|
Item.appearances_for(worn_items, pet_type, ...).values
|
||||||
|
end
|
||||||
|
|
||||||
|
def visible_layers
|
||||||
|
item_appearances = item_appearances(swf_asset_includes: [:zone])
|
||||||
|
|
||||||
|
pet_layers = pet_state.swf_assets.includes(:zone).to_a
|
||||||
|
item_layers = item_appearances.map(&:swf_assets).flatten
|
||||||
|
|
||||||
|
pet_restricted_zone_ids = pet_layers.map(&:restricted_zone_ids).
|
||||||
|
flatten.to_set
|
||||||
|
item_restricted_zone_ids = item_appearances.
|
||||||
|
map(&:restricted_zone_ids).flatten.to_set
|
||||||
|
|
||||||
|
# When an item restricts a zone, it hides pet layers of the same zone.
|
||||||
|
# We use this to e.g. make a hat hide a hair ruff.
|
||||||
|
#
|
||||||
|
# NOTE: Items' restricted layers also affect what items you can wear at
|
||||||
|
# the same time. We don't enforce anything about that here, and
|
||||||
|
# instead assume that the input by this point is valid!
|
||||||
|
pet_layers.reject! { |sa| item_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
|
|
||||||
|
# When a pet appearance restricts a zone, or when the pet is Unconverted,
|
||||||
|
# it makes body-specific items incompatible. We use this to disallow UCs
|
||||||
|
# from wearing certain body-specific Biology Effects, Statics, etc, while
|
||||||
|
# still allowing non-body-specific items in those zones! (I think this
|
||||||
|
# happens for some Invisible pet stuff, too?)
|
||||||
|
#
|
||||||
|
# TODO: We shouldn't be *hiding* these zones, like we do with items; we
|
||||||
|
# should be doing this way earlier, to prevent the item from even
|
||||||
|
# showing up even in search results!
|
||||||
|
#
|
||||||
|
# NOTE: This can result in both pet layers and items occupying the same
|
||||||
|
# zone, like Static, so long as the item isn't body-specific! That's
|
||||||
|
# correct, and the item layer should be on top! (Here, we implement
|
||||||
|
# it by placing item layers second in the list, and rely on JS sort
|
||||||
|
# stability, and *then* rely on the UI to respect that ordering when
|
||||||
|
# rendering them by depth. Not great! 😅)
|
||||||
|
#
|
||||||
|
# NOTE: We used to also include the pet appearance's *occupied* zones in
|
||||||
|
# this condition, not just the restricted zones, as a sensible
|
||||||
|
# defensive default, even though we weren't aware of any relevant
|
||||||
|
# items. But now we know that actually the "Bruce Brucey B Mouth"
|
||||||
|
# occupies the real Mouth zone, and still should be visible and
|
||||||
|
# above pet layers! So, we now only check *restricted* zones.
|
||||||
|
#
|
||||||
|
# NOTE: UCs used to implement their restrictions by listing specific
|
||||||
|
# zones, but it seems that the logic has changed to just be about
|
||||||
|
# UC-ness and body-specific-ness, and not necessarily involve the
|
||||||
|
# set of restricted zones at all. (This matters because e.g. UCs
|
||||||
|
# shouldn't show _any_ part of the Rainy Day Umbrella, but most UCs
|
||||||
|
# don't restrict Right-Hand Item (Zone 49).) Still, I'm keeping the
|
||||||
|
# zone restriction case running too, because I don't think it
|
||||||
|
# _hurts_ anything, and I'm not confident enough in this conclusion.
|
||||||
|
#
|
||||||
|
# TODO: Do Invisibles follow this new rule like UCs, too? Or do they still
|
||||||
|
# use zone restrictions?
|
||||||
|
if pet_state.pose === "UNCONVERTED"
|
||||||
|
item_layers.reject! { |sa| sa.body_specific? }
|
||||||
|
else
|
||||||
|
item_layers.reject! { |sa| sa.body_specific? &&
|
||||||
|
pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
|
end
|
||||||
|
|
||||||
|
# A pet appearance can also restrict its own zones. The Wraith Uni is an
|
||||||
|
# interesting example: it has a horn, but its zone restrictions hide it!
|
||||||
|
pet_layers.reject! { |sa| pet_restricted_zone_ids.include?(sa.zone_id) }
|
||||||
|
|
||||||
|
(pet_layers + item_layers).sort_by(&:depth)
|
||||||
|
end
|
||||||
|
|
||||||
def ensure_unique_name
|
def ensure_unique_name
|
||||||
# If no name was provided, start with "Untitled outfit".
|
# If no name was provided, start with "Untitled outfit".
|
||||||
self.name = "Untitled outfit" if name.blank?
|
self.name = "Untitled outfit" if name.blank?
|
||||||
|
|
|
@ -16,6 +16,17 @@ class PetType < ApplicationRecord
|
||||||
species = Species.find_by_name!(species_name)
|
species = Species.find_by_name!(species_name)
|
||||||
where(color_id: color.id, species_id: species.id)
|
where(color_id: color.id, species_id: species.id)
|
||||||
}
|
}
|
||||||
|
scope :preferring_species, ->(species_id) {
|
||||||
|
joins(:species).order([Arel.sql("species_id = ? DESC"), species_id])
|
||||||
|
}
|
||||||
|
scope :preferring_color, ->(color_id) {
|
||||||
|
joins(:color).order([Arel.sql("color_id = ? DESC"), color_id])
|
||||||
|
}
|
||||||
|
scope :preferring_simple, -> {
|
||||||
|
joins(:species, :color).
|
||||||
|
merge(Species.order(name: :asc)).
|
||||||
|
merge(Color.order(basic: :desc, standard: :desc, name: :asc))
|
||||||
|
}
|
||||||
|
|
||||||
def self.special_color_or_basic(special_color)
|
def self.special_color_or_basic(special_color)
|
||||||
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)
|
color_ids = special_color ? [special_color.id] : Color.basic.select([:id]).map(&:id)
|
||||||
|
@ -124,9 +135,9 @@ class PetType < ApplicationRecord
|
||||||
}.first
|
}.first
|
||||||
end
|
end
|
||||||
|
|
||||||
# Given a list of item IDs, return how they look on this pet type.
|
# Given a list of items, return how they look on this pet type.
|
||||||
def appearances_for(item_ids, ...)
|
def appearances_for(item, ...)
|
||||||
Item.appearances_for(item_ids, self, ...)
|
Item.appearances_for(item, self, ...)
|
||||||
end
|
end
|
||||||
|
|
||||||
def self.all_by_ids_or_children(ids, pet_states)
|
def self.all_by_ids_or_children(ids, pet_states)
|
||||||
|
|
|
@ -15,7 +15,6 @@ class SwfAsset < ApplicationRecord
|
||||||
belongs_to :zone
|
belongs_to :zone
|
||||||
has_many :parent_swf_asset_relationships
|
has_many :parent_swf_asset_relationships
|
||||||
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
has_one :contribution, :as => :contributed, :inverse_of => :contributed
|
||||||
has_many :parent_swf_asset_relationships
|
|
||||||
|
|
||||||
before_validation :normalize_manifest_url, if: :manifest_url?
|
before_validation :normalize_manifest_url, if: :manifest_url?
|
||||||
|
|
||||||
|
|
|
@ -14,11 +14,8 @@
|
||||||
sorry!
|
sorry!
|
||||||
|
|
||||||
= turbo_frame_tag "item-preview" do
|
= turbo_frame_tag "item-preview" do
|
||||||
%outfit-viewer
|
.outfit-viewer
|
||||||
%outfit-pet-appearance
|
= outfit_viewer_layers @preview_outfit.visible_layers
|
||||||
= outfit_viewer_layers @pet_layers
|
|
||||||
%outfit-item-appearance
|
|
||||||
= outfit_viewer_layers @item_layers
|
|
||||||
|
|
||||||
= form_for item_path(@item), method: :get, class: "species-color-picker",
|
= form_for item_path(@item), method: :get, class: "species-color-picker",
|
||||||
data: {"is-valid": @preview_error.nil?} do |f|
|
data: {"is-valid": @preview_error.nil?} do |f|
|
||||||
|
|
Loading…
Reference in a new issue