[WV2] Add alt style picker
This commit is contained in:
parent
fd2940880f
commit
3b471fcb05
10 changed files with 312 additions and 1020 deletions
|
|
@ -23,7 +23,7 @@ class PosePickerPopover extends HTMLElement {
|
||||||
#handleChange(e) {
|
#handleChange(e) {
|
||||||
// Only auto-submit if a radio button was changed
|
// Only auto-submit if a radio button was changed
|
||||||
if (e.target.type === "radio") {
|
if (e.target.type === "radio") {
|
||||||
this.querySelector("form").requestSubmit();
|
e.target.closest("form").requestSubmit();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
app/assets/javascripts/tab-panel.js
Normal file
37
app/assets/javascripts/tab-panel.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
/**
|
||||||
|
* TabPanel web component
|
||||||
|
*
|
||||||
|
* A simple tab switcher. Reads the `active` attribute to determine which tab
|
||||||
|
* is visible. Without JS, both panels are visible (tab buttons hidden via CSS).
|
||||||
|
*/
|
||||||
|
class TabPanel extends HTMLElement {
|
||||||
|
connectedCallback() {
|
||||||
|
this.querySelectorAll(".tab-button").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
this.setAttribute("active", button.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static get observedAttributes() {
|
||||||
|
return ["active"];
|
||||||
|
}
|
||||||
|
|
||||||
|
attributeChangedCallback(name) {
|
||||||
|
if (name === "active") this.#updateVisibility();
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateVisibility() {
|
||||||
|
const active = this.getAttribute("active");
|
||||||
|
|
||||||
|
this.querySelectorAll(".tab-button").forEach((button) => {
|
||||||
|
button.classList.toggle("active", button.dataset.tab === active);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.querySelectorAll(".tab-content").forEach((content) => {
|
||||||
|
content.hidden = content.dataset.tab !== active;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
customElements.define("tab-panel", TabPanel);
|
||||||
|
|
@ -456,9 +456,140 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Once auto-submit is enabled, hide the submit button completely */
|
/* Once auto-submit is enabled, hide the submit button completely */
|
||||||
&:state(auto-loading) .pose-submit-button {
|
&:state(auto-loading) .pose-submit-button,
|
||||||
|
&:state(auto-loading) .style-submit-button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Tab panel layout */
|
||||||
|
.tab-list {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-button {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
color: white;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Without JS, hide tab buttons and show both panels stacked */
|
||||||
|
tab-panel:not(:defined) .tab-list {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
tab-panel:not(:defined) .tab-content[hidden] {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style picker form */
|
||||||
|
.style-picker-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option-name {
|
||||||
|
color: white;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hover */
|
||||||
|
&:hover .style-option-content {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected state */
|
||||||
|
input[type="radio"]:checked + .style-option-content {
|
||||||
|
border-color: #48BB78;
|
||||||
|
box-shadow: 0 0 0 2px rgba(72, 187, 120, 0.3);
|
||||||
|
background: rgba(72, 187, 120, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state */
|
||||||
|
input[type="radio"]:focus + .style-option-content {
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style submit button: same progressive enhancement as pose */
|
||||||
|
.style-submit-button {
|
||||||
|
margin-top: 1rem;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (scripting: enabled) {
|
||||||
|
.style-submit-button {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in 0.25s forwards;
|
||||||
|
animation-delay: 0.75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Species/color picker */
|
/* Species/color picker */
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,15 @@ class WardrobeController < ApplicationController
|
||||||
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Load alt style from params, scoped to the current species
|
||||||
|
@alt_style = if params[:style].present? && @selected_species
|
||||||
|
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load all available alt styles for this species (for the style picker)
|
||||||
|
@available_alt_styles = @selected_species ?
|
||||||
|
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
|
||||||
|
|
||||||
# Load items from the objects[] parameter
|
# Load items from the objects[] parameter
|
||||||
item_ids = params[:objects] || []
|
item_ids = params[:objects] || []
|
||||||
items = Item.where(id: item_ids)
|
items = Item.where(id: item_ids)
|
||||||
|
|
@ -48,6 +57,7 @@ class WardrobeController < ApplicationController
|
||||||
# Build the outfit
|
# Build the outfit
|
||||||
@outfit = Outfit.new(
|
@outfit = Outfit.new(
|
||||||
pet_state: @pet_state,
|
pet_state: @pet_state,
|
||||||
|
alt_style: @alt_style,
|
||||||
worn_items: items,
|
worn_items: items,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -55,6 +65,9 @@ class WardrobeController < ApplicationController
|
||||||
# in parallel rather than sequentially when rendering
|
# in parallel rather than sequentially when rendering
|
||||||
SwfAsset.preload_manifests(@outfit.visible_layers)
|
SwfAsset.preload_manifests(@outfit.visible_layers)
|
||||||
|
|
||||||
|
# Also preload alt style layer manifests for the style picker thumbnails
|
||||||
|
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
|
||||||
|
|
||||||
# Handle search mode
|
# Handle search mode
|
||||||
@search_mode = params[:q].present?
|
@search_mode = params[:q].present?
|
||||||
if @search_mode
|
if @search_mode
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ module WardrobeHelper
|
||||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||||
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||||
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
||||||
|
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
|
||||||
|
|
||||||
unless except.include?(:worn_items)
|
unless except.include?(:worn_items)
|
||||||
outfit.worn_items.each do |item|
|
outfit.worn_items.each do |item|
|
||||||
|
|
@ -24,17 +25,22 @@ module WardrobeHelper
|
||||||
safe_join fields
|
safe_join fields
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the emoji and label for a pose, for display in the pose picker button
|
# Get the emoji and label for the pose picker button.
|
||||||
def pose_emoji_and_label(pose)
|
# Shows the alt style name when one is active, otherwise the pose name.
|
||||||
case pose
|
def pose_emoji_and_label(pose, alt_style: nil)
|
||||||
when "HAPPY_MASC", "HAPPY_FEM"
|
if alt_style
|
||||||
{ emoji: "😀", label: "Happy" }
|
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
|
||||||
when "SAD_MASC", "SAD_FEM"
|
|
||||||
{ emoji: "😢", label: "Sad" }
|
|
||||||
when "SICK_MASC", "SICK_FEM"
|
|
||||||
{ emoji: "🤢", label: "Sick" }
|
|
||||||
else
|
else
|
||||||
{ emoji: "😀", label: "Default" }
|
case pose
|
||||||
|
when "HAPPY_MASC", "HAPPY_FEM"
|
||||||
|
{ emoji: "😀", label: "Happy" }
|
||||||
|
when "SAD_MASC", "SAD_FEM"
|
||||||
|
{ emoji: "😢", label: "Sad" }
|
||||||
|
when "SICK_MASC", "SICK_FEM"
|
||||||
|
{ emoji: "🤢", label: "Sick" }
|
||||||
|
else
|
||||||
|
{ emoji: "😀", label: "Default" }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,39 +1,20 @@
|
||||||
- pose_info = pose_emoji_and_label(@selected_pose)
|
- pose_info = pose_emoji_and_label(@selected_pose, alt_style: @alt_style)
|
||||||
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
||||||
%span.pose-emoji= pose_info[:emoji]
|
%span.pose-emoji= pose_info[:emoji]
|
||||||
%span.pose-label= pose_info[:label]
|
%span.pose-label= pose_info[:label]
|
||||||
%span.chevron ▾
|
%span.chevron ▾
|
||||||
|
|
||||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||||
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
|
- active_tab = @alt_style ? "styles" : "expressions"
|
||||||
= outfit_state_params except: [:pose]
|
%tab-panel{active: active_tab}
|
||||||
%table.pose-picker-table
|
.tab-list
|
||||||
%thead
|
%button.tab-button{"data-tab": "expressions", type: "button",
|
||||||
%tr
|
class: ("active" if active_tab == "expressions")}
|
||||||
%th
|
Expressions
|
||||||
%th
|
%button.tab-button{"data-tab": "styles", type: "button",
|
||||||
%span.emoji-icon{title: "Happy"} 😀
|
class: ("active" if active_tab == "styles")}
|
||||||
%th
|
Styles
|
||||||
%span.emoji-icon{title: "Sad"} 😢
|
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
|
||||||
%th
|
= render "pose_picker_form"
|
||||||
%span.emoji-icon{title: "Sick"} 🤢
|
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
|
||||||
%tbody
|
= render "style_picker_form"
|
||||||
%tr
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Masculine"} 💁♂️
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
|
|
||||||
%tr
|
|
||||||
%th
|
|
||||||
%span.emoji-icon{title: "Feminine"} 💁♀️
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
|
|
||||||
%td
|
|
||||||
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
|
|
||||||
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
|
|
||||||
|
|
|
||||||
32
app/views/wardrobe/_pose_picker_form.html.haml
Normal file
32
app/views/wardrobe/_pose_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
|
||||||
|
= outfit_state_params except: [:pose]
|
||||||
|
%table.pose-picker-table
|
||||||
|
%thead
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Happy"} 😀
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Sad"} 😢
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Sick"} 🤢
|
||||||
|
%tbody
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Masculine"} 💁♂️
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
|
||||||
|
%tr
|
||||||
|
%th
|
||||||
|
%span.emoji-icon{title: "Feminine"} 💁♀️
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
|
||||||
|
%td
|
||||||
|
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
|
||||||
|
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
|
||||||
15
app/views/wardrobe/_style_picker_form.html.haml
Normal file
15
app/views/wardrobe/_style_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
= form_with url: wardrobe_v2_path, method: :get, class: "style-picker-form" do |f|
|
||||||
|
= outfit_state_params except: [:style]
|
||||||
|
.style-picker-list
|
||||||
|
%label.style-option
|
||||||
|
= radio_button_tag :style, "", @alt_style.nil?
|
||||||
|
.style-option-content
|
||||||
|
%span.style-option-name Default
|
||||||
|
- @available_alt_styles.each do |alt_style|
|
||||||
|
%label.style-option
|
||||||
|
= radio_button_tag :style, alt_style.id, @alt_style&.id == alt_style.id
|
||||||
|
.style-option-content
|
||||||
|
.style-option-thumbnail
|
||||||
|
%img{src: alt_style.thumbnail_url, alt: "", width: 40, height: 40, loading: "lazy"}
|
||||||
|
%span.style-option-name= alt_style.adjective_name
|
||||||
|
= submit_tag "Change style", name: nil, class: "style-submit-button"
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
= javascript_include_tag "species-color-picker", async: true
|
= javascript_include_tag "species-color-picker", async: true
|
||||||
= javascript_include_tag "pose-picker", async: true
|
= javascript_include_tag "pose-picker", async: true
|
||||||
|
= javascript_include_tag "tab-panel", async: true
|
||||||
= javascript_include_tag "wardrobe/show", async: true
|
= javascript_include_tag "wardrobe/show", async: true
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue