[WV2] Item search first draft
This commit is contained in:
parent
80db7ad3bf
commit
c4290980ed
7 changed files with 399 additions and 26 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
28
app/views/outfits/_search_results.html.haml
Normal file
28
app/views/outfits/_search_results.html.haml
Normal file
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue