[WV2] Support closeted items as well as worn items

This commit is contained in:
Emi Matchu 2026-02-06 07:54:09 -08:00
parent f5ad5d2b17
commit fd881ee31d
10 changed files with 132 additions and 47 deletions

View file

@ -79,7 +79,9 @@ select,
/* Icon button pattern - small action buttons with hover reveals */ /* Icon button pattern - small action buttons with hover reveals */
.item-remove-button, .item-remove-button,
.item-add-button { .item-add-button,
.item-hide-button,
.item-show-button {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@ -129,6 +131,24 @@ select,
} }
} }
.item-hide-button {
right: 2.5rem;
background: rgba(255, 255, 255, 0.9);
&:hover {
background: rgba(255, 255, 255, 1);
}
}
.item-show-button {
right: 2.5rem;
background: rgba(68, 136, 68, 0.9);
&:hover {
background: rgba(68, 136, 68, 1);
}
}
/* Item card - shared layout for worn items and search results */ /* Item card - shared layout for worn items and search results */
.item-card { .item-card {
display: flex; display: flex;
@ -147,7 +167,7 @@ select,
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
} }
&:hover :is(.item-add-button, .item-remove-button) { &:hover :is(.item-add-button, .item-remove-button, .item-hide-button, .item-show-button) {
opacity: 1; opacity: 1;
} }
@ -191,6 +211,23 @@ select,
} }
} }
/* Worn item emphasis */
.item-card[data-is-worn] {
background: #eef5ee;
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
.item-name {
font-weight: 600;
}
}
/* Closeted item de-emphasis */
.item-card[data-is-closeted] {
background: #f5f5f5;
border: 1px dashed #ccc;
opacity: 0.75;
}
/* Pagination links - treated as buttons for consistency */ /* Pagination links - treated as buttons for consistency */
.pagination { .pagination {
a, a,
@ -780,7 +817,7 @@ pose-picker-popover {
} }
} }
.worn-items { .outfit-items {
margin-top: 2rem; margin-top: 2rem;
.items-list { .items-list {

View file

@ -64,16 +64,19 @@ class WardrobeController < ApplicationController
@available_alt_styles = @selected_species ? @available_alt_styles = @selected_species ?
AltStyle.where(species_id: @selected_species.id).by_name_grouped : [] AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
# Load items from the objects[] parameter # Load items from the objects[] and closet[] parameters
item_ids = params[:objects] || [] worn_item_ids = params[:objects] || []
items = Item.where(id: item_ids) closeted_item_ids = params[:closet] || []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Build the outfit # Build the outfit
@outfit = Outfit.new( @outfit = Outfit.new(
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"), name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
pet_state: @pet_state, pet_state: @pet_state,
alt_style: @alt_style, alt_style: @alt_style,
worn_items: items, worn_items: worn_items,
closeted_items: closeted_items,
) )
# Preload the manifests for all visible layers, so they load efficiently # Preload the manifests for all visible layers, so they load efficiently
@ -132,7 +135,7 @@ class WardrobeController < ApplicationController
end end
def outfit_state_params_present? def outfit_state_params_present?
params[:species].present? || params[:color].present? || params[:objects].present? params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
end end
def build_search_filters(query_params, outfit) def build_search_filters(query_params, outfit)

View file

@ -17,6 +17,12 @@ module WardrobeHelper
end end
end end
unless except.include?(:closeted_items)
outfit.closeted_items.each do |item|
fields << hidden_field_tag('closet[]', item.id)
end
end
unless except.include?(:q) unless except.include?(:q)
(params[:q] || {}).each do |key, value| (params[:q] || {}).each do |key, value|
fields << hidden_field_tag("q[#{key}]", value) if value.present? fields << hidden_field_tag("q[#{key}]", value) if value.present?
@ -46,28 +52,33 @@ module WardrobeHelper
end end
# Group outfit items by zone, applying smart multi-zone simplification. # Group outfit items by zone, applying smart multi-zone simplification.
# Returns an array of hashes: {zone:, items:} # Returns an array of hashes: {zone_id:, zone_label:, items:}
# Each item entry is {item:, state: :worn/:closeted}.
# This matches the logic from wardrobe-2020's getZonesAndItems function. # This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit) def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil? return [] if outfit.pet_type.nil?
# Get item appearances for this outfit # Build a list of all items with their state (:worn or :closeted)
all_items = outfit.worn_items.map { |i| {item: i, state: :worn} } +
outfit.closeted_items.map { |i| {item: i, state: :closeted} }
# Get item appearances for all items at once
item_appearances = Item.appearances_for( item_appearances = Item.appearances_for(
outfit.worn_items, all_items.map { |e| e[:item] },
outfit.pet_type, outfit.pet_type,
swf_asset_includes: [:zone] swf_asset_includes: [:zone]
) )
# Separate incompatible items (no layers for this pet) # Separate compatible and incompatible items
compatible_items = [] compatible_entries = []
incompatible_items = [] incompatible_entries = []
outfit.worn_items.each do |item| all_items.each do |entry|
appearance = item_appearances[item.id] appearance = item_appearances[entry[:item].id]
if appearance&.present? if appearance&.present?
compatible_items << {item: item, appearance: appearance} compatible_entries << entry.merge(appearance: appearance)
else else
incompatible_items << item incompatible_entries << entry
end end
end end
@ -75,23 +86,19 @@ module WardrobeHelper
items_by_zone = Hash.new { |h, k| h[k] = [] } items_by_zone = Hash.new { |h, k| h[k] = [] }
zones_by_id = {} zones_by_id = {}
compatible_items.each do |item_with_appearance| compatible_entries.each do |entry|
item = item_with_appearance[:item] entry[:appearance].swf_assets.map(&:zone).uniq.each do |zone|
appearance = item_with_appearance[:appearance]
# Get unique zones for this item (an item may have multiple assets per zone)
appearance.swf_assets.map(&:zone).uniq.each do |zone|
zones_by_id[zone.id] = zone zones_by_id[zone.id] = zone
items_by_zone[zone.id] << item items_by_zone[zone.id] << {item: entry[:item], state: entry[:state]}
end end
end end
# Create zone groups with sorted items # Create zone groups with sorted items
zones_and_items = items_by_zone.map do |zone_id, items| zones_and_items = items_by_zone.map do |zone_id, entries|
{ {
zone_id: zone_id, zone_id: zone_id,
zone_label: zones_by_id[zone_id].label, zone_label: zones_by_id[zone_id].label,
items: items.sort_by { |item| item.name.downcase } items: entries.sort_by { |e| e[:item].name.downcase }
} }
end end
@ -107,11 +114,13 @@ module WardrobeHelper
zones_and_items = disambiguate_zone_labels(zones_and_items) zones_and_items = disambiguate_zone_labels(zones_and_items)
# Add incompatible items section if any # Add incompatible items section if any
if incompatible_items.any? if incompatible_entries.any?
zones_and_items << { zones_and_items << {
zone_id: nil, zone_id: nil,
zone_label: "Incompatible", zone_label: "Incompatible",
items: incompatible_items.sort_by { |item| item.name.downcase } items: incompatible_entries
.map { |e| {item: e[:item], state: e[:state]} }
.sort_by { |e| e[:item].name.downcase }
} }
end end
@ -123,13 +132,14 @@ module WardrobeHelper
# Simplify zone groups by removing redundant single-item groups. # Simplify zone groups by removing redundant single-item groups.
# Keep groups with multiple items (conflicts). For single-item groups, # Keep groups with multiple items (conflicts). For single-item groups,
# only keep them if the item doesn't appear in a multi-item group. # only keep them if the item doesn't appear in a multi-item group.
# Each item entry is {item:, state:}.
def simplify_multi_zone_groups(zones_and_items) def simplify_multi_zone_groups(zones_and_items)
# Find groups with conflicts (multiple items) # Find groups with conflicts (multiple items)
groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 } groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 }
# Track which items appear in conflict groups # Track which items appear in conflict groups
items_with_conflicts = Set.new( items_with_conflicts = Set.new(
groups_with_conflicts.flat_map { |g| g[:items].map(&:id) } groups_with_conflicts.flat_map { |g| g[:items].map { |e| e[:item].id } }
) )
# Track which items we've already shown # Track which items we've already shown
@ -139,14 +149,13 @@ module WardrobeHelper
zones_and_items.select do |group| zones_and_items.select do |group|
# Always keep groups with multiple items # Always keep groups with multiple items
if group[:items].length > 1 if group[:items].length > 1
group[:items].each { |item| items_we_have_seen.add(item.id) } group[:items].each { |e| items_we_have_seen.add(e[:item].id) }
true true
else else
# For single-item groups, only keep if: # For single-item groups, only keep if:
# - Item hasn't been seen yet AND # - Item hasn't been seen yet AND
# - Item won't appear in a conflict group # - Item won't appear in a conflict group
item = group[:items].first item_id = group[:items].first[:item].id
item_id = item.id
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id) if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
false false

View file

@ -262,7 +262,11 @@ class Outfit < ApplicationRecord
end end
def same_wardrobe_state_as?(other) def same_wardrobe_state_as?(other)
wardrobe_params == other.wardrobe_params # Exclude :name because it's managed separately via atomic rename, not URL
# state. This also works around the @outfit (new) vs @saved_outfit
# (persisted) split in WardrobeController, where only the unpersisted
# outfit includes :name. We should consider keeping their names in sync.
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
end end
def wardrobe_params def wardrobe_params
@ -274,6 +278,7 @@ class Outfit < ApplicationRecord
closet: closeted_item_ids.sort, closet: closeted_item_ids.sort,
} }
params[:style] = alt_style_id if alt_style_id.present? params[:style] = alt_style_id if alt_style_id.present?
params[:name] = name if !persisted? && name.present?
params params
end end
@ -313,9 +318,23 @@ class Outfit < ApplicationRecord
end end
end end
# Create a copy of this outfit, but *not* wearing the given item. # Create a copy of this outfit without the given item at all
# (removed from both worn and closeted).
def without_item(item) def without_item(item)
dup.tap { |o| o.worn_items.delete(item) } dup.tap do |o|
o.worn_items.delete(item)
o.closeted_items.delete(item)
end
end
# Create a copy of this outfit with the given item moved from worn to
# closeted. If it's not currently worn, returns the outfit unchanged.
def hide_item(item)
dup.tap do |o|
next unless o.worn_item_ids.include?(item.id)
o.worn_items.delete(item)
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
end
end end
# Create a copy of this outfit, additionally wearing the given item. # Create a copy of this outfit, additionally wearing the given item.
@ -325,6 +344,9 @@ class Outfit < ApplicationRecord
# Skip if item is nil, already worn, or outfit has no pet_state # Skip if item is nil, already worn, or outfit has no pet_state
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil? next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
# If the item was closeted, remove it from closet (it's moving to worn)
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
# Load appearances for the new item and all currently worn items # Load appearances for the new item and all currently worn items
all_items = o.worn_items + [item] all_items = o.worn_items + [item]
appearances = Item.appearances_for(all_items, o.pet_type, appearances = Item.appearances_for(all_items, o.pet_type,

View file

@ -1,5 +1,6 @@
- is_worn = @outfit.worn_items.include?(item) - is_worn = @outfit.worn_items.include?(item)
%li.item-card - is_closeted = @outfit.closeted_items.include?(item)
%li.item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}}
.item-thumbnail .item-thumbnail
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy" = image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
.item-info .item-info
@ -8,6 +9,16 @@
= render "items/badges/kind", item: item = render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item = render "items/badges/first_seen", item: item
- if is_worn - if is_worn
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
👁️‍🗨️
= outfit_state_params @outfit.hide_item(item)
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
= outfit_state_params @outfit.without_item(item)
- elsif is_closeted
= button_to @wardrobe_path, method: :get, class: "item-show-button", title: "Show #{item.name}", "aria-label": "Show #{item.name}" do
👁️
= outfit_state_params @outfit.with_item(item)
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do = button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
= outfit_state_params @outfit.without_item(item) = outfit_state_params @outfit.without_item(item)

View file

@ -3,8 +3,6 @@
- if @has_unsaved_changes - if @has_unsaved_changes
= form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f| = form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f|
= render "save_outfit_fields" = render "save_outfit_fields"
- @saved_outfit.closeted_items.each do |item|
= hidden_field_tag "outfit[item_ids][closeted][]", item.id
= f.submit "Save", class: "outfit-save-button" = f.submit "Save", class: "outfit-save-button"
- else - else
%button.outfit-save-button{disabled: true} Saved! %button.outfit-save-button{disabled: true} Saved!

View file

@ -6,3 +6,5 @@
= hidden_field_tag "outfit[alt_style_id]", @alt_style.id = hidden_field_tag "outfit[alt_style_id]", @alt_style.id
- @outfit.worn_items.each do |item| - @outfit.worn_items.each do |item|
= hidden_field_tag "outfit[item_ids][worn][]", item.id = hidden_field_tag "outfit[item_ids][worn][]", item.id
- @outfit.closeted_items.each do |item|
= hidden_field_tag "outfit[item_ids][closeted][]", item.id

View file

@ -1,12 +1,12 @@
.search-results .search-results
- if @search_results.any? - if @search_results.any?
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] } = will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
%ul.search-results-list %ul.search-results-list
- @search_results.each do |item| - @search_results.each do |item|
= render "item_card", item: item = render "item_card", item: item
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] } = will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
- else - else
.empty-state .empty-state

View file

@ -69,11 +69,11 @@
- else - else
= render "outfit_rename_field" = render "outfit_rename_field"
= render "save_button" = render "save_button"
- if @outfit.worn_items.any? - if @outfit.worn_items.any? || @outfit.closeted_items.any?
.worn-items .outfit-items
- outfit_items_by_zone(@outfit).each do |zone_group| - outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group .zone-group
%h3.zone-label= zone_group[:zone_label] %h3.zone-label= zone_group[:zone_label]
%ul.items-list %ul.items-list
- zone_group[:items].each do |item| - zone_group[:items].each do |entry|
= render "item_card", item: item = render "item_card", item: entry[:item]

View file

@ -58,7 +58,7 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search,
- Stale style params dropped gracefully when switching species - Stale style params dropped gracefully when switching species
- Search results auto-filtered by alt style compatibility - Search results auto-filtered by alt style compatibility
**Closeted Items** **Closeted Items** (baseline done, progressive enhancement pending)
- Instead of just wearing/unwearing items, also support a "closeted" state: the user is *considering* this item, - Instead of just wearing/unwearing items, also support a "closeted" state: the user is *considering* this item,
but it is not displayed on the pet itself right now. but it is not displayed on the pet itself right now.
- Wearing an item will stop wearing, but keep in closet, items that are mutually incompatible with it. - Wearing an item will stop wearing, but keep in closet, items that are mutually incompatible with it.
@ -66,7 +66,10 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search,
- Unworn items have "Add" (wear). - Unworn items have "Add" (wear).
- Worn items have "Hide" (stop wearing, keep in closet) and "Remove" (remove from worn and closet). - Worn items have "Hide" (stop wearing, keep in closet) and "Remove" (remove from worn and closet).
- Closeted items have "Show" (wear) and "Remove" (remove from closet). - Closeted items have "Show" (wear) and "Remove" (remove from closet).
- Progressive enhancement: - Visual distinction: worn items have green emphasis, closeted items have dashed border and reduced opacity.
- Closeted items appear in zone groups alongside worn items.
- `closet[]` URL params, saving/loading, and search pagination all work.
- Progressive enhancement (pending):
- In the outfit view, items in the same zone group (mutually-incompatible) are a radio group. Whichever radio button - In the outfit view, items in the same zone group (mutually-incompatible) are a radio group. Whichever radio button
is checked is the worn item. The others are merely closeted. is checked is the worn item. The others are merely closeted.
- In the search view, each item has a worn checkbox (analogous to the worn radio button). - In the search view, each item has a worn checkbox (analogous to the worn radio button).