[WV2] Outfit saving first draft

This commit is contained in:
Emi Matchu 2026-02-05 20:47:05 -08:00
parent 5e68d3809c
commit 0d4b553162
16 changed files with 337 additions and 28 deletions

View file

@ -1,6 +1,36 @@
// Wardrobe v2 - Simple Rails+Turbo outfit editor // Wardrobe v2 - Simple Rails+Turbo outfit editor
// //
// This page uses Turbo Frames for instant updates when changing species/color. // This page uses Turbo for instant updates when changing species/color.
// The outfit_viewer Web Component handles the pet rendering. // The outfit_viewer Web Component handles the pet rendering.
console.log("Wardrobe v2 loaded!"); // Unsaved changes warning: use a MutationObserver to watch the
// data-has-unsaved-changes attribute on the wardrobe container. This is more
// robust than event listeners because it works regardless of how the DOM is
// updated (Turbo morph, direct manipulation, etc.).
function setupUnsavedChangesObserver() {
const container = document.querySelector("[data-has-unsaved-changes]");
if (!container) return;
function update() {
if (container.dataset.hasUnsavedChanges === "true") {
window.onbeforeunload = (e) => {
e.preventDefault();
return "";
};
} else {
window.onbeforeunload = null;
}
}
// Set initial state
update();
// Watch for attribute changes
const observer = new MutationObserver(update);
observer.observe(container, {
attributes: true,
attributeFilter: ["data-has-unsaved-changes"],
});
}
setupUnsavedChangesObserver();

View file

@ -751,12 +751,6 @@ pose-picker-popover {
overflow-y: auto; overflow-y: auto;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3); box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #448844;
}
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
color: #448844; color: #448844;
@ -845,6 +839,115 @@ pose-picker-popover {
} }
} }
/* ===================================================================
Flash Messages
=================================================================== */
.flash-messages {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0.75rem 1rem;
text-align: center;
}
.flash-alert {
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
border-radius: 6px;
padding: 0.75rem 1rem;
max-width: 600px;
margin: 0 auto;
}
/* ===================================================================
Outfit Header (name + save button)
=================================================================== */
.outfit-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.outfit-name-form {
flex: 1;
min-width: 0;
display: flex;
}
.outfit-name-input {
flex: 1;
min-width: 0;
font-size: 1.5rem;
font-weight: bold;
color: #448844;
border: 1px solid transparent;
border-radius: 6px;
padding: 0.25rem 0.5rem;
background: transparent;
transition: border-color 0.2s, background 0.2s;
&:hover {
border-color: #ddd;
background: #fafafa;
}
&:focus {
outline: none;
border-color: #448844;
background: white;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
}
&::placeholder {
color: #aaa;
font-weight: normal;
}
}
/* Progressive enhancement: hide rename submit when JS is available */
.outfit-name-submit {
margin-left: 0.5rem;
}
@media (scripting: enabled) {
.outfit-name-submit {
display: none;
}
}
.outfit-save-form {
display: inline;
}
.outfit-save-button {
white-space: nowrap;
&:disabled {
opacity: 0.6;
cursor: default;
color: #888;
border-color: #ddd;
background: #f5f5f5;
&:hover {
background: #f5f5f5;
border-color: #ddd;
}
}
}
a.outfit-save-button {
text-decoration: none;
display: inline-block;
}
/* =================================================================== /* ===================================================================
Animations Animations
=================================================================== */ =================================================================== */

View file

@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
@outfit.user = current_user @outfit.user = current_user
if @outfit.save if @outfit.save
render :json => @outfit respond_to do |format|
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
format.json { render json: @outfit }
end
else else
render_outfit_errors respond_to do |format|
format.html do
redirect_back fallback_location: wardrobe_v2_path,
alert: @outfit.errors.full_messages.join(", ")
end
format.json { render_outfit_errors }
end
end end
end end
@ -123,9 +132,18 @@ class OutfitsController < ApplicationController
def update def update
if @outfit.update(outfit_params) if @outfit.update(outfit_params)
render :json => @outfit respond_to do |format|
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
format.json { render json: @outfit }
end
else else
render_outfit_errors respond_to do |format|
format.html do
redirect_back fallback_location: wardrobe_v2_outfit_path(@outfit),
alert: @outfit.errors.full_messages.join(", ")
end
format.json { render_outfit_errors }
end
end end
end end

View file

@ -1,5 +1,19 @@
class WardrobeController < ApplicationController class WardrobeController < ApplicationController
def show def show
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
# If visiting a saved outfit with no state params, redirect with the
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
# the rest of the action always reads from params.
if @saved_outfit && !outfit_state_params_present?
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
return
end
# Set the form target path for all wardrobe forms
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
# Get selected species and color from params, or default to Blue Acara # Get selected species and color from params, or default to Blue Acara
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara") @selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue") @selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
@ -56,6 +70,7 @@ class WardrobeController < ApplicationController
# Build the outfit # Build the outfit
@outfit = Outfit.new( @outfit = Outfit.new(
name: params[:name].presence || @saved_outfit&.name || "Untitled outfit",
pet_state: @pet_state, pet_state: @pet_state,
alt_style: @alt_style, alt_style: @alt_style,
worn_items: items, worn_items: items,
@ -68,6 +83,14 @@ class WardrobeController < ApplicationController
# Also preload alt style layer manifests for the style picker thumbnails # Also preload alt style layer manifests for the style picker thumbnails
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
# Compute saved outfit state for the view
if @saved_outfit
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
else
@has_unsaved_changes = false
end
# Handle search mode # Handle search mode
@search_mode = params[:q].present? @search_mode = params[:q].present?
if @search_mode if @search_mode
@ -108,6 +131,10 @@ class WardrobeController < ApplicationController
poses_hash poses_hash
end end
def outfit_state_params_present?
params[:species].present? || params[:color].present? || params[:objects].present?
end
def build_search_filters(query_params, outfit) def build_search_filters(query_params, outfit)
filters = [] filters = []

View file

@ -5,6 +5,7 @@ module WardrobeHelper
def outfit_state_params(outfit = @outfit, except: []) def outfit_state_params(outfit = @outfit, except: [])
fields = [] fields = []
fields << hidden_field_tag(:name, @outfit.name) if @outfit.name.present? && !except.include?(:name)
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)

View file

@ -261,15 +261,18 @@ class Outfit < ApplicationRecord
(biology_layers + item_layers).sort_by(&:depth) (biology_layers + item_layers).sort_by(&:depth)
end end
def same_wardrobe_state_as?(other)
wardrobe_params == other.wardrobe_params
end
def wardrobe_params def wardrobe_params
params = { params = {
name: name, name: name,
color: color_id, color: color_id,
species: species_id, species: species_id,
pose: pose, pose: pose,
state: pet_state_id, objects: worn_item_ids.sort,
objects: worn_item_ids, closet: closeted_item_ids.sort,
closet: closeted_item_ids,
} }
params[:style] = alt_style_id if alt_style_id.present? params[:style] = alt_style_id if alt_style_id.present?
params params

View file

@ -8,10 +8,10 @@
= 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_v2_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)
- else - else
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do = button_to @wardrobe_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
= outfit_state_params @outfit.with_item(item) = outfit_state_params @outfit.with_item(item)

View file

@ -1,4 +1,4 @@
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f| = form_with url: @wardrobe_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose, :style] = outfit_state_params except: [:pose, :style]
%table.pose-picker-table %table.pose-picker-table
%thead %thead

View file

@ -0,0 +1,22 @@
- if @saved_outfit
- if @is_owner
- if @has_unsaved_changes
= form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f|
= 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"
- else
%button.outfit-save-button{disabled: true} Saved!
- elsif user_signed_in?
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
= render "save_outfit_fields"
= f.submit "Save a copy", class: "outfit-save-button"
- else
= link_to "Log in to save a copy",
new_auth_user_session_path(return_to: request.fullpath),
class: "outfit-save-button"
- elsif user_signed_in?
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
= render "save_outfit_fields"
= f.submit "Save", class: "outfit-save-button"

View file

@ -0,0 +1,8 @@
= hidden_field_tag "outfit[name]", @outfit.name
= hidden_field_tag "outfit[biology][species_id]", @outfit.species_id
= hidden_field_tag "outfit[biology][color_id]", @outfit.color_id
= hidden_field_tag "outfit[biology][pose]", @outfit.pet_state.pose
- if @alt_style
= hidden_field_tag "outfit[alt_style_id]", @alt_style.id
- @outfit.worn_items.each do |item|
= hidden_field_tag "outfit[item_ids][worn][]", item.id

View file

@ -1,5 +1,5 @@
%species-color-picker %species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f| = form_with url: @wardrobe_path, method: :get do |f|
= outfit_state_params except: [:color, :species] = outfit_state_params except: [:color, :species]
= select_tag :color, = select_tag :color,
options_from_collection_for_select(@colors, "id", "human_name", options_from_collection_for_select(@colors, "id", "human_name",

View file

@ -1,4 +1,4 @@
= form_with url: wardrobe_v2_path, method: :get, class: "style-picker-form" do |f| = form_with url: @wardrobe_path, method: :get, class: "style-picker-form" do |f|
= outfit_state_params except: [:style] = outfit_state_params except: [:style]
.style-picker-list .style-picker-list
- @available_alt_styles.each do |alt_style| - @available_alt_styles.each do |alt_style|

View file

@ -20,7 +20,10 @@
= csrf_meta_tags = csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2 %body.wardrobe-v2
.wardrobe-container - if flash[:alert]
.flash-messages
.flash-alert= flash[:alert]
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
.outfit-preview-section .outfit-preview-section
- if @pet_type.nil? - if @pet_type.nil?
.no-preview-message .no-preview-message
@ -48,10 +51,10 @@
.outfit-controls-section .outfit-controls-section
.item-search-form .item-search-form
- if @search_mode - if @search_mode
= button_to wardrobe_v2_path, method: :get, class: "back-button" do = button_to @wardrobe_path, method: :get, class: "back-button" do
= outfit_state_params except: [:q] = outfit_state_params except: [:q]
= form_with url: wardrobe_v2_path, method: :get, class: "search-form" do |f| = form_with url: @wardrobe_path, method: :get, class: "search-form" do |f|
= outfit_state_params = outfit_state_params
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items" = f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
= f.submit "Search" = f.submit "Search"
@ -59,7 +62,14 @@
- if @search_mode - if @search_mode
= render "search_results" = render "search_results"
- else - else
%h1 Untitled outfit .outfit-header
= form_with url: @wardrobe_path, method: :get, class: "outfit-name-form" do |f|
= outfit_state_params except: [:name]
= f.text_field :name, value: @outfit.name,
class: "outfit-name-input", placeholder: "Untitled outfit",
"aria-label": "Outfit name"
= f.submit "Rename", name: nil, class: "outfit-name-submit"
= render "save_button"
- if @outfit.worn_items.any? - if @outfit.worn_items.any?
.worn-items .worn-items
- outfit_items_by_zone(@outfit).each do |zone_group| - outfit_items_by_zone(@outfit).each do |zone_group|

View file

@ -12,6 +12,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/outfits/new', to: 'outfits#edit', as: :wardrobe get '/outfits/new', to: 'outfits#edit', as: :wardrobe
get '/wardrobe' => redirect('/outfits/new') get '/wardrobe' => redirect('/outfits/new')
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2 get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
get '/start/:color_name/:species_name' => 'outfits#start' get '/start/:color_name/:species_name' => 'outfits#start'
# The outfits users have created! # The outfits users have created!

View file

@ -23,6 +23,7 @@ Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a
- **Outfit rendering**: Uses the shared `<outfit-viewer>` web component - **Outfit rendering**: Uses the shared `<outfit-viewer>` web component
- **Progressive enhancement**: Everything works without JS; web components add auto-submit and smoother interactions - **Progressive enhancement**: Everything works without JS; web components add auto-submit and smoother interactions
- **Smooth navigation**: Idiomorph DOM morphing reuses `<outfit-viewer>` layers across full-page navigations - **Smooth navigation**: Idiomorph DOM morphing reuses `<outfit-viewer>` layers across full-page navigations
- **Outfit saving/loading**: Load saved outfits at `/outfits/:id/v2`, save changes (owner) or save copies (non-owner), editable outfit name, unsaved changes warning
### Key implementation files ### Key implementation files
@ -44,10 +45,13 @@ Code lives in `app/controllers/wardrobe_controller.rb`, `app/views/wardrobe/`, `
The goal is a basic usable wardrobe. Species/color/pose selection, item search, and add/remove are already done. The goal is a basic usable wardrobe. Species/color/pose selection, item search, and add/remove are already done.
**Outfit Saving/Loading** (critical, not started) **Outfit Saving/Loading** (basic implementation done)
- Save button, editable outfit name, auto-save with indicator - Save button near editable outfit name, disabled/"Saved!" when state matches saved
- Route to load saved outfits (`GET /outfits/:id/v2`) - Route to load saved outfits (`GET /outfits/:id/v2`) with redirect-based state initialization
- Owner-only editing, handle unsaved outfits gracefully - "Save a copy" for non-owners, login prompt for unauthenticated users
- `beforeunload` warning for unsaved changes via MutationObserver
- Outfit name tracked as URL param (`name`) to survive navigation
- Not yet done: auto-save, renaming (the field is present but isn't consistently tracked)
**Alt Styles Support** (done) **Alt Styles Support** (done)
- `Outfit#visible_layers` handles alt styles - `Outfit#visible_layers` handles alt styles
@ -55,6 +59,22 @@ 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**
- 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.
- Wearing an item will stop wearing, but keep in closet, items that are mutually incompatible with it.
- Baseline behavior: separate toggle buttons for worn state and closeted state.
- Unworn items have "Add" (wear).
- 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).
- Progressive enhancement:
- 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.
- In the search view, each item has a worn checkbox (analogous to the worn radio button).
- In both views, when the item is closeted (always the case in the outfit view), there is a "Remove" button.
- The radio button and checkbox are visually hidden, and are reflected in styles that emphasize the selected item,
e.g., somewhat darker border/background, bold, etc. (See Wardrobe 2020.)
### Phase 2: Polish & Parity ### Phase 2: Polish & Parity
Match the quality and usability of Wardrobe 2020 where it matters. Match the quality and usability of Wardrobe 2020 where it matters.

View file

@ -343,6 +343,72 @@ RSpec.describe Outfit do
item item
end end
describe "#same_wardrobe_state_as?" do
it "returns true for outfits with identical state" do
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when names differ" do
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when poses differ" do
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when worn items differ" do
hat = create_item("Hat", zones(:hat1))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns true regardless of worn item order" do
hat = create_item("Hat", zones(:hat1))
shirt = create_item("Shirt", zones(:shirtdress))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when species differ" do
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when alt styles differ" do
alt_style = AltStyle.create!(
species: acara,
color: blue,
body_id: 999,
series_name: "Nostalgic",
thumbnail_url: "https://images.neopets.example/alt.png"
)
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
end
describe "#visible_layers" do describe "#visible_layers" do
before do before do
# Clean up any existing pet types to avoid conflicts # Clean up any existing pet types to avoid conflicts