diff --git a/app/assets/stylesheets/outfits/new_v2.css b/app/assets/stylesheets/outfits/new_v2.css index 3db4fe48..d9f376f2 100644 --- a/app/assets/stylesheets/outfits/new_v2.css +++ b/app/assets/stylesheets/outfits/new_v2.css @@ -248,7 +248,8 @@ body.wardrobe-v2 { .item-info { flex: 1; - min-width: 0; /* Allow text to truncate */ + min-width: 0; + /* Allow text to truncate */ display: flex; flex-direction: column; gap: 0.5rem; @@ -306,6 +307,235 @@ body.wardrobe-v2 { } } +.item-search-form { + margin-bottom: 1.5rem; + display: flex; + gap: 0.5rem; + + input[type="text"] { + flex: 1; + padding: 0.75rem; + font-size: 1rem; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + + &:focus { + outline: none; + border-color: #448844; + box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1); + } + } + + input[type="submit"] { + padding: 0.75rem 1.5rem; + font-size: 1rem; + font-weight: 500; + border: none; + border-radius: 6px; + background: #448844; + color: white; + cursor: pointer; + transition: background 0.2s; + + &:hover { + background: #357535; + } + + &:active { + transform: scale(0.98); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.3); + } + } +} + +.search-results { + .search-results-header { + margin-bottom: 1.5rem; + + .back-button { + padding: 0.5rem 1rem; + margin: 0 0 1rem 0; + border: 1px solid #ddd; + border-radius: 6px; + background: white; + color: #448844; + cursor: pointer; + font-size: 0.95rem; + font-weight: 500; + transition: background 0.2s, border-color 0.2s; + + &:hover { + background: #f9f9f9; + border-color: #448844; + } + + &:focus { + outline: none; + border-color: #448844; + box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1); + } + } + } + + .search-results-list { + list-style: none; + padding: 0; + margin: 1rem 0; + + .item-card { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem; + background: #f9f9f9; + margin-bottom: 0.5rem; + border-radius: 8px; + color: #333; + transition: background 0.2s, box-shadow 0.2s; + position: relative; + + &:hover { + background: #f0f0f0; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + } + + &:hover .item-add-button { + opacity: 1; + } + } + + .item-thumbnail { + flex-shrink: 0; + width: 50px; + height: 50px; + border-radius: 6px; + overflow: hidden; + background: white; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); + + img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; + } + } + + .item-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + } + + .item-name { + font-weight: 500; + color: #2D3748; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .item-badges { + display: flex; + gap: 0.375rem; + flex-wrap: wrap; + } + + .item-add-button { + position: absolute; + top: 0.5rem; + right: 0.5rem; + padding: 0; + margin: 0; + border: none; + background: rgba(68, 136, 68, 0.9); + border-radius: 4px; + cursor: pointer; + font-size: 1rem; + line-height: 1; + width: 1.75rem; + height: 1.75rem; + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s, background 0.2s, transform 0.1s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + + &:hover { + background: rgba(68, 136, 68, 1); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:focus { + opacity: 1; + outline: 2px solid #448844; + outline-offset: 2px; + } + } + } + + .empty-state { + padding: 2rem; + text-align: center; + color: #666; + } + + .pagination { + margin: 1.5rem 0; + display: flex; + justify-content: center; + gap: 0.5rem; + font-size: 0.9rem; + + a, + span, + em { + padding: 0.5rem 0.75rem; + border: 1px solid #ddd; + border-radius: 4px; + text-decoration: none; + color: #448844; + background: white; + + &:hover { + background: #f9f9f9; + border-color: #448844; + } + } + + .current, + em { + background: #448844; + color: white; + border-color: #448844; + font-style: normal; + } + + .disabled { + color: #ccc; + cursor: not-allowed; + border-color: #eee; + + &:hover { + background: white; + border-color: #eee; + } + } + } +} + @keyframes fade-in { from { opacity: 0; diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb index d1397c49..e91fc156 100644 --- a/app/controllers/outfits_controller.rb +++ b/app/controllers/outfits_controller.rb @@ -111,6 +111,17 @@ class OutfitsController < ApplicationController # in parallel rather than sequentially when rendering SwfAsset.preload_manifests(@outfit.visible_layers) + # Handle search mode + @search_mode = params[:q].present? + if @search_mode + search_filters = build_search_filters(params[:q], @outfit) + query_params = ActionController::Parameters.new( + search_filters.each_with_index.map { |filter, i| [i.to_s, filter] }.to_h + ) + @query = Item::Search::Query.from_params(query_params, current_user) + @search_results = @query.results.paginate(page: params.dig(:q, :page), per_page: 30) + end + render layout: false end @@ -164,5 +175,52 @@ class OutfitsController < ApplicationController :full_error_messages => @outfit.errors.full_messages}, :status => :bad_request end + + def build_search_filters(query_params, outfit) + filters = [] + + # Add name filter if present + if query_params[:name].present? + filters << { key: "name", value: query_params[:name] } + end + + # Add item kind filter if present + if query_params[:item_kind].present? + case query_params[:item_kind] + when "nc" + filters << { key: "is_nc", value: "true" } + when "np" + filters << { key: "is_np", value: "true" } + when "pb" + filters << { key: "is_pb", value: "true" } + end + end + + # Add zone filter if present + if query_params[:zone].present? + filters << { key: "occupied_zone_set_name", value: query_params[:zone] } + end + + # Always add auto-filter for items that fit the current pet + pet_type = outfit.pet_type + if pet_type + fit_filter = { + key: "fits", + value: { + species_id: pet_type.species_id.to_s, + color_id: pet_type.color_id.to_s + } + } + + # Include alt_style_id if present + if outfit.alt_style_id.present? + fit_filter[:value][:alt_style_id] = outfit.alt_style_id.to_s + end + + filters << fit_filter + end + + filters + end end diff --git a/app/helpers/outfits_helper.rb b/app/helpers/outfits_helper.rb index 13f36159..460eb5bb 100644 --- a/app/helpers/outfits_helper.rb +++ b/app/helpers/outfits_helper.rb @@ -80,6 +80,12 @@ module OutfitsHelper end end + unless except.include?(:q) + (params[:q] || {}).each do |key, value| + fields << hidden_field_tag("q[#{key}]", value) if value.present? + end + end + safe_join fields end diff --git a/app/models/outfit.rb b/app/models/outfit.rb index 0e066537..8c4db97e 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -296,4 +296,9 @@ class Outfit < ApplicationRecord def without_item(item) dup.tap { |o| o.worn_items.delete(item) } end + + # Create a copy of this outfit, additionally wearing the given item. + def with_item(item) + dup.tap { |o| o.worn_items << item unless o.worn_items.include?(item) } + end end diff --git a/app/views/outfits/_search_results.html.haml b/app/views/outfits/_search_results.html.haml new file mode 100644 index 00000000..93d19e3a --- /dev/null +++ b/app/views/outfits/_search_results.html.haml @@ -0,0 +1,28 @@ +.search-results + .search-results-header + = button_to wardrobe_v2_path, method: :get, class: "back-button" do + ← Back to outfit + = outfit_state_params except: [:q] + + - if @search_results.any? + = will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] } + + %ul.search-results-list + - @search_results.each do |item| + %li.item-card + .item-thumbnail + = image_tag item.thumbnail_url, alt: item.name, loading: "lazy" + .item-info + .item-name= item.name + .item-badges + = render "items/badges/kind", item: item + = render "items/badges/first_seen", item: item + = button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do + ➕ + = outfit_state_params @outfit.with_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] } + + - else + .empty-state + %p No matching items found. Try a different search term, or browse items on the main site. diff --git a/app/views/outfits/new_v2.html.haml b/app/views/outfits/new_v2.html.haml index da37ffbf..93e97be2 100644 --- a/app/views/outfits/new_v2.html.haml +++ b/app/views/outfits/new_v2.html.haml @@ -44,7 +44,14 @@ .outfit-controls-section %h1 Customize your pet - - if @outfit.worn_items.any? + = form_with url: wardrobe_v2_path, method: :get, class: "item-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" + + - if @search_mode + = render "search_results" + - elsif @outfit.worn_items.any? .worn-items - outfit_items_by_zone(@outfit).each do |zone_group| .zone-group diff --git a/docs/wardrobe-v2-migration.md b/docs/wardrobe-v2-migration.md index a9731421..3492001c 100644 --- a/docs/wardrobe-v2-migration.md +++ b/docs/wardrobe-v2-migration.md @@ -49,17 +49,33 @@ Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a - Displays item thumbnails, names, and badges (NC/NP, first seen date) - Alphabetical sorting within zones -**Item Removal** ([new_v2.html.haml:62-64](app/views/outfits/new_v2.html.haml#L62-L64)) -- Remove button (❌) on each item +**Item Search** ([new_v2.html.haml:47-50](app/views/outfits/new_v2.html.haml#L47-L50)) +- Search form at top of controls section +- Auto-filters to items that fit current pet (species + color + alt_style) +- Uses `Item::Search::Query.from_params` for structured search +- Toggles between search results and worn items views (full page refresh) +- Pagination with 30 items per page using `q[page]` parameter +- All search state scoped under `q[...]` params (name, page, etc.) +- "Back to outfit" button to exit search + +**Item Addition** ([_search_results.html.haml](app/views/outfits/_search_results.html.haml)) +- Add button (➕) on each search result item +- Adds item to outfit via GET request with updated `objects[]` params +- Button hidden by default, appears on hover/focus +- Preserves search state when adding items +- Uses `outfit.with_item(item)` helper to generate new state + +**Item Removal** ([new_v2.html.haml:70-72](app/views/outfits/new_v2.html.haml#L70-L72)) +- Remove button (❌) on each worn item - Removes item from outfit via GET request with updated `objects[]` params - Button hidden by default, appears on hover/focus - Uses `outfit.without_item(item)` helper to generate new state -**State Management** ([outfits_helper.rb:68-84](app/helpers/outfits_helper.rb#L68-L84)) +**State Management** ([outfits_helper.rb:68-90](app/helpers/outfits_helper.rb#L68-L90)) - All state lives in URL params (no client-side state) -- `outfit_state_params` helper generates hidden fields -- Preserves: species, color, worn items (`objects[]`) -- Can exclude specific params (e.g., to override in species/color form) +- `outfit_state_params` helper generates hidden fields for outfit state +- Preserves: species, color, worn items (`objects[]`), search query (`q[...]`) +- Can exclude specific params via `except:` (e.g., to override species/color, or clear search) - Every action generates new URL via GET request #### Supporting Helpers @@ -86,6 +102,11 @@ Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a - Creates a duplicate outfit without the specified item - Used for remove button state generation +**`Outfit#with_item`** ([outfit.rb:300-303](app/models/outfit.rb#L300-L303)) +- Creates a duplicate outfit with the specified item added +- Used for add button state generation in search results +- Prevents duplicate items (checks if item already worn) + **`Outfit#visible_layers`** ([outfit.rb:172-192](app/models/outfit.rb#L172-L192)) - Returns array of `SwfAsset` layers to render - Combines pet biology layers + compatible item layers @@ -97,6 +118,12 @@ Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a - Returns hash of `{item_id => Appearance}` structs - Used by `outfit_items_by_zone` to determine compatibility +**`Item::Search::Query.from_params`** ([item/search/query.rb](app/models/item/search/query.rb)) +- Structured search query builder (vs. `from_text` which parses strings) +- Takes indexed hash of filters with `key`, `value`, `is_positive` +- Supported filters: `name`, `is_nc`, `is_np`, `is_pb`, `fits`, `occupied_zone_set_name`, etc. +- Used by Wardrobe V2 to auto-filter items by current pet compatibility + ### What's NOT Implemented Yet Below is a comprehensive comparison with the full feature set of Wardrobe 2020 (React version). @@ -104,18 +131,19 @@ Below is a comprehensive comparison with the full feature set of Wardrobe 2020 ( #### Critical Missing Features **Item Search & Addition** -- ❌ No search UI at all -- ❌ No item browser/results display -- ❌ No way to add items to outfit -- ❌ Missing from Wardrobe 2020: - - Free text search with autosuggest - - Advanced filters (NC/NP/PB, zone, ownership) - - Filter chips display - - Paginated results (30 per page) +- ✅ Basic search UI implemented (text search only) +- ✅ Item results display with pagination (30 per page) +- ✅ Add items to outfit from search results +- ✅ Auto-filtering to items that fit current pet +- ✅ Empty state messages +- ❌ Still missing from Wardrobe 2020: + - Inline search syntax (`is:nc`, `fits:blue-acara`, etc.) - currently only supports plain text + - Advanced filter UI (NC/NP/PB toggles, zone selector, ownership filters) + - Filter chips display showing active filters + - Autosuggest/autocomplete - Keyboard navigation in search results (Up/Down arrows, Escape, Enter) - - Preloading adjacent pages + - Preloading adjacent pages for faster pagination - NC Styles intelligent hints - - Empty state messages - Item restoration logic (restoring previous items when trying on conflicts) **Outfit Saving/Loading** @@ -391,21 +419,32 @@ Browser displays (instant if Turbo, full page otherwise) ## Next Steps +**Recently Completed:** +- ✅ Basic item search functionality (November 2025) + - Text search with auto-filtering by pet compatibility + - Pagination with 30 items per page + - Add/remove items with state preservation + - Clean URL-based state management with all search state scoped under `q[...]` + ### Phase 1: Core Functionality (MVP) **Goal:** Basic usable wardrobe that can compete with essential features. -1. **Item Search & Addition** 🔴 Critical - - [ ] Search input field in sidebar - - [ ] Basic text search (query Items API) - - [ ] Paginated results display (30 per page) - - [ ] Checkbox to wear/unwear items - - [ ] Add items to outfit via URL params - - [ ] Empty state message +1. **Item Search & Addition** 🟢 Complete (basic), 🟡 Enhancements pending + - [x] Search input field in sidebar + - [x] Basic text search (query Items API) + - [x] Paginated results display (30 per page) + - [x] Button to wear items (Add button on search results) + - [x] Add items to outfit via URL params + - [x] Empty state message + - [x] Auto-filter items to current pet compatibility + - [x] Back to outfit button to exit search + - [x] Search state scoped under `q[...]` params - Optional enhancements: + - [ ] Inline search syntax (`is:nc`, `fits:blue-acara`) - [ ] Autosuggest/autocomplete - [ ] Filter chips display - - [ ] Advanced filters (NC/NP/PB, zone) + - [ ] Advanced filter UI (NC/NP/PB toggles, zone selector) - [ ] Keyboard navigation (arrows, escape, enter) 2. **Outfit Saving/Loading** 🔴 Critical