From 0d4b5531627a6efed9331859626755aa8ad25fb4 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Thu, 5 Feb 2026 20:47:05 -0800 Subject: [PATCH] [WV2] Outfit saving first draft --- app/assets/javascripts/wardrobe/show.js | 34 +++++- app/assets/stylesheets/wardrobe/show.css | 115 +++++++++++++++++- app/controllers/outfits_controller.rb | 26 +++- app/controllers/wardrobe_controller.rb | 27 ++++ app/helpers/wardrobe_helper.rb | 1 + app/models/outfit.rb | 9 +- app/views/wardrobe/_item_card.html.haml | 4 +- .../wardrobe/_pose_picker_form.html.haml | 2 +- app/views/wardrobe/_save_button.html.haml | 22 ++++ .../wardrobe/_save_outfit_fields.html.haml | 8 ++ .../wardrobe/_species_color_picker.html.haml | 2 +- .../wardrobe/_style_picker_form.html.haml | 2 +- app/views/wardrobe/show.html.haml | 18 ++- config/routes.rb | 1 + docs/wardrobe-v2-migration.md | 28 ++++- spec/models/outfit_spec.rb | 66 ++++++++++ 16 files changed, 337 insertions(+), 28 deletions(-) create mode 100644 app/views/wardrobe/_save_button.html.haml create mode 100644 app/views/wardrobe/_save_outfit_fields.html.haml diff --git a/app/assets/javascripts/wardrobe/show.js b/app/assets/javascripts/wardrobe/show.js index cc202d01..8190e959 100644 --- a/app/assets/javascripts/wardrobe/show.js +++ b/app/assets/javascripts/wardrobe/show.js @@ -1,6 +1,36 @@ // 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. -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(); diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index bc7ff49d..d6a772ae 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -751,12 +751,6 @@ pose-picker-popover { overflow-y: auto; box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3); - h1 { - margin-top: 0; - font-size: 1.75rem; - color: #448844; - } - h2 { font-size: 1.25rem; 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 =================================================================== */ diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb index 0da8e394..21bff62e 100644 --- a/app/controllers/outfits_controller.rb +++ b/app/controllers/outfits_controller.rb @@ -6,9 +6,18 @@ class OutfitsController < ApplicationController @outfit.user = current_user 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 - 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 @@ -123,9 +132,18 @@ class OutfitsController < ApplicationController def update 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 - 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 diff --git a/app/controllers/wardrobe_controller.rb b/app/controllers/wardrobe_controller.rb index b85d9f16..2fa47ed4 100644 --- a/app/controllers/wardrobe_controller.rb +++ b/app/controllers/wardrobe_controller.rb @@ -1,5 +1,19 @@ class WardrobeController < ApplicationController 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 @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") @@ -56,6 +70,7 @@ class WardrobeController < ApplicationController # Build the outfit @outfit = Outfit.new( + name: params[:name].presence || @saved_outfit&.name || "Untitled outfit", pet_state: @pet_state, alt_style: @alt_style, worn_items: items, @@ -68,6 +83,14 @@ class WardrobeController < ApplicationController # Also preload alt style layer manifests for the style picker thumbnails 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 @search_mode = params[:q].present? if @search_mode @@ -108,6 +131,10 @@ class WardrobeController < ApplicationController poses_hash end + def outfit_state_params_present? + params[:species].present? || params[:color].present? || params[:objects].present? + end + def build_search_filters(query_params, outfit) filters = [] diff --git a/app/helpers/wardrobe_helper.rb b/app/helpers/wardrobe_helper.rb index 54c1b6c7..62a685d9 100644 --- a/app/helpers/wardrobe_helper.rb +++ b/app/helpers/wardrobe_helper.rb @@ -5,6 +5,7 @@ module WardrobeHelper def outfit_state_params(outfit = @outfit, except: []) 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(:color, @outfit.color_id) unless except.include?(:color) fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose) diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 74a5c596..ad30266c 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -261,15 +261,18 @@ class Outfit < ApplicationRecord (biology_layers + item_layers).sort_by(&:depth) end + def same_wardrobe_state_as?(other) + wardrobe_params == other.wardrobe_params + end + def wardrobe_params params = { name: name, color: color_id, species: species_id, pose: pose, - state: pet_state_id, - objects: worn_item_ids, - closet: closeted_item_ids, + objects: worn_item_ids.sort, + closet: closeted_item_ids.sort, } params[:style] = alt_style_id if alt_style_id.present? params diff --git a/app/views/wardrobe/_item_card.html.haml b/app/views/wardrobe/_item_card.html.haml index e111a0be..158040a3 100644 --- a/app/views/wardrobe/_item_card.html.haml +++ b/app/views/wardrobe/_item_card.html.haml @@ -8,10 +8,10 @@ = render "items/badges/kind", item: item = render "items/badges/first_seen", item: item - 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) - 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) diff --git a/app/views/wardrobe/_pose_picker_form.html.haml b/app/views/wardrobe/_pose_picker_form.html.haml index 8643ca8e..dc3bb0f2 100644 --- a/app/views/wardrobe/_pose_picker_form.html.haml +++ b/app/views/wardrobe/_pose_picker_form.html.haml @@ -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] %table.pose-picker-table %thead diff --git a/app/views/wardrobe/_save_button.html.haml b/app/views/wardrobe/_save_button.html.haml new file mode 100644 index 00000000..4b821c21 --- /dev/null +++ b/app/views/wardrobe/_save_button.html.haml @@ -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" diff --git a/app/views/wardrobe/_save_outfit_fields.html.haml b/app/views/wardrobe/_save_outfit_fields.html.haml new file mode 100644 index 00000000..11e4226f --- /dev/null +++ b/app/views/wardrobe/_save_outfit_fields.html.haml @@ -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 diff --git a/app/views/wardrobe/_species_color_picker.html.haml b/app/views/wardrobe/_species_color_picker.html.haml index 7e762af6..01b88710 100644 --- a/app/views/wardrobe/_species_color_picker.html.haml +++ b/app/views/wardrobe/_species_color_picker.html.haml @@ -1,5 +1,5 @@ %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] = select_tag :color, options_from_collection_for_select(@colors, "id", "human_name", diff --git a/app/views/wardrobe/_style_picker_form.html.haml b/app/views/wardrobe/_style_picker_form.html.haml index 9214f15d..1c02f162 100644 --- a/app/views/wardrobe/_style_picker_form.html.haml +++ b/app/views/wardrobe/_style_picker_form.html.haml @@ -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] .style-picker-list - @available_alt_styles.each do |alt_style| diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index a7913e89..3538b832 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -20,7 +20,10 @@ = csrf_meta_tags %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} %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 - if @pet_type.nil? .no-preview-message @@ -48,10 +51,10 @@ .outfit-controls-section .item-search-form - 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] - = 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 = f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items" = f.submit "Search" @@ -59,7 +62,14 @@ - if @search_mode = render "search_results" - 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? .worn-items - outfit_items_by_zone(@outfit).each do |zone_group| diff --git a/config/routes.rb b/config/routes.rb index f8e03bfa..a768497d 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -12,6 +12,7 @@ OpenneoImpressItems::Application.routes.draw do get '/outfits/new', to: 'outfits#edit', as: :wardrobe get '/wardrobe' => redirect('/outfits/new') 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' # The outfits users have created! diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index 4556a228..dfe44e48 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -23,6 +23,7 @@ Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a - **Outfit rendering**: Uses the shared `` web component - **Progressive enhancement**: Everything works without JS; web components add auto-submit and smoother interactions - **Smooth navigation**: Idiomorph DOM morphing reuses `` 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 @@ -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. -**Outfit Saving/Loading** (critical, not started) -- Save button, editable outfit name, auto-save with indicator -- Route to load saved outfits (`GET /outfits/:id/v2`) -- Owner-only editing, handle unsaved outfits gracefully +**Outfit Saving/Loading** (basic implementation done) +- Save button near editable outfit name, disabled/"Saved!" when state matches saved +- Route to load saved outfits (`GET /outfits/:id/v2`) with redirect-based state initialization +- "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) - `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 - 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 Match the quality and usability of Wardrobe 2020 where it matters. diff --git a/spec/models/outfit_spec.rb b/spec/models/outfit_spec.rb index 93d7eb4e..15bb3c51 100644 --- a/spec/models/outfit_spec.rb +++ b/spec/models/outfit_spec.rb @@ -343,6 +343,72 @@ RSpec.describe Outfit do item 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 before do # Clean up any existing pet types to avoid conflicts