[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) {
|
||||
// Only auto-submit if a radio button was changed
|
||||
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 */
|
||||
&:state(auto-loading) .pose-submit-button {
|
||||
&:state(auto-loading) .pose-submit-button,
|
||||
&:state(auto-loading) .style-submit-button {
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -41,6 +41,15 @@ class WardrobeController < ApplicationController
|
|||
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
||||
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
|
||||
item_ids = params[:objects] || []
|
||||
items = Item.where(id: item_ids)
|
||||
|
|
@ -48,6 +57,7 @@ class WardrobeController < ApplicationController
|
|||
# Build the outfit
|
||||
@outfit = Outfit.new(
|
||||
pet_state: @pet_state,
|
||||
alt_style: @alt_style,
|
||||
worn_items: items,
|
||||
)
|
||||
|
||||
|
|
@ -55,6 +65,9 @@ class WardrobeController < ApplicationController
|
|||
# in parallel rather than sequentially when rendering
|
||||
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
|
||||
@search_mode = params[:q].present?
|
||||
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(: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(:style, @alt_style.id) if @alt_style && !except.include?(:style)
|
||||
|
||||
unless except.include?(:worn_items)
|
||||
outfit.worn_items.each do |item|
|
||||
|
|
@ -24,17 +25,22 @@ module WardrobeHelper
|
|||
safe_join fields
|
||||
end
|
||||
|
||||
# Get the emoji and label for a pose, for display in the pose picker button
|
||||
def pose_emoji_and_label(pose)
|
||||
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" }
|
||||
# Get the emoji and label for the pose picker button.
|
||||
# Shows the alt style name when one is active, otherwise the pose name.
|
||||
def pose_emoji_and_label(pose, alt_style: nil)
|
||||
if alt_style
|
||||
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
%span.pose-emoji= pose_info[:emoji]
|
||||
%span.pose-label= pose_info[:label]
|
||||
%span.chevron ▾
|
||||
|
||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||
= 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"
|
||||
- active_tab = @alt_style ? "styles" : "expressions"
|
||||
%tab-panel{active: active_tab}
|
||||
.tab-list
|
||||
%button.tab-button{"data-tab": "expressions", type: "button",
|
||||
class: ("active" if active_tab == "expressions")}
|
||||
Expressions
|
||||
%button.tab-button{"data-tab": "styles", type: "button",
|
||||
class: ("active" if active_tab == "styles")}
|
||||
Styles
|
||||
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
|
||||
= render "pose_picker_form"
|
||||
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
|
||||
= render "style_picker_form"
|
||||
|
|
|
|||
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 "species-color-picker", async: true
|
||||
= javascript_include_tag "pose-picker", async: true
|
||||
= javascript_include_tag "tab-panel", async: true
|
||||
= javascript_include_tag "wardrobe/show", async: true
|
||||
= csrf_meta_tags
|
||||
%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