Compare commits

...

32 commits

Author SHA1 Message Date
f4b1309149 [WV2] Stabilize IDs for pose picker outfit viewers
This should help with the morphing for the main preview
2026-02-06 19:27:22 -08:00
f13481783d Merge branch 'main' into feature/wardrobe-v2 2026-02-06 19:24:30 -08:00
0691153101 Stabilize IDs for outfit viewer component 2026-02-06 19:22:02 -08:00
f9b040c20b Upgrade Idiomorph to 0.7.4
This seems to fix some morphing issues, whew!
2026-02-06 19:19:14 -08:00
0a9c346fa6 [WV2] Minor update to WV2 migration doc 2026-02-06 18:11:11 -08:00
6f7b307e39 [WV2] Fix bug with play/pause visibility 2026-02-06 17:50:32 -08:00
b462272dc3 [WV2] Add search keyboard shortcuts 2026-02-06 17:20:10 -08:00
10e2140045 [WV2] Progressive enhancement for item search 2026-02-06 17:05:01 -08:00
36a28cff10 [WV2] Add progressive enhancement for outfit item list toggles
Rather than just buttons, upgrade to radio buttons when we have JS.
2026-02-06 16:42:42 -08:00
81b60eefad [WV2] Unify auto-submit behaviors into a shared web component 2026-02-06 11:29:04 -08:00
9baa64d39a [WV2] Unify common CSS patterns 2026-02-06 09:17:37 -08:00
3582b3674b [WV2] Remove unnecessary worn/closeted state tracking from helper fn 2026-02-06 09:10:37 -08:00
d0acb1c7e5 [WV2] Use variables for colors 2026-02-06 09:09:58 -08:00
0a82ed7b68 [WV2] Reorganize partials into subdirectories 2026-02-06 08:11:19 -08:00
fd881ee31d [WV2] Support closeted items as well as worn items 2026-02-06 07:54:09 -08:00
f5ad5d2b17 [WV2] Simplify canceling outfit renaming
Don't need a button anymore, with focusout and escape doing it
2026-02-05 22:01:45 -08:00
d7c561f91d [WV2] Use outfit name for page title 2026-02-05 21:58:27 -08:00
6fa4e57184 [WV2] Outfit renaming as an atomic operation 2026-02-05 21:56:23 -08:00
0d4b553162 [WV2] Outfit saving first draft 2026-02-05 20:47:05 -08:00
5e68d3809c [WV2] Fix syncing for play/pause state across page morphs
Reproduce:
1. Add an item with animations, and play them.
2. Remove the item.
3. Add it back.
4. Observe the button shows up in "Paused" state, even though it's playing.

This is because the server-side template wasn't doing anything to try to keep the play/pause button it renders in sync with the current saved state in the cookies, so it was always causing a morph to the pause state. Now we listen to the cookie instead!

I also updated the JS behavior to be a bit more consistent: treat the behavior as defaulting to true, unless it's explicitly set to the string "false".
2026-02-05 19:15:09 -08:00
ff3dd2249e [WV2] Fix Safari-specific bug for play/pause button state 2026-02-05 19:03:17 -08:00
97a035b3a3 [WV2] Fix bug where play/pause button shows even after anims removed 2026-02-05 18:59:08 -08:00
d7b1f0e067 [WV2] Minor tweaks to look more like the real wardrobe 2026-02-05 18:49:07 -08:00
4503c12a1f [WV2] Make show.css a bit more manageable 2026-02-05 18:43:02 -08:00
e694bc5d05 [WV2] Minor UI improvements to pose picker 2026-02-05 18:35:10 -08:00
fc93239482 [WV2] Scroll selected alt style into view 2026-02-05 18:17:34 -08:00
b7bbd1ace3 [WV2] Simplify pose vs style picking
Rather than surface the fact that pose and style are independent values, in this change we treat them as basically mutually exclusive appearance options.

If there's no alt style selected, a pose option is visibly selected instead. If there's an alt style selected, no pose option is visibly selected (even though the data model contains one), and selecting one removes the alt style.
2026-02-05 18:11:01 -08:00
3b471fcb05 [WV2] Add alt style picker 2026-02-05 18:04:49 -08:00
fd2940880f [WV2] Update migration status doc 2026-02-05 17:33:30 -08:00
df043b939e Support GET requests for /pets/load 2026-02-05 17:17:46 -08:00
304a7ac9e1 Add 2min items:auto_model cron job 2026-02-05 17:07:52 -08:00
366158b698 Add time frames to the Top Contributors list
Note that these queries are a bit slow. I don't think these new subpages will be accessed anywhere near often enough for their ~2sec query time to be a big deal. But if we start getting into trouble with it (e.g. someone starts slamming us for fun), we can look into how how cache these values over time.
2026-01-20 19:54:22 -08:00
59 changed files with 3579 additions and 2485 deletions

View file

@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1"
# For automated tests.
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
gem 'rails-controller-testing', group: [:test]
gem "webmock", "~> 3.24", group: [:test]

View file

@ -341,6 +341,10 @@ GEM
activesupport (= 8.1.2)
bundler (>= 1.15.0)
railties (= 8.1.2)
rails-controller-testing (1.0.5)
actionpack (>= 5.0.1.rc1)
actionview (>= 5.0.1.rc1)
activesupport (>= 5.0.1.rc1)
rails-dom-testing (2.3.0)
activesupport (>= 5.0.0)
minitest
@ -505,6 +509,7 @@ DEPENDENCIES
rack-attack (~> 6.7)
rack-mini-profiler (~> 4.0, >= 4.0.1)
rails (~> 8.0, >= 8.0.1)
rails-controller-testing
rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 8.0, >= 8.0.2)

View file

@ -0,0 +1,27 @@
/**
* AutoSubmitForm web component
*
* Generic progressive enhancement for forms that should auto-submit on change:
* - Listens for `change` events on descendant form inputs
* - Calls `requestSubmit()` on the nearest `<form>`
* - Exposes `:state(auto-loading)` to hide fallback submit buttons via CSS
*/
class AutoSubmitForm extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
e.target.closest("form")?.requestSubmit();
}
}
customElements.define("auto-submit-form", AutoSubmitForm);

View file

@ -4,7 +4,7 @@ document.addEventListener("change", (e) => {
try {
const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form",
"#item-preview .species-color-picker form",
);
const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']",

View file

@ -0,0 +1,47 @@
/**
* 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/blur 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 input = this.querySelector("input[type=text]");
if (!pencil || !input) return;
pencil.addEventListener("click", () => {
this.dataset.originalValue = input.value;
this.setAttribute("editing", "");
input.focus();
input.select();
});
this.addEventListener("keydown", (e) => {
if (e.key === "Escape" && this.hasAttribute("editing")) {
e.preventDefault();
this.#cancelEditing(input);
}
});
this.addEventListener("focusout", (e) => {
if (this.hasAttribute("editing") && !this.contains(e.relatedTarget)) {
this.#cancelEditing(input);
}
});
}
#cancelEditing(input) {
input.value = this.dataset.originalValue ?? input.value;
this.removeAttribute("editing");
}
}
customElements.define("outfit-rename-field", OutfitRenameField);

View file

@ -1,6 +1,7 @@
class OutfitViewer extends HTMLElement {
#internals;
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
constructor() {
super();
@ -8,7 +9,31 @@ class OutfitViewer extends HTMLElement {
}
connectedCallback() {
// Set up listener for bubbled hasanimationschange events from layers
const observer = new MutationObserver((mutations) => {
// When a layer is added, update its playing state to match ours.
const addedLayers = mutations
.flatMap(m => [...m.addedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
for (const layer of addedLayers) {
if (this.#internals.states.has("playing")) {
layer.play();
} else {
layer.pause();
}
}
const removedLayers = mutations
.flatMap(m => [...m.removedNodes])
.filter(n => n.tagName === "OUTFIT-LAYER");
// If any layers were added or removed, updated our hasAnimations state.
if (addedLayers.length > 0 || removedLayers.length > 0) {
this.#updateHasAnimations();
}
});
observer.observe(this, { childList: true });
// When a new layer finishes loading and determines it has animations, update.
this.addEventListener("hasanimationschange", (e) => {
// Only handle events from outfit-layer children, not from ourselves
if (e.target === this) return;
@ -16,23 +41,6 @@ class OutfitViewer extends HTMLElement {
this.#updateHasAnimations();
});
// Watch for new layers being added and apply the current playing state
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.tagName === "OUTFIT-LAYER") {
// Apply current playing state to the new layer
if (this.#internals.states.has("playing")) {
node.play();
} else {
node.pause();
}
}
}
}
});
observer.observe(this, { childList: true });
// The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0);
@ -57,12 +65,12 @@ class OutfitViewer extends HTMLElement {
this.querySelector("outfit-layer:state(has-animations)") !== null;
// Check if state actually changed
const hadAnimations = this.#internals.states.has("has-animations");
if (hasAnimations === hadAnimations) {
if (hasAnimations === this.#hasAnimations) {
return; // No change, skip
}
// Update internal state
this.#hasAnimations = hasAnimations;
if (hasAnimations) {
this.#internals.states.add("has-animations");
} else {
@ -137,7 +145,7 @@ class OutfitViewer extends HTMLElement {
.split("; ")
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
if (cookie) {
return cookie.split("=")[1] === "true";
return cookie.split("=")[1] !== "false";
}
return true; // Default to playing
}
@ -388,6 +396,13 @@ class OutfitViewerPlayPauseToggle extends HTMLElement {
"outfit-layer:state(has-animations)",
) !== null,
);
// After a Turbo morph, Idiomorph may remove our `hidden` attribute
// (since the server HTML never includes it). Re-apply visibility
// based on the current animation state.
document.addEventListener("turbo:render", () => {
this.#syncFromOutfitViewer();
});
}
#syncFromOutfitViewer() {

View file

@ -1,30 +1,31 @@
/**
* PosePicker web component
* PosePickerPopover web component
*
* Progressive enhancement for pose picker forms:
* - Auto-submits the form when a pose is selected (if JS is enabled)
* - Shows a submit button as fallback (if JS is disabled or slow to load)
* - Uses Custom Element internals API to communicate state to CSS
* Scrolls the selected style into view when the style picker list becomes
* visible (e.g. tab switch or popover open).
*/
class PosePickerPopover extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
#styleListObserver;
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
// When the style picker list becomes visible (e.g. tab switch or
// popover open), scroll the selected style into view.
const styleList = this.querySelector(".style-picker-list");
if (styleList) {
this.#styleListObserver = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
const checked = styleList.querySelector("input:checked");
checked
?.closest("label")
?.scrollIntoView({ block: "nearest" });
}
});
this.#styleListObserver.observe(styleList);
}
}
#handleChange(e) {
// Only auto-submit if a radio button was changed
if (e.target.type === "radio") {
this.querySelector("form").requestSubmit();
}
disconnectedCallback() {
this.#styleListObserver?.disconnect();
}
}

View file

@ -1,28 +0,0 @@
/**
* SpeciesColorPicker web component
*
* Progressive enhancement for species/color picker forms:
* - Auto-submits the form when species or color changes (if JS is enabled)
* - Shows a submit button as fallback (if JS is disabled or slow to load)
* - Uses Custom Element internals API to communicate state to CSS
*/
class SpeciesColorPicker extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it!
this.addEventListener("change", this.#handleChange);
this.#internals.states.add("auto-loading");
}
#handleChange(e) {
this.querySelector("form").requestSubmit();
}
}
customElements.define("species-color-picker", SpeciesColorPicker);

View file

@ -0,0 +1,37 @@
/**
* TabPanel web component
*
* A simple tab switcher. Reads the `active` attribute to determine which tab
* is visible. Without JS, both panels are visible (tab buttons hidden via CSS).
*/
class TabPanel extends HTMLElement {
connectedCallback() {
this.querySelectorAll(".tab-button").forEach((button) => {
button.addEventListener("click", () => {
this.setAttribute("active", button.dataset.tab);
});
});
}
static get observedAttributes() {
return ["active"];
}
attributeChangedCallback(name) {
if (name === "active") this.#updateVisibility();
}
#updateVisibility() {
const active = this.getAttribute("active");
this.querySelectorAll(".tab-button").forEach((button) => {
button.classList.toggle("active", button.dataset.tab === active);
});
this.querySelectorAll(".tab-content").forEach((content) => {
content.hidden = content.dataset.tab !== active;
});
}
}
customElements.define("tab-panel", TabPanel);

View file

@ -0,0 +1,70 @@
/**
* ItemCard web component
*
* Progressive enhancement for item cards in both outfit and search views.
* Replaces baseline Show/Hide/Add buttons with click-to-toggle behavior:
*
* Outfit view (radio inputs):
* - Clicking a closeted item's label selects its radio and submits the Show form
* - Clicking a worn item's label submits the Hide form (un-wears it)
* - Arrow keys navigate between items via native radio behavior
* - Space on a checked radio submits the Hide form (radios don't toggle natively)
*
* Search view (checkbox inputs):
* - Clicking an unworn item's label checks the checkbox and submits the Add form
* - Clicking a worn item's label prevents default and submits the Hide form
*/
class ItemCard extends HTMLElement {
connectedCallback() {
this.addEventListener("click", this.#handleClick);
this.addEventListener("keydown", this.#handleKeydown);
this.addEventListener("change", this.#handleChange);
}
#handleClick = (e) => {
const label = e.target.closest(".item-card-label");
if (!label) return;
const input = label.querySelector("input[type=radio], input[type=checkbox]");
if (!input) return;
// If this item is worn, un-wear it by submitting the Hide form
if (this.dataset.isWorn != null) {
e.preventDefault();
const hideButton = this.querySelector(".item-hide-button");
if (hideButton) hideButton.closest("form").requestSubmit();
}
// Otherwise, let the default label click proceed—it checks the input,
// which fires the `change` event handled below
};
#handleKeydown = (e) => {
// Spacebar on an already-checked radio: un-wear the item
if (e.key !== " ") return;
const input = e.target;
if (input.type !== "radio" || !input.checked) return;
if (this.dataset.isWorn == null) return;
e.preventDefault();
const hideButton = this.querySelector(".item-hide-button");
if (hideButton) hideButton.closest("form").requestSubmit();
};
#handleChange = (e) => {
const input = e.target;
if (!input.checked) return;
if (input.type !== "radio" && input.type !== "checkbox") return;
// Submit the Show form to wear this item, or the Add form if not closeted
const showButton = this.querySelector(".item-show-button");
if (showButton) {
showButton.closest("form").requestSubmit();
} else {
const addButton = this.querySelector(".item-add-button");
if (addButton) addButton.closest("form").requestSubmit();
}
};
}
customElements.define("item-card", ItemCard);

View file

@ -0,0 +1,61 @@
/**
* Keyboard shortcuts for item search.
*
* - Up/Down arrows move focus between the search field and the item list,
* so keyboard users can quickly browse results without tabbing.
* - Escape exits search mode (clicks the back button).
*/
document.addEventListener("keydown", (e) => {
if (e.key === "Escape") {
const backButton = document.querySelector(
".item-search-form .back-button",
);
if (!backButton) return;
// Only act when focus is on the search input or a search result.
const section = document.querySelector(".outfit-controls-section");
const searchInput = section?.querySelector(
'.search-form input[type="text"]',
);
const isSearchFocused =
document.activeElement === searchInput ||
document.activeElement?.closest(".search-results-list") != null;
if (!isSearchFocused) return;
e.preventDefault();
backButton.click();
return;
}
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
const section = document.querySelector(".outfit-controls-section");
if (!section) return;
const searchInput = section.querySelector('.search-form input[type="text"]');
if (!searchInput) return;
// Collect all focusable item inputs in the results list.
const itemInputs = [
...section.querySelectorAll(
'.search-results-list item-card input[type="checkbox"]',
),
];
if (itemInputs.length === 0) return;
const allTargets = [searchInput, ...itemInputs];
const currentIndex = allTargets.indexOf(document.activeElement);
if (currentIndex === -1) return;
let nextIndex;
if (e.key === "ArrowDown") {
nextIndex = Math.min(currentIndex + 1, allTargets.length - 1);
} else {
nextIndex = Math.max(currentIndex - 1, 0);
}
if (nextIndex !== currentIndex) {
e.preventDefault();
allTargets[nextIndex].focus();
}
});

View file

@ -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();

View file

@ -109,7 +109,7 @@ outfit-viewer
.error-indicator
display: block
species-color-picker
.species-color-picker
.error-icon
cursor: help
margin-right: .25em
@ -130,7 +130,7 @@ species-color-picker
animation-delay: .75s
// Once the auto-loading behavior is ready, remove the submit button.
&:state(auto-loading)
auto-submit-form:state(auto-loading)
input[type=submit]
display: none
@ -296,7 +296,7 @@ species-face-picker
width: 380px
height: 380px
species-color-picker
.species-color-picker
grid-area: picker
species-face-picker

View file

@ -3,6 +3,14 @@
body.users-top_contributors
text-align: center
.timeframe-nav
margin: 1em 0
display: flex
justify-content: center
gap: 1em
list-style: none
padding: 0
#top-contributors
border:
spacing: 0

File diff suppressed because it is too large Load diff

View file

@ -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,25 @@ class OutfitsController < ApplicationController
def update
if @outfit.update(outfit_params)
render :json => @outfit
respond_to do |format|
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
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

View file

@ -34,9 +34,10 @@ class PetsController < ApplicationController
end
def destination
case (params[:destination] || params[:origin])
when 'wardrobe' then wardrobe_path
else root_path
if request.get?
wardrobe_path
else
root_path
end
end

View file

@ -14,7 +14,10 @@ class UsersController < ApplicationController
end
def top_contributors
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
@users = User.top_contributors_for(@timeframe.to_sym)
.paginate(page: params[:page], per_page: 20)
end
def edit

View file

@ -1,5 +1,21 @@
class WardrobeController < ApplicationController
prepend_view_path Rails.root.join("app/views/wardrobe")
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")
@ -41,20 +57,45 @@ class WardrobeController < ApplicationController
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
end
# Load items from the objects[] parameter
item_ids = params[:objects] || []
items = Item.where(id: item_ids)
# Load alt style from params, scoped to the current species
@alt_style = if params[:style].present? && @selected_species
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
end
# Load all available alt styles for this species (for the style picker)
@available_alt_styles = @selected_species ?
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
# Load items from the objects[] and closet[] parameters
worn_item_ids = params[:objects] || []
closeted_item_ids = params[:closet] || []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Build the outfit
@outfit = Outfit.new(
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
pet_state: @pet_state,
worn_items: items,
alt_style: @alt_style,
worn_items: worn_items,
closeted_items: closeted_items,
)
# Preload the manifests for all visible layers, so they load efficiently
# in parallel rather than sequentially when rendering
SwfAsset.preload_manifests(@outfit.visible_layers)
# 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
@ -95,6 +136,10 @@ class WardrobeController < ApplicationController
poses_hash
end
def outfit_state_params_present?
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
end
def build_search_filters(query_params, outfit)
filters = []

View file

@ -1,8 +1,4 @@
module OutfitsHelper
def destination_tag(value)
hidden_field_tag 'destination', value, :id => nil
end
def latest_contribution_description(contribution)
user = contribution.user
contributed = contribution.contributed

View file

@ -5,9 +5,11 @@ module WardrobeHelper
def outfit_state_params(outfit = @outfit, except: [])
fields = []
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)
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
unless except.include?(:worn_items)
outfit.worn_items.each do |item|
@ -15,6 +17,12 @@ module WardrobeHelper
end
end
unless except.include?(:closeted_items)
outfit.closeted_items.each do |item|
fields << hidden_field_tag('closet[]', item.id)
end
end
unless except.include?(:q)
(params[:q] || {}).each do |key, value|
fields << hidden_field_tag("q[#{key}]", value) if value.present?
@ -24,41 +32,48 @@ module WardrobeHelper
safe_join fields
end
# Get the emoji and label for a pose, for display in the pose picker button
def pose_emoji_and_label(pose)
case pose
when "HAPPY_MASC", "HAPPY_FEM"
{ emoji: "😀", label: "Happy" }
when "SAD_MASC", "SAD_FEM"
{ emoji: "😢", label: "Sad" }
when "SICK_MASC", "SICK_FEM"
{ emoji: "🤢", label: "Sick" }
# Get the emoji and label for the pose picker button.
# Shows the alt style name when one is active, otherwise the pose name.
def pose_emoji_and_label(pose, alt_style: nil)
if alt_style
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
else
{ emoji: "😀", label: "Default" }
case pose
when "HAPPY_MASC", "HAPPY_FEM"
{ emoji: "😀", label: "Happy" }
when "SAD_MASC", "SAD_FEM"
{ emoji: "😢", label: "Sad" }
when "SICK_MASC", "SICK_FEM"
{ emoji: "🤢", label: "Sick" }
else
{ emoji: "😀", label: "Default" }
end
end
end
# Group outfit items by zone, applying smart multi-zone simplification.
# Returns an array of hashes: {zone:, items:}
# Returns an array of hashes: {zone_id:, zone_label:, items: [Item, ...]}
# This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil?
# Get item appearances for this outfit
all_items = outfit.worn_items + outfit.closeted_items
# Get item appearances for all items at once
item_appearances = Item.appearances_for(
outfit.worn_items,
all_items,
outfit.pet_type,
swf_asset_includes: [:zone]
)
# Separate incompatible items (no layers for this pet)
compatible_items = []
# Separate compatible and incompatible items
compatible = {}
incompatible_items = []
outfit.worn_items.each do |item|
all_items.each do |item|
appearance = item_appearances[item.id]
if appearance&.present?
compatible_items << {item: item, appearance: appearance}
compatible[item] = appearance
else
incompatible_items << item
end
@ -68,11 +83,7 @@ module WardrobeHelper
items_by_zone = Hash.new { |h, k| h[k] = [] }
zones_by_id = {}
compatible_items.each do |item_with_appearance|
item = item_with_appearance[:item]
appearance = item_with_appearance[:appearance]
# Get unique zones for this item (an item may have multiple assets per zone)
compatible.each do |item, appearance|
appearance.swf_assets.map(&:zone).uniq.each do |zone|
zones_by_id[zone.id] = zone
items_by_zone[zone.id] << item
@ -138,8 +149,7 @@ module WardrobeHelper
# For single-item groups, only keep if:
# - Item hasn't been seen yet AND
# - Item won't appear in a conflict group
item = group[:items].first
item_id = item.id
item_id = group[:items].first.id
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
false

View file

@ -261,17 +261,24 @@ class Outfit < ApplicationRecord
(biology_layers + item_layers).sort_by(&:depth)
end
def same_wardrobe_state_as?(other)
# Exclude :name because it's managed separately via atomic rename, not URL
# state. This also works around the @outfit (new) vs @saved_outfit
# (persisted) split in WardrobeController, where only the unpersisted
# outfit includes :name. We should consider keeping their names in sync.
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
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[:name] = name if !persisted? && name.present?
params
end
@ -311,9 +318,23 @@ class Outfit < ApplicationRecord
end
end
# Create a copy of this outfit, but *not* wearing the given item.
# Create a copy of this outfit without the given item at all
# (removed from both worn and closeted).
def without_item(item)
dup.tap { |o| o.worn_items.delete(item) }
dup.tap do |o|
o.worn_items.delete(item)
o.closeted_items.delete(item)
end
end
# Create a copy of this outfit with the given item moved from worn to
# closeted. If it's not currently worn, returns the outfit unchanged.
def hide_item(item)
dup.tap do |o|
next unless o.worn_item_ids.include?(item.id)
o.worn_items.delete(item)
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
end
end
# Create a copy of this outfit, additionally wearing the given item.
@ -323,6 +344,9 @@ class Outfit < ApplicationRecord
# Skip if item is nil, already worn, or outfit has no pet_state
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
# If the item was closeted, remove it from closet (it's moving to worn)
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
# Load appearances for the new item and all currently worn items
all_items = o.worn_items + [item]
appearances = Item.appearances_for(all_items, o.pet_type,

View file

@ -25,6 +25,51 @@ class User < ApplicationRecord
scope :top_contributors, -> { order('points DESC').where('points > 0') }
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
scope :top_contributors_for, ->(timeframe = :all_time) {
case timeframe.to_sym
when :all_time
top_contributors # Use existing efficient scope
else
top_contributors_by_period(timeframe)
end
}
def self.top_contributors_by_period(timeframe)
start_date = case timeframe.to_sym
when :this_week then 1.week.ago
when :this_month then 1.month.ago
when :this_year then 1.year.ago
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
end
# Build the CASE statement dynamically from Contribution::POINT_VALUES
point_case = Contribution::POINT_VALUES.map { |type, points|
"WHEN #{connection.quote(type)} THEN #{points}"
}.join("\n ")
select(
'users.*',
"COALESCE(SUM(
CASE contributions.contributed_type
#{point_case}
END
), 0) AS period_points"
)
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
.where('contributions.created_at >= ?', start_date)
.group('users.id')
.having('period_points > 0')
.order('period_points DESC, users.id ASC')
end
# Virtual attribute reader for dynamically calculated points (from time-period queries).
# Falls back to the denormalized `points` column when not calculated.
def period_points
attributes['period_points'] || points
end
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
after_update :log_trade_activity, if: -> user {
(user.saved_change_to_owned_closet_hangers_visibility? &&

View file

@ -1,10 +1,11 @@
- html_options = {} unless defined? html_options
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
- viewer_id = html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
= content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner"
- outfit.visible_layers.each do |swf_asset|
%outfit-layer{
id: "#{viewer_id}-layer-#{swf_asset.id}",
data: {
"asset-id": swf_asset.id,
"zone": swf_asset.zone.label,

View file

@ -33,20 +33,21 @@
Customize more
= edit_icon
%species-color-picker
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
.species-color-picker
%auto-submit-form
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
- elsif @preview_error == :no_item_data
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
= select_tag "preview[color_id]",
options_from_collection_for_select(Color.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil
= select_tag "preview[color_id]",
options_from_collection_for_select(Color.alphabetical,
"id", "human_name", @selected_preview_pet_type.color_id)
= select_tag "preview[species_id]",
options_from_collection_for_select(Species.alphabetical,
"id", "human_name", @selected_preview_pet_type.species_id)
= submit_tag "Go", name: nil
%species-face-picker
%noscript
@ -138,5 +139,5 @@
- content_for :javascripts do
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "auto-submit-form", async: true
= javascript_include_tag "items/show", async: true

View file

@ -25,8 +25,7 @@
%h1= t 'app_name'
%h2= t '.tagline'
= form_tag load_pet_path, method: 'POST', class: 'primary load-pet-to-wardrobe' do
= hidden_field_tag 'destination', 'wardrobe'
= form_tag load_pet_path, method: 'GET', class: 'primary load-pet-to-wardrobe' do
%fieldset
%legend= t '.load_pet'
= pet_name_tag class: 'main-pet-name'

View file

@ -1,4 +1,13 @@
- title t('.title')
%ul.timeframe-nav
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
%li
- if @timeframe == tf
%strong= t(".timeframes.#{tf}")
- else
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
= will_paginate @users
%table#top-contributors
%thead
@ -11,5 +20,5 @@
%tr
%th{:scope => 'row'}= @users.offset + rank + 1
%td= link_to user.name, user_contributions_path(user)
%td= user.points
%td= user.period_points
= will_paginate @users

View file

@ -1,17 +0,0 @@
- is_worn = @outfit.worn_items.include?(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
- if is_worn
= button_to wardrobe_v2_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
= outfit_state_params @outfit.with_item(item)

View file

@ -1,39 +0,0 @@
- pose_info = pose_emoji_and_label(@selected_pose)
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
%span.pose-emoji= pose_info[:emoji]
%span.pose-label= pose_info[:label]
%span.chevron ▾
%pose-picker-popover#pose-picker-popover{popover: "auto"}
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose]
%table.pose-picker-table
%thead
%tr
%th
%th
%span.emoji-icon{title: "Happy"} 😀
%th
%span.emoji-icon{title: "Sad"} 😢
%th
%span.emoji-icon{title: "Sick"} 🤢
%tbody
%tr
%th
%span.emoji-icon{title: "Masculine"} 💁‍♂️
%td
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
%td
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
%td
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
%tr
%th
%span.emoji-icon{title: "Feminine"} 💁‍♀️
%td
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
%td
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
%td
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
= submit_tag "Change pose", name: nil, class: "pose-submit-button"

View file

@ -1,12 +0,0 @@
%species-color-picker
= form_with url: wardrobe_v2_path, method: :get do |f|
= outfit_state_params except: [:color, :species]
= select_tag :color,
options_from_collection_for_select(@colors, "id", "human_name",
@selected_color&.id),
"aria-label": "Pet color"
= select_tag :species,
options_from_collection_for_select(@species, "id", "human_name",
@selected_species&.id),
"aria-label": "Pet species"
= submit_tag "Go", name: nil

View file

@ -15,7 +15,8 @@
- if is_available
-# Create a minimal outfit with just this pet state for the thumbnail
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer"
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer",
id: "pose-thumbnail-viewer-#{pet_state.id}"
- else
.pose-unavailable
%span.question-mark{title: "Not available"} ❓

View file

@ -0,0 +1,22 @@
- pose_info = pose_emoji_and_label(@selected_pose, alt_style: @alt_style)
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
%span.pose-emoji= pose_info[:emoji]
%span.pose-label= pose_info[:label]
%span.chevron ▾
%pose-picker-popover#pose-picker-popover{popover: "auto"}
- active_tab = @alt_style ? "styles" : "expressions"
%tab-panel{active: active_tab}
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
%auto-submit-form
= render "appearance/pose_picker_form"
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
%auto-submit-form
= render "appearance/style_picker_form"
.tab-list
%button.tab-button{"data-tab": "expressions", type: "button",
class: ("active" if active_tab == "expressions")}
Expressions
%button.tab-button{"data-tab": "styles", type: "button",
class: ("active" if active_tab == "styles")}
Styles

View file

@ -0,0 +1,32 @@
= form_with url: @wardrobe_path, method: :get, class: "pose-picker-form" do |f|
= outfit_state_params except: [:pose, :style]
%table.pose-picker-table
%thead
%tr
%th
%th
%span.emoji-icon{title: "Happy"} 😀
%th
%span.emoji-icon{title: "Sad"} 😢
%th
%span.emoji-icon{title: "Sick"} 🤢
%tbody
%tr
%th
%span.emoji-icon{title: "Masculine"} 💁‍♂️
%td
= render "appearance/pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: !@alt_style && @selected_pose == "HAPPY_MASC"
%td
= render "appearance/pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: !@alt_style && @selected_pose == "SAD_MASC"
%td
= render "appearance/pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: !@alt_style && @selected_pose == "SICK_MASC"
%tr
%th
%span.emoji-icon{title: "Feminine"} 💁‍♀️
%td
= render "appearance/pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: !@alt_style && @selected_pose == "HAPPY_FEM"
%td
= render "appearance/pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: !@alt_style && @selected_pose == "SAD_FEM"
%td
= render "appearance/pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: !@alt_style && @selected_pose == "SICK_FEM"
= submit_tag "Change pose", name: nil, class: "pose-submit-button progressive-submit"

View file

@ -0,0 +1,13 @@
.species-color-picker
%auto-submit-form
= 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",
@selected_color&.id),
"aria-label": "Pet color"
= select_tag :species,
options_from_collection_for_select(@species, "id", "human_name",
@selected_species&.id),
"aria-label": "Pet species"
= submit_tag "Go", name: nil, class: "progressive-submit"

View file

@ -0,0 +1,11 @@
= 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|
%label.style-option
= radio_button_tag :style, alt_style.id, @alt_style&.id == alt_style.id
.style-option-content
.style-option-thumbnail
%img{src: alt_style.thumbnail_url, alt: "", width: 40, height: 40, loading: "lazy"}
%span.style-option-name= alt_style.adjective_name
= submit_tag "Change style", name: nil, class: "style-submit-button progressive-submit"

View file

@ -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"

View file

@ -0,0 +1,20 @@
- 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 "header/save_outfit_fields"
= 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 "header/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 "header/save_outfit_fields"
= f.submit "Save", class: "outfit-save-button"

View file

@ -0,0 +1,10 @@
= 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
- @outfit.closeted_items.each do |item|
= hidden_field_tag "outfit[item_ids][closeted][]", item.id

View file

@ -0,0 +1,29 @@
- is_worn = @outfit.worn_items.include?(item)
- is_closeted = @outfit.closeted_items.include?(item)
%item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}}
- if defined?(zone_id) && zone_id
%label.item-card-label
%input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name}
= render "wardrobe/items/item_card_content", item: item
- else
%label.item-card-label
%input.visually-hidden{type: "checkbox", checked: is_worn || nil, "aria-label": item.name}
= render "wardrobe/items/item_card_content", item: item
- if is_worn
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
👁️‍🗨️
= outfit_state_params @outfit.hide_item(item)
= 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)
- elsif is_closeted
= button_to @wardrobe_path, method: :get, class: "item-show-button", title: "Show #{item.name}", "aria-label": "Show #{item.name}" do
👁️
= outfit_state_params @outfit.with_item(item)
= 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_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
= outfit_state_params @outfit.with_item(item)

View file

@ -0,0 +1,7 @@
.item-thumbnail
= image_tag item.thumbnail_url, alt: "", loading: "lazy"
.item-info
.item-name= item.name
.item-badges
= render "items/badges/kind", item: item
= render "items/badges/first_seen", item: item

View file

@ -1,12 +1,12 @@
.search-results
- if @search_results.any?
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
%ul.search-results-list
- @search_results.each do |item|
= render "item_card", item: item
= render "items/item_card", 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] }
= will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
- else
.empty-state

View file

@ -1,11 +1,11 @@
- title "Wardrobe v2"
- title @outfit.name
!!! 5
%html
%head
%meta{charset: 'utf-8'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
%title= yield :title
%title #{yield :title} | #{t "app_name"}
%link{href: image_path('favicon.png'), rel: 'icon'}
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
@ -13,13 +13,20 @@
= javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "auto-submit-form", 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/item-card", async: true
= javascript_include_tag "wardrobe/item-search-keys", async: true
= javascript_include_tag "wardrobe/show", async: true
= 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
@ -27,42 +34,48 @@
We haven't seen this kind of pet before! Try a different species/color
combination.
- else
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer",
preferred_image_format: :svg # TODO: Make this a selectable option
.preview-controls
.preview-controls-top
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
%label.play-pause-control-button.button
%input{type: "checkbox"}
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
%span.paused-label Paused
%span.playing-label Playing
.preview-controls-bottom
= render "species_color_picker"
= render "appearance/species_color_picker"
- if @pet_type
= render "pose_picker"
= render "appearance/pose_picker"
.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"
- if @search_mode
= render "search_results"
= render "items/search_results"
- else
%h1 Untitled outfit
- if @outfit.worn_items.any?
.worn-items
.outfit-header
- if @saved_outfit && !@is_owner
.outfit-name-static= @outfit.name
- else
= render "header/outfit_rename_field"
= render "header/save_button"
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
.outfit-items
- outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group
%h3.zone-label= zone_group[:zone_label]
%ul.items-list
- zone_group[:items].each do |item|
= render "item_card", item: item
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]

View file

@ -640,6 +640,11 @@ en-MEEP:
rank: Reep
user: Meepit
points: Peeps
timeframes:
all_time: All Meep
this_year: Meeps Year
this_month: Meeps Month
this_week: Meeps Week
update:
success: Settings successfully meeped.

View file

@ -783,6 +783,11 @@ en:
rank: Rank
user: User
points: Points
timeframes:
all_time: All Time
this_year: This Year
this_month: This Month
this_week: This Week
update:
success: Settings successfully saved.

View file

@ -505,6 +505,11 @@ es:
rank: Puesto
user: Usuario
points: Puntos
timeframes:
all_time: Todo el Tiempo
this_year: Este Año
this_month: Este Mes
this_week: Esta Semana
update:
success: Ajustes guardados correctamente.
invalid: "No hemos podido guardar los ajustes: %{errors}"

View file

@ -499,6 +499,11 @@ pt:
rank: Rank
user: Usuário
points: Pontos
timeframes:
all_time: Todo o Tempo
this_year: Este Ano
this_month: Este Mês
this_week: Esta Semana
update:
success: Configurações salvas com sucesso
invalid: "Não foi possível salvar as configurações: %{errors}"

View file

@ -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!
@ -47,7 +48,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/alt-styles', to: redirect('/rainbow-pool/styles')
# Loading and modeling pets!
post '/pets/load' => 'pets#load', :as => :load_pet
match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post]
get '/modeling' => 'pets#bulk', :as => :bulk_pets
# Contributions to our modeling database!

View file

@ -0,0 +1,6 @@
class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1]
def change
add_index :contributions, [:user_id, :created_at],
name: 'index_contributions_on_user_id_and_created_at'
end
end

View file

@ -10,28 +10,28 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do
ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.string "encrypted_password", limit: 64
t.datetime "created_at", precision: nil
t.datetime "current_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "email", limit: 50
t.string "encrypted_password", limit: 64
t.integer "failed_attempts", default: 0
t.datetime "last_sign_in_at", precision: nil
t.string "last_sign_in_ip"
t.datetime "locked_at", precision: nil
t.string "name", limit: 30, null: false
t.string "neopass_email"
t.string "password_salt", limit: 32
t.string "provider"
t.datetime "remember_created_at"
t.datetime "reset_password_sent_at", precision: nil
t.string "reset_password_token"
t.integer "sign_in_count", default: 0
t.datetime "current_sign_in_at", precision: nil
t.datetime "last_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "last_sign_in_ip"
t.integer "failed_attempts", default: 0
t.string "unlock_token"
t.datetime "locked_at", precision: nil
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.datetime "reset_password_sent_at", precision: nil
t.datetime "remember_created_at"
t.string "provider"
t.string "uid"
t.string "neopass_email"
t.string "unlock_token"
t.datetime "updated_at", precision: nil
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true

View file

@ -10,50 +10,50 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "species_id", null: false
t.integer "color_id", null: false
t.integer "body_id", null: false
t.integer "color_id", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "series_name"
t.string "thumbnail_url", null: false
t.string "full_name"
t.string "series_name"
t.integer "species_id", null: false
t.string "thumbnail_url", null: false
t.datetime "updated_at", precision: nil, null: false
t.index ["color_id"], name: "index_alt_styles_on_color_id"
t.index ["species_id"], name: "index_alt_styles_on_species_id"
end
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "short_name", limit: 10, null: false
t.string "name", limit: 40, null: false
t.text "icon", size: :long, null: false
t.text "gateway", size: :long, null: false
t.text "icon", size: :long, null: false
t.string "name", limit: 40, null: false
t.string "secret", limit: 64, null: false
t.string "short_name", limit: 10, null: false
end
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "progress", default: 0, null: false
t.integer "goal", null: false
t.boolean "active", null: false
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.boolean "advertised", default: true, null: false
t.datetime "created_at", precision: nil, null: false
t.text "description", size: :long, null: false
t.string "purpose", default: "our hosting costs this year", null: false
t.string "theme_id", default: "hug", null: false
t.text "thanks", size: :long
t.integer "goal", null: false
t.string "name"
t.integer "progress", default: 0, null: false
t.string "purpose", default: "our hosting costs this year", null: false
t.text "thanks", size: :long
t.string "theme_id", default: "hug", null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "item_id"
t.integer "user_id"
t.integer "quantity"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.boolean "owned", default: true, null: false
t.integer "item_id"
t.integer "list_id"
t.boolean "owned", default: true, null: false
t.integer "quantity"
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "name"
t.text "description", size: :long
t.integer "user_id"
t.boolean "hangers_owned", null: false
t.datetime "created_at", precision: nil
t.text "description", size: :long
t.boolean "hangers_owned", null: false
t.string "name"
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.integer "visibility", default: 1, null: false
t.index ["user_id"], name: "index_closet_lists_on_user_id"
end
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.boolean "basic"
t.boolean "standard"
t.string "name", null: false
t.string "pb_item_name"
t.string "pb_item_thumbnail_url"
t.boolean "standard"
end
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "contributed_type", limit: 8, null: false
t.integer "contributed_id", null: false
t.integer "user_id", null: false
t.string "contributed_type", limit: 8, null: false
t.datetime "created_at", precision: nil, null: false
t.integer "user_id", null: false
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
t.index ["user_id"], name: "index_contributions_on_user_id"
end
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil, null: false
t.integer "donation_id", null: false
t.integer "outfit_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
end
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "amount", null: false
t.integer "campaign_id", null: false
t.string "charge_id", null: false
t.integer "user_id"
t.datetime "created_at", precision: nil, null: false
t.string "donor_email"
t.string "donor_name"
t.string "secret"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false
t.string "donor_email"
t.integer "campaign_id", null: false
t.integer "user_id"
end
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.datetime "created_at", precision: nil
t.boolean "is_worn"
t.integer "item_id"
t.integer "outfit_id"
t.boolean "is_worn"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
end
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.text "zones_restrict", size: :medium, null: false
t.text "thumbnail_url", size: :long, null: false
t.text "cached_compatible_body_ids", default: ""
t.string "cached_occupied_zone_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.string "category", limit: 50
t.string "type", limit: 50
t.integer "rarity_index", limit: 2
t.integer "price", limit: 3, null: false
t.integer "weight_lbs", limit: 2
t.text "species_support_ids", size: :long
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.text "description", size: :medium, null: false
t.integer "dyeworks_base_item_id"
t.boolean "explicitly_body_specific", default: false, null: false
t.boolean "is_manually_nc", default: false, null: false
t.integer "manual_special_color_id"
t.column "modeling_status_hint", "enum('done','glitchy')"
t.boolean "is_manually_nc", default: false, null: false
t.string "name", null: false
t.text "description", size: :medium, null: false
t.integer "price", limit: 3, null: false
t.string "rarity", default: "", null: false
t.integer "dyeworks_base_item_id"
t.string "cached_occupied_zone_ids", default: ""
t.text "cached_compatible_body_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.integer "rarity_index", limit: 2
t.text "species_support_ids", size: :long
t.text "thumbnail_url", size: :long, null: false
t.string "type", limit: 50
t.datetime "updated_at", precision: nil
t.integer "weight_lbs", limit: 2
t.text "zones_restrict", size: :medium, null: false
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "series", null: false
t.integer "token", null: false
t.integer "user_id", null: false
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
t.index ["user_id"], name: "login_cookies_user_id"
end
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "item_id", null: false
t.integer "price", null: false
t.integer "discount_price"
t.datetime "created_at", null: false
t.datetime "discount_begins_at"
t.datetime "discount_ends_at"
t.datetime "created_at", null: false
t.integer "discount_price"
t.integer "item_id", null: false
t.integer "price", null: false
t.datetime "updated_at", null: false
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
end
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "user_id"
t.string "neopets_username"
t.datetime "created_at", precision: nil, null: false
t.string "neopets_username"
t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
end
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "pet_state_id"
t.integer "user_id"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil
t.string "name"
t.boolean "starred", default: false, null: false
t.string "image"
t.string "image_layers_hash"
t.boolean "image_enqueued", default: false, null: false
t.bigint "alt_style_id"
t.datetime "created_at", precision: nil
t.string "image"
t.boolean "image_enqueued", default: false, null: false
t.string "image_layers_hash"
t.string "name"
t.integer "pet_state_id"
t.boolean "starred", default: false, null: false
t.datetime "updated_at", precision: nil
t.integer "user_id"
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
t.index ["user_id"], name: "index_outfits_on_user_id"
@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "parent_id", null: false
t.integer "swf_asset_id", null: false
t.string "parent_type", limit: 8, null: false
t.integer "swf_asset_id", null: false
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
end
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "pet_name", limit: 20, null: false
t.text "amf", size: :long, null: false
t.datetime "created_at", precision: nil, null: false
t.string "pet_name", limit: 20, null: false
end
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "pet_type_id", null: false
t.text "swf_asset_ids", size: :medium, null: false
t.boolean "female"
t.integer "mood_id"
t.boolean "unconverted"
t.boolean "labeled", default: false, null: false
t.boolean "glitched", default: false, null: false
t.string "artist_neopets_username"
t.datetime "created_at"
t.boolean "female"
t.boolean "glitched", default: false, null: false
t.boolean "labeled", default: false, null: false
t.integer "mood_id"
t.integer "pet_type_id", null: false
t.text "swf_asset_ids", size: :medium, null: false
t.boolean "unconverted"
t.datetime "updated_at"
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
end
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "color_id", null: false
t.integer "species_id", null: false
t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false
t.string "image_hash", limit: 8
t.string "basic_image_hash"
t.integer "body_id", limit: 2, null: false
t.integer "color_id", null: false
t.datetime "created_at", precision: nil, null: false
t.string "image_hash", limit: 8
t.integer "species_id", null: false
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
t.index ["body_id"], name: "pet_types_body_id"
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "type", limit: 7, null: false
t.integer "body_id", limit: 2, null: false
t.datetime "converted_at", precision: nil
t.datetime "created_at", precision: nil, null: false
t.boolean "has_image", default: false, null: false
t.boolean "image_manual", default: false, null: false
t.boolean "image_requested", default: false, null: false
t.string "known_glitches", limit: 128, default: ""
t.text "manifest", size: :long
t.timestamp "manifest_cached_at"
t.datetime "manifest_loaded_at"
t.integer "manifest_status_code"
t.string "manifest_url"
t.integer "remote_id", limit: 3, null: false
t.datetime "reported_broken_at", precision: nil
t.string "type", limit: 7, null: false
t.text "url", size: :long, null: false
t.integer "zone_id", null: false
t.text "zones_restrict", size: :medium, null: false
t.datetime "created_at", precision: nil, null: false
t.integer "body_id", limit: 2, null: false
t.boolean "has_image", default: false, null: false
t.boolean "image_requested", default: false, null: false
t.datetime "reported_broken_at", precision: nil
t.datetime "converted_at", precision: nil
t.boolean "image_manual", default: false, null: false
t.text "manifest", size: :long
t.timestamp "manifest_cached_at"
t.string "known_glitches", limit: 128, default: ""
t.string "manifest_url"
t.datetime "manifest_loaded_at"
t.integer "manifest_status_code"
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
end
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.string "name", limit: 30, null: false
t.integer "auth_server_id", limit: 1, null: false
t.integer "remote_id", null: false
t.integer "points", default: 0, null: false
t.boolean "beta", default: false, null: false
t.string "remember_token"
t.datetime "remember_created_at", precision: nil
t.integer "owned_closet_hangers_visibility", default: 1, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
t.integer "contact_neopets_connection_id"
t.timestamp "last_trade_activity_at"
t.boolean "support_staff", default: false, null: false
t.string "name", limit: 30, null: false
t.integer "owned_closet_hangers_visibility", default: 1, null: false
t.integer "points", default: 0, null: false
t.datetime "remember_created_at", precision: nil
t.string "remember_token"
t.integer "remote_id", null: false
t.boolean "shadowbanned", default: false, null: false
t.boolean "support_staff", default: false, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
end
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "depth"
t.integer "type_id"
t.string "label", null: false
t.string "plain_label", null: false
t.integer "type_id"
end
add_foreign_key "alt_styles", "colors"

View file

@ -0,0 +1,72 @@
# Sample contributions for testing Top Contributors feature
# Run with: rails runner db/seeds/top_contributors_sample_data.rb
puts "Creating sample contributions for Top Contributors testing..."
# Find or create test users
users = []
5.times do |i|
name = "TestContributor#{i + 1}"
user = User.find_or_create_by!(name: name) do |u|
# Create a corresponding auth_user record
auth_user = AuthUser.create!(
name: name,
email: "test#{i + 1}@example.com",
password: 'password123',
)
u.remote_id = auth_user.id
u.auth_server_id = 1
end
users << user
end
# Get some existing items/pet types to contribute
items = Item.limit(10).to_a
pet_types = PetType.limit(5).to_a
swf_assets = SwfAsset.limit(5).to_a
if items.empty? || pet_types.empty?
puts "WARNING: No items or pet types found. Create some first or contributions will be limited."
end
# Create contributions with different time periods
# User 1: Heavy contributor this week
if items.any?
3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) }
5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) }
end
# User 2: Heavy contributor this month, but not this week
if items.any? && pet_types.any?
2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) }
1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) }
end
# User 3: Heavy contributor this year, but not this month
if pet_types.any?
3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) }
end
# User 4: Old contributor (only in all-time)
if items.any?
users[3].update!(points: 500) # Set points directly for all-time view
2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) }
end
# User 5: Mixed contributions across all periods
if items.any? && pet_types.any?
Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago)
Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago)
Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago)
end
if swf_assets.any?
Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago)
end
puts "Created sample contributions:"
puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)"
puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)"
puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)"
puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)"
puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)"
puts "\nTest the feature at: http://localhost:3000/users/top-contributors"

View file

@ -442,13 +442,12 @@
mode: "755"
state: directory
- name: Remove 10min cron job to run `rails nc_mall:sync`
- name: Create 2min cron job to run `rails items:auto_model`
become_user: impress
cron:
state: absent
name: "Impress: sync NC Mall data"
minute: "*/10"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
name: "Impress: auto-model items"
minute: "*/2"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'"
- name: Create 10min cron job to run `rails neopets:import`
become_user: impress

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,119 @@
require_relative '../rails_helper'
RSpec.describe UsersController, type: :controller do
include Devise::Test::ControllerHelpers
describe 'GET #top_contributors' do
let!(:user1) { create_user('Alice', 100) }
let!(:user2) { create_user('Bob', 50) }
let!(:user3) { create_user('Charlie', 0) }
context 'without timeframe parameter' do
it 'defaults to all_time timeframe' do
get :top_contributors
expect(assigns(:timeframe)).to eq('all_time')
end
it 'returns users ordered by points' do
get :top_contributors
users = assigns(:users)
expect(users.to_a.map(&:id)).to eq([user1.id, user2.id])
end
it 'paginates results' do
get :top_contributors, params: { page: 1 }
users = assigns(:users)
expect(users).to respond_to(:total_pages)
expect(users).to respond_to(:current_page)
end
end
context 'with valid timeframe parameter' do
it 'accepts all_time' do
get :top_contributors, params: { timeframe: 'all_time' }
expect(assigns(:timeframe)).to eq('all_time')
end
it 'accepts this_year' do
get :top_contributors, params: { timeframe: 'this_year' }
expect(assigns(:timeframe)).to eq('this_year')
end
it 'accepts this_month' do
get :top_contributors, params: { timeframe: 'this_month' }
expect(assigns(:timeframe)).to eq('this_month')
end
it 'accepts this_week' do
get :top_contributors, params: { timeframe: 'this_week' }
expect(assigns(:timeframe)).to eq('this_week')
end
it 'calls User.top_contributors_for with the timeframe' do
expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original
get :top_contributors, params: { timeframe: 'this_week' }
end
end
context 'with invalid timeframe parameter' do
it 'defaults to all_time' do
get :top_contributors, params: { timeframe: 'invalid' }
expect(assigns(:timeframe)).to eq('all_time')
end
it 'does not raise an error' do
expect {
get :top_contributors, params: { timeframe: 'invalid' }
}.not_to raise_error
end
end
context 'with pagination' do
before do
# Create 25 users to test pagination (per_page is 20)
25.times do |i|
create_user("User#{i}", 100 - i)
end
end
it 'paginates with 20 users per page' do
get :top_contributors
expect(assigns(:users).size).to eq(20)
end
it 'supports page parameter' do
get :top_contributors, params: { page: 2 }
expect(assigns(:users).current_page).to eq(2)
end
it 'works with timeframe and pagination together' do
get :top_contributors, params: { timeframe: 'all_time', page: 2 }
expect(assigns(:timeframe)).to eq('all_time')
expect(assigns(:users).current_page).to eq(2)
end
end
context 'renders the correct template' do
it 'renders the top_contributors template' do
get :top_contributors
expect(response).to render_template('top_contributors')
end
it 'returns HTTP success' do
get :top_contributors
expect(response).to have_http_status(:success)
end
end
end
# Helper methods
def create_user(name, points = 0)
auth_user = AuthUser.create!(
name: name,
email: "#{name.downcase}@example.com",
password: 'password123',
password_confirmation: 'password123'
)
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points)
end
end

View file

@ -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 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 true
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

293
spec/models/user_spec.rb Normal file
View file

@ -0,0 +1,293 @@
require_relative '../rails_helper'
RSpec.describe User do
describe '.top_contributors_for' do
let!(:user1) { create_user('Alice') }
let!(:user2) { create_user('Bob') }
let!(:user3) { create_user('Charlie') }
context 'with all_time timeframe' do
it 'uses the denormalized points column' do
user1.update!(points: 100)
user2.update!(points: 50)
user3.update!(points: 0)
results = User.top_contributors_for(:all_time)
expect(results.map(&:id)).to eq([user1.id, user2.id])
expect(results.first.points).to eq(100)
expect(results.second.points).to eq(50)
end
it 'excludes users with zero points' do
user1.update!(points: 100)
user2.update!(points: 0)
results = User.top_contributors_for(:all_time)
expect(results).not_to include(user2)
end
it 'orders by points descending' do
user1.update!(points: 50)
user2.update!(points: 100)
user3.update!(points: 75)
results = User.top_contributors_for(:all_time)
expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id])
end
end
context 'with this_week timeframe' do
let(:item) { create_item }
before do
# Create contributions from this week
create_contribution(user1, item, 3.days.ago) # 3 points
create_contribution(user1, item, 2.days.ago) # 3 points
# Create contributions from last month (should be excluded)
create_contribution(user2, item, 1.month.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last week' do
results = User.top_contributors_for(:this_week)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(6)
end
it 'excludes users with no recent contributions' do
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user2)
end
it 'excludes contributions older than one week' do
create_contribution(user3, item, 8.days.ago)
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user3)
end
end
context 'with this_month timeframe' do
let(:item) { create_item }
let(:pet_type) { create_pet_type }
before do
# User 1: contributions from this month
create_contribution(user1, item, 15.days.ago) # 3 points
create_contribution(user1, pet_type, 20.days.ago) # 15 points
# User 2: contributions older than one month
create_contribution(user2, item, 35.days.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last month' do
results = User.top_contributors_for(:this_month)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(18)
end
it 'excludes contributions older than one month' do
results = User.top_contributors_for(:this_month)
expect(results).not_to include(user2)
end
end
context 'with this_year timeframe' do
let(:item) { create_item }
before do
# User 1: contributions from this year
create_contribution(user1, item, 3.months.ago) # 3 points
create_contribution(user1, item, 6.months.ago) # 3 points
# User 2: contributions older than one year
create_contribution(user2, item, 13.months.ago) # 3 points (excluded)
end
it 'calculates points from contributions in the last year' do
results = User.top_contributors_for(:this_year)
expect(results.first).to eq(user1)
expect(results.first.period_points).to eq(6)
end
it 'excludes contributions older than one year' do
results = User.top_contributors_for(:this_year)
expect(results).not_to include(user2)
end
end
context 'point value calculations' do
let(:item) { create_item }
let(:pet_type) { create_pet_type }
let(:alt_style) { create_alt_style }
it 'assigns 3 points for Item contributions' do
create_contribution(user1, item, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(3)
end
it 'assigns 15 points for PetType contributions' do
create_contribution(user1, pet_type, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(15)
end
it 'assigns 30 points for AltStyle contributions' do
create_contribution(user1, alt_style, 1.day.ago)
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(30)
end
it 'sums multiple contribution types correctly' do
create_contribution(user1, item, 1.day.ago) # 3 points
create_contribution(user1, pet_type, 2.days.ago) # 15 points
create_contribution(user1, alt_style, 3.days.ago) # 30 points
results = User.top_contributors_for(:this_week)
expect(results.first.period_points).to eq(48)
end
end
context 'ordering and filtering' do
let(:item) { create_item }
before do
# Create various contributions
3.times { create_contribution(user1, item, 1.day.ago) } # 9 points
5.times { create_contribution(user2, item, 2.days.ago) } # 15 points
2.times { create_contribution(user3, item, 3.days.ago) } # 6 points
end
it 'orders by period_points descending' do
results = User.top_contributors_for(:this_week)
expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id])
end
it 'uses user.id as secondary sort for tied scores' do
# Create two users with same points
user4 = create_user('Dave')
user5 = create_user('Eve')
create_contribution(user4, item, 1.day.ago) # 3 points
create_contribution(user5, item, 1.day.ago) # 3 points
results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id])
# Should be ordered by user.id ASC when points are tied
expect(results.first.id).to be < results.second.id
end
it 'excludes users with zero contributions in period' do
# user3 has no contributions this week
user4 = create_user('Dave')
results = User.top_contributors_for(:this_week)
expect(results).not_to include(user4)
end
end
context 'with invalid timeframe' do
it 'raises ArgumentError' do
expect { User.top_contributors_by_period(:invalid) }.
to raise_error(ArgumentError, /Invalid timeframe/)
end
end
end
describe '#period_points' do
let(:user) { create_user('Alice') }
context 'when period_points attribute is set' do
it 'returns the calculated period_points' do
# Simulate a query that sets period_points
user_with_period = User.select('users.*, 42 AS period_points').find(user.id)
expect(user_with_period.period_points).to eq(42)
end
end
context 'when period_points attribute is not set' do
it 'falls back to denormalized points column' do
user.update!(points: 100)
expect(user.period_points).to eq(100)
end
end
end
# Helper methods
def create_user(name)
auth_user = AuthUser.create!(
name: name,
email: "#{name.downcase}@example.com",
password: 'password123',
password_confirmation: 'password123'
)
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1)
end
def create_contribution(user, contributed, created_at)
Contribution.create!(
user: user,
contributed: contributed,
created_at: created_at
)
end
def create_item
# Create a minimal item for testing
Item.create!(
name: "Test Item #{SecureRandom.hex(4)}",
description: "Test item",
thumbnail_url: "http://example.com/thumb.png",
rarity: "",
price: 0,
zones_restrict: ""
)
end
def create_swf_asset
# Create a minimal swf_asset for testing
zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1)
SwfAsset.create!(
type: 'object',
remote_id: SecureRandom.random_number(100000),
url: "http://example.com/test.swf",
zone_id: zone.id,
body_id: 0
)
end
def create_pet_type
# Use find_or_create_by to avoid duplicate key errors
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
PetType.create!(
species_id: species.id,
color_id: color.id,
body_id: 0
)
end
def create_pet_state
pet_type = create_pet_type
PetState.create!(
pet_type: pet_type,
swf_asset_ids: []
)
end
def create_alt_style
# Use find_or_create_by to avoid duplicate key errors
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
AltStyle.create!(
species_id: species.id,
color_id: color.id,
body_id: 0,
series_name: "Test Series",
thumbnail_url: "http://example.com/thumb.png"
)
end
end

Binary file not shown.

File diff suppressed because it is too large Load diff