diff --git a/app/assets/javascripts/outfit-rename-field.js b/app/assets/javascripts/outfit-rename-field.js new file mode 100644 index 00000000..c3b0d4f8 --- /dev/null +++ b/app/assets/javascripts/outfit-rename-field.js @@ -0,0 +1,42 @@ +/** + * OutfitRenameField web component + * + * Progressive enhancement for the outfit name field: + * - Shows a static text header with a pencil icon button + * - Pencil appears on hover/focus of the container + * - Clicking pencil switches to the editable form + * - Enter submits, Escape or Cancel reverts to static display + * + * State is managed via the `editing` attribute, which CSS uses to toggle + * visibility. Turbo morphs naturally reset this attribute (since it's not in + * the server HTML), so no morph-specific handling is needed. + */ +class OutfitRenameField extends HTMLElement { + connectedCallback() { + const pencil = this.querySelector(".outfit-rename-pencil"); + const cancel = this.querySelector(".outfit-rename-cancel"); + const input = this.querySelector("input[type=text]"); + if (!pencil || !cancel || !input) return; + + pencil.addEventListener("click", () => { + this.dataset.originalValue = input.value; + this.setAttribute("editing", ""); + input.focus(); + input.select(); + }); + + cancel.addEventListener("click", () => { + input.value = this.dataset.originalValue ?? input.value; + this.removeAttribute("editing"); + }); + + this.addEventListener("keydown", (e) => { + if (e.key === "Escape" && this.hasAttribute("editing")) { + e.preventDefault(); + cancel.click(); + } + }); + } +} + +customElements.define("outfit-rename-field", OutfitRenameField); diff --git a/app/assets/stylesheets/wardrobe/show.css b/app/assets/stylesheets/wardrobe/show.css index d6a772ae..fe6a24a3 100644 --- a/app/assets/stylesheets/wardrobe/show.css +++ b/app/assets/stylesheets/wardrobe/show.css @@ -911,15 +911,84 @@ pose-picker-popover { } } -/* Progressive enhancement: hide rename submit when JS is available */ +/* Rename button: hidden by default, shown on hover/focus */ .outfit-name-submit { margin-left: 0.5rem; + display: none; +} + +.outfit-name-form:focus-within .outfit-name-submit, +.outfit-name-form:hover .outfit-name-submit { + display: inline; +} + +/* Static name display for non-owners */ +.outfit-name-static { + font-size: 1.5rem; + font-weight: bold; + color: #448844; + padding: 0.25rem 0.5rem; + flex: 1; + min-width: 0; +} + +/* Web component: static display with pencil icon */ +outfit-rename-field { + flex: 1; + min-width: 0; +} + +outfit-rename-field .outfit-name-form { + display: none; +} + +outfit-rename-field[editing] .outfit-rename-static-display { + display: none; +} + +outfit-rename-field[editing] .outfit-name-form { + display: flex; +} + +.outfit-rename-static-display { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.outfit-rename-name { + font-size: 1.5rem; + font-weight: bold; + color: #448844; + padding: 0.25rem 0.5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.outfit-rename-pencil { + background: none; + border: none; + cursor: pointer; + font-size: 1rem; + padding: 0.25rem; + opacity: 0; + transition: opacity 0.15s; +} + +.outfit-rename-static-display:hover .outfit-rename-pencil, +.outfit-rename-pencil:focus { + opacity: 1; +} + +.outfit-rename-cancel { + margin-left: 0.5rem; } -@media (scripting: enabled) { - .outfit-name-submit { - display: none; - } +/* Hide save button when rename is in editing state */ +.outfit-header:has(outfit-rename-field[editing]) .outfit-save-form, +.outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled { + display: none; } .outfit-save-form { diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb index 21bff62e..70adb52c 100644 --- a/app/controllers/outfits_controller.rb +++ b/app/controllers/outfits_controller.rb @@ -133,7 +133,14 @@ class OutfitsController < ApplicationController def update if @outfit.update(outfit_params) respond_to do |format| - format.html { redirect_to wardrobe_v2_outfit_path(@outfit) } + format.html do + return_to = params[:return_to] + if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//") + redirect_to return_to + else + redirect_to wardrobe_v2_outfit_path(@outfit) + end + end format.json { render json: @outfit } end else diff --git a/app/controllers/wardrobe_controller.rb b/app/controllers/wardrobe_controller.rb index 2fa47ed4..600f8ca1 100644 --- a/app/controllers/wardrobe_controller.rb +++ b/app/controllers/wardrobe_controller.rb @@ -70,7 +70,7 @@ class WardrobeController < ApplicationController # Build the outfit @outfit = Outfit.new( - name: params[:name].presence || @saved_outfit&.name || "Untitled outfit", + name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"), pet_state: @pet_state, alt_style: @alt_style, worn_items: items, diff --git a/app/helpers/wardrobe_helper.rb b/app/helpers/wardrobe_helper.rb index 62a685d9..ac89e229 100644 --- a/app/helpers/wardrobe_helper.rb +++ b/app/helpers/wardrobe_helper.rb @@ -5,7 +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(:name, @outfit.name) if !@saved_outfit && @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 ad30266c..48270019 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -267,7 +267,6 @@ class Outfit < ApplicationRecord def wardrobe_params params = { - name: name, color: color_id, species: species_id, pose: pose, diff --git a/app/views/wardrobe/_outfit_rename_field.html.haml b/app/views/wardrobe/_outfit_rename_field.html.haml new file mode 100644 index 00000000..ddb6636d --- /dev/null +++ b/app/views/wardrobe/_outfit_rename_field.html.haml @@ -0,0 +1,22 @@ +- if @saved_outfit + - form_url = outfit_path(@saved_outfit) + - form_method = :patch + - field_name = "outfit[name]" +- else + - form_url = @wardrobe_path + - form_method = :get + - field_name = :name + +%outfit-rename-field + .outfit-rename-static-display + %span.outfit-rename-name= @outfit.name.presence || "Untitled outfit" + %button.outfit-rename-pencil{type: "button", "aria-label": "Rename outfit"} ✏️ + = form_with url: form_url, method: form_method, class: "outfit-name-form" do |f| + = hidden_field_tag :return_to, request.fullpath + - unless @saved_outfit + = outfit_state_params except: [:name] + = f.text_field 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" + %button.outfit-rename-cancel{type: "button"} Cancel diff --git a/app/views/wardrobe/show.html.haml b/app/views/wardrobe/show.html.haml index 3538b832..9065115f 100644 --- a/app/views/wardrobe/show.html.haml +++ b/app/views/wardrobe/show.html.haml @@ -16,6 +16,7 @@ = 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 "outfit-rename-field", async: true = javascript_include_tag "wardrobe/show", async: true = csrf_meta_tags %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} @@ -63,12 +64,10 @@ = render "search_results" - else .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" + - if @saved_outfit && !@is_owner + .outfit-name-static= @outfit.name + - else + = render "outfit_rename_field" = render "save_button" - if @outfit.worn_items.any? .worn-items diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index dfe44e48..6c8bade4 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -50,8 +50,7 @@ The goal is a basic usable wardrobe. Species/color/pose selection, item search, - 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) +- Outfit name: For saved outfits, rename is a standalone PATCH operation (not a URL param). For unsaved outfits, name is tracked as a URL param. Progressive enhancement shows static text + pencil icon for renaming. **Alt Styles Support** (done) - `Outfit#visible_layers` handles alt styles @@ -93,6 +92,7 @@ Feature parity with Wardrobe 2020 where valuable. - **Conflict management**: Auto zone conflict resolution, smart item restoration on unwear - **Pet loading**: "Load my pet" by name, modeling integration - **Search enhancements**: Inline syntax (`is:nc`, `fits:blue-acara`), advanced filter UI, autocomplete +- **Outfit auto-saving**: Save outfit changes automatically over time, rather than requiring clicking Save ### Phase 4: Migration & Rollout diff --git a/spec/models/outfit_spec.rb b/spec/models/outfit_spec.rb index 15bb3c51..0f6e948e 100644 --- a/spec/models/outfit_spec.rb +++ b/spec/models/outfit_spec.rb @@ -351,11 +351,11 @@ RSpec.describe Outfit do expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true end - it "returns false when names differ" do + it "returns true even when names differ (name is not part of wardrobe state)" 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 + expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true end it "returns false when poses differ" do