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. # For automated tests.
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test] gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
gem 'rails-controller-testing', group: [:test]
gem "webmock", "~> 3.24", group: [:test] gem "webmock", "~> 3.24", group: [:test]

View file

@ -341,6 +341,10 @@ GEM
activesupport (= 8.1.2) activesupport (= 8.1.2)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 8.1.2) 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) rails-dom-testing (2.3.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@ -505,6 +509,7 @@ DEPENDENCIES
rack-attack (~> 6.7) rack-attack (~> 6.7)
rack-mini-profiler (~> 4.0, >= 4.0.1) rack-mini-profiler (~> 4.0, >= 4.0.1)
rails (~> 8.0, >= 8.0.1) rails (~> 8.0, >= 8.0.1)
rails-controller-testing
rails-i18n (~> 8.0, >= 8.0.1) rails-i18n (~> 8.0, >= 8.0.1)
rdiscount (~> 2.2, >= 2.2.7.1) rdiscount (~> 2.2, >= 2.2.7.1)
rspec-rails (~> 8.0, >= 8.0.2) 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 { try {
const mainPickerForm = document.querySelector( const mainPickerForm = document.querySelector(
"#item-preview species-color-picker form", "#item-preview .species-color-picker form",
); );
const mainSpeciesField = mainPickerForm.querySelector( const mainSpeciesField = mainPickerForm.querySelector(
"[name='preview[species_id]']", "[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 { class OutfitViewer extends HTMLElement {
#internals; #internals;
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround) #isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
constructor() { constructor() {
super(); super();
@ -8,7 +9,31 @@ class OutfitViewer extends HTMLElement {
} }
connectedCallback() { 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) => { this.addEventListener("hasanimationschange", (e) => {
// Only handle events from outfit-layer children, not from ourselves // Only handle events from outfit-layer children, not from ourselves
if (e.target === this) return; if (e.target === this) return;
@ -16,23 +41,6 @@ class OutfitViewer extends HTMLElement {
this.#updateHasAnimations(); 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 // The `<outfit-layer>` is connected to the DOM right before its
// children are. So, to engage with the children, wait a tick! // children are. So, to engage with the children, wait a tick!
setTimeout(() => this.#connectToChildren(), 0); setTimeout(() => this.#connectToChildren(), 0);
@ -57,12 +65,12 @@ class OutfitViewer extends HTMLElement {
this.querySelector("outfit-layer:state(has-animations)") !== null; this.querySelector("outfit-layer:state(has-animations)") !== null;
// Check if state actually changed // Check if state actually changed
const hadAnimations = this.#internals.states.has("has-animations"); if (hasAnimations === this.#hasAnimations) {
if (hasAnimations === hadAnimations) {
return; // No change, skip return; // No change, skip
} }
// Update internal state // Update internal state
this.#hasAnimations = hasAnimations;
if (hasAnimations) { if (hasAnimations) {
this.#internals.states.add("has-animations"); this.#internals.states.add("has-animations");
} else { } else {
@ -137,7 +145,7 @@ class OutfitViewer extends HTMLElement {
.split("; ") .split("; ")
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying=")); .find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
if (cookie) { if (cookie) {
return cookie.split("=")[1] === "true"; return cookie.split("=")[1] !== "false";
} }
return true; // Default to playing return true; // Default to playing
} }
@ -388,6 +396,13 @@ class OutfitViewerPlayPauseToggle extends HTMLElement {
"outfit-layer:state(has-animations)", "outfit-layer:state(has-animations)",
) !== null, ) !== 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() { #syncFromOutfitViewer() {

View file

@ -1,30 +1,31 @@
/** /**
* PosePicker web component * PosePickerPopover web component
* *
* Progressive enhancement for pose picker forms: * Scrolls the selected style into view when the style picker list becomes
* - Auto-submits the form when a pose is selected (if JS is enabled) * visible (e.g. tab switch or popover open).
* - 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 PosePickerPopover extends HTMLElement { class PosePickerPopover extends HTMLElement {
#internals; #styleListObserver;
constructor() {
super();
this.#internals = this.attachInternals();
}
connectedCallback() { connectedCallback() {
// Listen for changes to auto-submit the form, then tell CSS about it! // When the style picker list becomes visible (e.g. tab switch or
this.addEventListener("change", this.#handleChange); // popover open), scroll the selected style into view.
this.#internals.states.add("auto-loading"); 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) { disconnectedCallback() {
// Only auto-submit if a radio button was changed this.#styleListObserver?.disconnect();
if (e.target.type === "radio") {
this.querySelector("form").requestSubmit();
}
} }
} }

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

View file

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

View file

@ -1,5 +1,10 @@
@import "../application/item-badges.css"; @import "../application/item-badges.css";
/* ===================================================================
Shared Components
Buttons, item cards, pagination, and other reusable patterns.
=================================================================== */
/* Base button defaults - applied to all interactive controls */ /* Base button defaults - applied to all interactive controls */
button, button,
input[type="submit"], input[type="submit"],
@ -10,19 +15,19 @@ select,
border-radius: 0.375rem; border-radius: 0.375rem;
border: 1px solid #ddd; border: 1px solid #ddd;
background: white; background: white;
color: #448844; color: var(--color-primary);
cursor: pointer; cursor: pointer;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
background: #f9f9f9; background: #f9f9f9;
border-color: #448844; border-color: var(--color-primary);
} }
&:focus { &:focus {
outline: none; outline: none;
border-color: #448844; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1); box-shadow: 0 0 0 3px var(--color-primary-muted);
} }
} }
@ -55,12 +60,12 @@ select,
padding: 0.75rem 1.5rem; padding: 0.75rem 1.5rem;
font-size: 1rem; font-size: 1rem;
border: none; border: none;
background: #448844; background: var(--color-primary);
color: white; color: white;
font-weight: 500; font-weight: 500;
&:hover { &:hover {
background: #357535; background: var(--color-primary-hover);
} }
&:focus { &:focus {
@ -74,7 +79,9 @@ select,
/* Icon button pattern - small action buttons with hover reveals */ /* Icon button pattern - small action buttons with hover reveals */
.item-remove-button, .item-remove-button,
.item-add-button { .item-add-button,
.item-hide-button,
.item-show-button {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
right: 0.5rem; right: 0.5rem;
@ -103,7 +110,7 @@ select,
&:focus { &:focus {
opacity: 1; opacity: 1;
outline: 2px solid #448844; outline: 2px solid var(--color-primary);
outline-offset: 2px; outline-offset: 2px;
} }
} }
@ -124,6 +131,140 @@ select,
} }
} }
.item-hide-button {
right: 2.5rem;
background: rgba(255, 255, 255, 0.9);
&:hover {
background: rgba(255, 255, 255, 1);
}
}
.item-show-button {
right: 2.5rem;
background: rgba(68, 136, 68, 0.9);
&:hover {
background: rgba(68, 136, 68, 1);
}
}
/* Item card - shared layout for worn items and search results */
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 :is(.item-add-button, .item-remove-button, .item-hide-button, .item-show-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;
}
}
/* Worn item emphasis */
item-card[data-is-worn] {
background: #eef5ee;
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
.item-name {
font-weight: 600;
}
}
/* Closeted item de-emphasis */
item-card[data-is-closeted] {
background: #f5f5f5;
border: 1px dashed #ccc;
opacity: 0.75;
}
/* Visually hidden inputs (radio/checkbox) - accessible but not visible */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
/* Item card label - click target wrapping thumbnail + info */
.item-card-label {
display: flex;
align-items: center;
gap: 0.75rem;
cursor: pointer;
flex: 1;
min-width: 0;
}
/* When item-card is defined (JS loaded), hide Show/Hide/Add buttons
and make the label the primary interaction. Keep Remove visible. */
item-card:defined .item-show-button,
item-card:defined .item-hide-button,
item-card:defined .item-add-button {
display: none;
}
/* Focus ring on item card when input is focused (keyboard navigation) */
item-card:defined:has(input:focus-visible) {
outline: 2px solid var(--color-primary);
outline-offset: -2px;
}
/* Pagination links - treated as buttons for consistency */ /* Pagination links - treated as buttons for consistency */
.pagination { .pagination {
a, a,
@ -134,26 +275,26 @@ select,
border-radius: 4px; border-radius: 4px;
border: 1px solid #ddd; border: 1px solid #ddd;
background: white; background: white;
color: #448844; color: var(--color-primary);
text-decoration: none; text-decoration: none;
transition: all 0.2s; transition: all 0.2s;
&:hover { &:hover {
background: #f9f9f9; background: #f9f9f9;
border-color: #448844; border-color: var(--color-primary);
} }
} }
.current, .current,
em { em {
background: #448844; background: var(--color-primary);
color: white; color: white;
border-color: #448844; border-color: var(--color-primary);
font-style: normal; font-style: normal;
&:hover { &:hover {
background: #448844; background: var(--color-primary);
border-color: #448844; border-color: var(--color-primary);
} }
} }
@ -169,7 +310,31 @@ select,
} }
} }
/* Progressive enhancement: submit buttons hidden when JS auto-submits */
@media (scripting: enabled) {
.progressive-submit {
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
auto-submit-form:state(auto-loading) .progressive-submit {
display: none;
}
/* ===================================================================
Page Layout
Top-level grid: preview on left/top, controls on right/bottom.
=================================================================== */
body.wardrobe-v2 { body.wardrobe-v2 {
--color-primary: #448844;
--color-primary-hover: #357535;
--color-primary-muted: rgba(68, 136, 68, 0.1);
--color-accent: #48BB78;
--color-accent-glow: rgba(72, 187, 120, 0.4);
margin: 0; margin: 0;
padding: 0; padding: 0;
height: 100vh; height: 100vh;
@ -180,7 +345,6 @@ body.wardrobe-v2 {
.wardrobe-container { .wardrobe-container {
display: grid; display: grid;
height: 100vh; height: 100vh;
background: #000;
/* Mobile: vertical stack with preview on top, controls below */ /* Mobile: vertical stack with preview on top, controls below */
grid-template-areas: grid-template-areas:
@ -197,12 +361,17 @@ body.wardrobe-v2 {
} }
} }
/* ===================================================================
Outfit Preview
Left/top panel: outfit viewer, floating controls, pose picker.
=================================================================== */
.outfit-preview-section { .outfit-preview-section {
grid-area: preview; grid-area: preview;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background: #000; background: rgb(23, 25, 35);
position: relative; position: relative;
container-type: size; container-type: size;
@ -292,7 +461,62 @@ body.wardrobe-v2 {
display: none; display: none;
} }
/* Pose picker button */ /* Species/color picker */
.species-color-picker {
display: contents;
auto-submit-form,
form {
display: contents;
}
select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 1rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
option {
background: #2D3748;
color: white;
}
}
input[type="submit"] {
padding: 0.5rem 1rem;
font-size: 1rem;
}
@media (scripting: enabled) {
input[type="submit"] {
position: absolute;
margin-left: 0.5em;
}
}
}
/* Show controls on hover (real hover only, not simulated touch hover) */
@media (hover: hover) {
&:hover .preview-controls {
opacity: 1;
}
}
/* Show controls when they have focus or when popover is open */
&:has(.preview-controls:focus-within) .preview-controls,
&:has(.pose-picker-button[popovertargetopen]) .preview-controls {
opacity: 1;
}
}
/* ===================================================================
Pose Picker Popover
Floating panel for choosing expression/pose and alt styles.
=================================================================== */
.pose-picker-button { .pose-picker-button {
anchor-name: --pose-picker-anchor; anchor-name: --pose-picker-anchor;
display: flex; display: flex;
@ -321,7 +545,6 @@ body.wardrobe-v2 {
} }
} }
/* Pose picker popover */
pose-picker-popover { pose-picker-popover {
position: absolute; position: absolute;
position-anchor: --pose-picker-anchor; position-anchor: --pose-picker-anchor;
@ -335,6 +558,7 @@ body.wardrobe-v2 {
padding: 1.25rem; padding: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
width: 20rem;
.pose-picker-form { .pose-picker-form {
display: flex; display: flex;
@ -363,19 +587,21 @@ body.wardrobe-v2 {
user-select: none; user-select: none;
} }
.pose-option input[type="radio"],
.style-option input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pose-option { .pose-option {
display: block; display: block;
cursor: pointer; cursor: pointer;
position: relative; position: relative;
width: 60px; width: 60px;
height: 60px; height: 60px;
margin: 0 auto;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pose-thumbnail { .pose-thumbnail {
width: 60px; width: 60px;
@ -412,8 +638,8 @@ body.wardrobe-v2 {
/* Selected state */ /* Selected state */
input[type="radio"]:checked + .pose-thumbnail { input[type="radio"]:checked + .pose-thumbnail {
border-color: #48BB78; border-color: var(--color-accent);
box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.4); box-shadow: 0 0 0 3px var(--color-accent-glow);
transform: scale(1.05); transform: scale(1.05);
} }
@ -440,92 +666,131 @@ body.wardrobe-v2 {
} }
} }
/* Submit button: progressive enhancement pattern */
.pose-submit-button { .pose-submit-button {
margin-top: 1rem; margin-top: 1rem;
width: 100%; width: 100%;
} }
/* If JS is enabled, hide the submit button initially with a delay */ /* Tab panel layout */
@media (scripting: enabled) { .tab-list {
.pose-submit-button { display: flex;
opacity: 0; gap: 0.25rem;
animation: fade-in 0.25s forwards; margin-top: 1rem;
animation-delay: 0.75s;
}
} }
/* Once auto-submit is enabled, hide the submit button completely */ .tab-button {
&:state(auto-loading) .pose-submit-button { flex: 1;
display: none; padding: 0.4rem 0.75rem;
} font-size: 0.85rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
cursor: pointer;
transition: all 0.2s;
&:hover {
background: rgba(255, 255, 255, 0.15);
color: rgba(255, 255, 255, 0.8);
border-color: rgba(255, 255, 255, 0.3);
} }
/* Species/color picker */ &.active {
species-color-picker { background: rgba(255, 255, 255, 0.2);
display: contents;
form {
display: contents;
}
select {
padding: 0.5rem 2rem 0.5rem 0.75rem;
font-size: 1rem;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
background-position: right 0.5rem center;
background-repeat: no-repeat;
background-size: 1.5em 1.5em;
option {
background: #2D3748;
color: white; color: white;
border-color: rgba(255, 255, 255, 0.4);
} }
} }
/* Submit button: progressive enhancement pattern */ /* Without JS, hide tab buttons and show both panels stacked */
/* If JS is disabled, the button is always visible */ tab-panel:not(:defined) .tab-list {
/* If JS is enabled but slow to load, fade in after 0.75s */
/* Once the web component loads, hide the button completely */
input[type="submit"] {
padding: 0.5rem 1rem;
font-size: 1rem;
}
/* If JS is enabled, hide the submit button initially with a delay */
@media (scripting: enabled) {
input[type="submit"] {
position: absolute;
margin-left: 0.5em;
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
/* Once auto-loading is ready, hide the submit button completely */
&:state(auto-loading) {
input[type="submit"] {
display: none; display: none;
} }
tab-panel:not(:defined) .tab-content[hidden] {
display: block !important;
}
/* Style picker form */
.style-picker-form {
display: flex;
flex-direction: column;
}
.style-picker-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 200px;
overflow-y: auto;
}
.style-option {
display: block;
cursor: pointer;
.style-option-content {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.2s;
}
.style-option-thumbnail {
flex-shrink: 0;
width: 40px;
height: 40px;
border-radius: 6px;
overflow: hidden;
background: rgba(255, 255, 255, 0.1);
img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
} }
} }
/* Show controls on hover (real hover only, not simulated touch hover) */ .style-option-name {
@media (hover: hover) { color: white;
&:hover .preview-controls { font-size: 0.9rem;
opacity: 1; }
/* Hover */
&:hover .style-option-content {
border-color: rgba(255, 255, 255, 0.3);
background: rgba(255, 255, 255, 0.05);
}
/* Selected state */
input[type="radio"]:checked + .style-option-content {
border-color: var(--color-accent);
box-shadow: 0 0 0 2px rgba(72, 187, 120, 0.3);
background: rgba(72, 187, 120, 0.1);
}
/* Focus state */
input[type="radio"]:focus + .style-option-content {
border-color: rgba(255, 255, 255, 0.8);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
} }
} }
/* Show controls when they have focus or when popover is open */ .style-submit-button {
&:has(.preview-controls:focus-within) .preview-controls, margin-top: 1rem;
&:has(.pose-picker-button[popovertargetopen]) .preview-controls { width: 100%;
opacity: 1;
} }
} }
/* ===================================================================
Outfit Controls
Right/bottom panel: worn items, search, and results.
=================================================================== */
.outfit-controls-section { .outfit-controls-section {
grid-area: controls; grid-area: controls;
background: #fff; background: #fff;
@ -534,15 +799,9 @@ body.wardrobe-v2 {
overflow-y: auto; overflow-y: auto;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3); box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #448844;
}
h2 { h2 {
font-size: 1.25rem; font-size: 1.25rem;
color: #448844; color: var(--color-primary);
margin-top: 2rem; margin-top: 2rem;
} }
@ -569,7 +828,7 @@ body.wardrobe-v2 {
} }
} }
.worn-items { .outfit-items {
margin-top: 2rem; margin-top: 2rem;
.items-list { .items-list {
@ -577,70 +836,6 @@ body.wardrobe-v2 {
padding: 0; padding: 0;
margin: 0.5rem 0; margin: 0.5rem 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-remove-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;
/* Allow text to truncate */
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-remove-button styles are defined in button system above */
} }
.item-search-form { .item-search-form {
@ -664,12 +859,10 @@ body.wardrobe-v2 {
&:focus { &:focus {
outline: none; outline: none;
border-color: #448844; border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1); box-shadow: 0 0 0 3px var(--color-primary-muted);
} }
} }
/* input[type="submit"] styles are defined in button system above */
} }
} }
@ -678,69 +871,6 @@ body.wardrobe-v2 {
list-style: none; list-style: none;
padding: 0; padding: 0;
margin: 1rem 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 :is(.item-add-button, .item-remove-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 styles are defined in button system above */
} }
.empty-state { .empty-state {
@ -754,11 +884,187 @@ body.wardrobe-v2 {
display: flex; display: flex;
justify-content: center; justify-content: center;
gap: 0.5rem; gap: 0.5rem;
}
}
/* Pagination link styles are defined in button system above */ /* ===================================================================
Flash Messages
=================================================================== */
.flash-messages {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
padding: 0.75rem 1rem;
text-align: center;
}
.flash-alert {
background: #f8d7da;
color: #842029;
border: 1px solid #f5c2c7;
border-radius: 6px;
padding: 0.75rem 1rem;
max-width: 600px;
margin: 0 auto;
}
/* ===================================================================
Outfit Header (name + save button)
=================================================================== */
.outfit-header {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.outfit-name-form {
flex: 1;
min-width: 0;
display: flex;
}
.outfit-name-input {
flex: 1;
min-width: 0;
font-size: 1.5rem;
font-weight: bold;
color: var(--color-primary);
border: 1px solid transparent;
border-radius: 6px;
padding: 0.25rem 0.5rem;
background: transparent;
transition: border-color 0.2s, background 0.2s;
&:hover {
border-color: #ddd;
background: #fafafa;
}
&:focus {
outline: none;
border-color: var(--color-primary);
background: white;
box-shadow: 0 0 0 3px var(--color-primary-muted);
}
&::placeholder {
color: #aaa;
font-weight: normal;
} }
} }
/* Rename button: hidden by default, shown on hover/focus */
.outfit-name-submit {
margin-left: 0.5rem;
display: none;
}
.outfit-name-form:focus-within .outfit-name-submit,
.outfit-name-form:hover .outfit-name-submit {
display: inline;
}
/* Static name display for non-owners */
.outfit-name-static {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-primary);
padding: 0.25rem 0.5rem;
flex: 1;
min-width: 0;
}
/* Web component: static display with pencil icon */
outfit-rename-field {
flex: 1;
min-width: 0;
}
outfit-rename-field .outfit-name-form {
display: none;
}
outfit-rename-field[editing] .outfit-rename-static-display {
display: none;
}
outfit-rename-field[editing] .outfit-name-form {
display: flex;
}
.outfit-rename-static-display {
display: flex;
align-items: center;
gap: 0.25rem;
}
.outfit-rename-name {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-primary);
padding: 0.25rem 0.5rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.outfit-rename-pencil {
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
padding: 0.25rem;
opacity: 0;
transition: opacity 0.15s;
}
.outfit-rename-static-display:hover .outfit-rename-pencil,
.outfit-rename-pencil:focus {
opacity: 1;
}
/* Hide save button when rename is in editing state */
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-form,
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled {
display: none;
}
.outfit-save-form {
display: inline;
}
.outfit-save-button {
white-space: nowrap;
&:disabled {
opacity: 0.6;
cursor: default;
color: #888;
border-color: #ddd;
background: #f5f5f5;
&:hover {
background: #f5f5f5;
border-color: #ddd;
}
}
}
a.outfit-save-button {
text-decoration: none;
display: inline-block;
}
/* ===================================================================
Animations
=================================================================== */
@keyframes fade-in { @keyframes fade-in {
from { from {
opacity: 0; opacity: 0;

View file

@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
@outfit.user = current_user @outfit.user = current_user
if @outfit.save 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 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
end end
@ -123,9 +132,25 @@ class OutfitsController < ApplicationController
def update def update
if @outfit.update(outfit_params) 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 else
render_outfit_errors redirect_to wardrobe_v2_outfit_path(@outfit)
end
end
format.json { render json: @outfit }
end
else
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
end end

View file

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

View file

@ -14,7 +14,10 @@ class UsersController < ApplicationController
end end
def top_contributors 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 end
def edit def edit

View file

@ -1,5 +1,21 @@
class WardrobeController < ApplicationController class WardrobeController < ApplicationController
prepend_view_path Rails.root.join("app/views/wardrobe")
def show 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 # 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_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") @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)) SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
end end
# Load items from the objects[] parameter # Load alt style from params, scoped to the current species
item_ids = params[:objects] || [] @alt_style = if params[:style].present? && @selected_species
items = Item.where(id: item_ids) 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 # Build the outfit
@outfit = Outfit.new( @outfit = Outfit.new(
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
pet_state: @pet_state, 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 # Preload the manifests for all visible layers, so they load efficiently
# in parallel rather than sequentially when rendering # in parallel rather than sequentially when rendering
SwfAsset.preload_manifests(@outfit.visible_layers) 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 # Handle search mode
@search_mode = params[:q].present? @search_mode = params[:q].present?
if @search_mode if @search_mode
@ -95,6 +136,10 @@ class WardrobeController < ApplicationController
poses_hash poses_hash
end 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) def build_search_filters(query_params, outfit)
filters = [] filters = []

View file

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

View file

@ -5,9 +5,11 @@ module WardrobeHelper
def outfit_state_params(outfit = @outfit, except: []) def outfit_state_params(outfit = @outfit, except: [])
fields = [] 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(:species, @outfit.species_id) unless except.include?(:species)
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color) 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(: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) unless except.include?(:worn_items)
outfit.worn_items.each do |item| outfit.worn_items.each do |item|
@ -15,6 +17,12 @@ module WardrobeHelper
end end
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) unless except.include?(:q)
(params[:q] || {}).each do |key, value| (params[:q] || {}).each do |key, value|
fields << hidden_field_tag("q[#{key}]", value) if value.present? fields << hidden_field_tag("q[#{key}]", value) if value.present?
@ -24,8 +32,12 @@ module WardrobeHelper
safe_join fields safe_join fields
end end
# Get the emoji and label for a pose, for display in the pose picker button # Get the emoji and label for the pose picker button.
def pose_emoji_and_label(pose) # 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
case pose case pose
when "HAPPY_MASC", "HAPPY_FEM" when "HAPPY_MASC", "HAPPY_FEM"
{ emoji: "😀", label: "Happy" } { emoji: "😀", label: "Happy" }
@ -37,28 +49,31 @@ module WardrobeHelper
{ emoji: "😀", label: "Default" } { emoji: "😀", label: "Default" }
end end
end end
end
# Group outfit items by zone, applying smart multi-zone simplification. # 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. # This matches the logic from wardrobe-2020's getZonesAndItems function.
def outfit_items_by_zone(outfit) def outfit_items_by_zone(outfit)
return [] if outfit.pet_type.nil? 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( item_appearances = Item.appearances_for(
outfit.worn_items, all_items,
outfit.pet_type, outfit.pet_type,
swf_asset_includes: [:zone] swf_asset_includes: [:zone]
) )
# Separate incompatible items (no layers for this pet) # Separate compatible and incompatible items
compatible_items = [] compatible = {}
incompatible_items = [] incompatible_items = []
outfit.worn_items.each do |item| all_items.each do |item|
appearance = item_appearances[item.id] appearance = item_appearances[item.id]
if appearance&.present? if appearance&.present?
compatible_items << {item: item, appearance: appearance} compatible[item] = appearance
else else
incompatible_items << item incompatible_items << item
end end
@ -68,11 +83,7 @@ module WardrobeHelper
items_by_zone = Hash.new { |h, k| h[k] = [] } items_by_zone = Hash.new { |h, k| h[k] = [] }
zones_by_id = {} zones_by_id = {}
compatible_items.each do |item_with_appearance| compatible.each do |item, 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)
appearance.swf_assets.map(&:zone).uniq.each do |zone| appearance.swf_assets.map(&:zone).uniq.each do |zone|
zones_by_id[zone.id] = zone zones_by_id[zone.id] = zone
items_by_zone[zone.id] << item items_by_zone[zone.id] << item
@ -138,8 +149,7 @@ module WardrobeHelper
# For single-item groups, only keep if: # For single-item groups, only keep if:
# - Item hasn't been seen yet AND # - Item hasn't been seen yet AND
# - Item won't appear in a conflict group # - Item won't appear in a conflict group
item = group[:items].first item_id = group[:items].first.id
item_id = item.id
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id) if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
false false

View file

@ -261,17 +261,24 @@ class Outfit < ApplicationRecord
(biology_layers + item_layers).sort_by(&:depth) (biology_layers + item_layers).sort_by(&:depth)
end 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 def wardrobe_params
params = { params = {
name: name,
color: color_id, color: color_id,
species: species_id, species: species_id,
pose: pose, pose: pose,
state: pet_state_id, objects: worn_item_ids.sort,
objects: worn_item_ids, closet: closeted_item_ids.sort,
closet: closeted_item_ids,
} }
params[:style] = alt_style_id if alt_style_id.present? params[:style] = alt_style_id if alt_style_id.present?
params[:name] = name if !persisted? && name.present?
params params
end end
@ -311,9 +318,23 @@ class Outfit < ApplicationRecord
end end
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) 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 end
# Create a copy of this outfit, additionally wearing the given item. # 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 # 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? 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 # Load appearances for the new item and all currently worn items
all_items = o.worn_items + [item] all_items = o.worn_items + [item]
appearances = Item.appearances_for(all_items, o.pet_type, 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') } 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 :sync_name_with_auth_user!, if: :saved_change_to_name?
after_update :log_trade_activity, if: -> user { after_update :log_trade_activity, if: -> user {
(user.saved_change_to_owned_closet_hangers_visibility? && (user.saved_change_to_owned_closet_hangers_visibility? &&

View file

@ -1,10 +1,11 @@
- html_options = {} unless defined? html_options - 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 = content_tag "outfit-viewer", **html_options do
.loading-indicator= render partial: "hanger_spinner" .loading-indicator= render partial: "hanger_spinner"
- outfit.visible_layers.each do |swf_asset| - outfit.visible_layers.each do |swf_asset|
%outfit-layer{ %outfit-layer{
id: "#{viewer_id}-layer-#{swf_asset.id}",
data: { data: {
"asset-id": swf_asset.id, "asset-id": swf_asset.id,
"zone": swf_asset.zone.label, "zone": swf_asset.zone.label,

View file

@ -33,7 +33,8 @@
Customize more Customize more
= edit_icon = edit_icon
%species-color-picker .species-color-picker
%auto-submit-form
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f| = form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
- if @preview_error == :pet_type_does_not_exist - if @preview_error == :pet_type_does_not_exist
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️ %span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
@ -138,5 +139,5 @@
- content_for :javascripts do - content_for :javascripts do
= javascript_include_tag "idiomorph", async: true = javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", 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 = javascript_include_tag "items/show", async: true

View file

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

View file

@ -1,4 +1,13 @@
- title t('.title') - 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 = will_paginate @users
%table#top-contributors %table#top-contributors
%thead %thead
@ -11,5 +20,5 @@
%tr %tr
%th{:scope => 'row'}= @users.offset + rank + 1 %th{:scope => 'row'}= @users.offset + rank + 1
%td= link_to user.name, user_contributions_path(user) %td= link_to user.name, user_contributions_path(user)
%td= user.points %td= user.period_points
= will_paginate @users = 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 - if is_available
-# Create a minimal outfit with just this pet state for the thumbnail -# Create a minimal outfit with just this pet state for the thumbnail
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: []) - 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 - else
.pose-unavailable .pose-unavailable
%span.question-mark{title: "Not available"} ❓ %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 .search-results
- if @search_results.any? - 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 %ul.search-results-list
- @search_results.each do |item| - @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 - else
.empty-state .empty-state

View file

@ -1,11 +1,11 @@
- title "Wardrobe v2" - title @outfit.name
!!! 5 !!! 5
%html %html
%head %head
%meta{charset: 'utf-8'} %meta{charset: 'utf-8'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'} %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'} %link{href: image_path('favicon.png'), rel: 'icon'}
= stylesheet_link_tag "application/hanger-spinner" = stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer" = stylesheet_link_tag "application/outfit-viewer"
@ -13,13 +13,20 @@
= javascript_include_tag "application", async: true = javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true = javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", 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 "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 = javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags = csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'} %meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2 %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 .outfit-preview-section
- if @pet_type.nil? - if @pet_type.nil?
.no-preview-message .no-preview-message
@ -27,42 +34,48 @@
We haven't seen this kind of pet before! Try a different species/color We haven't seen this kind of pet before! Try a different species/color
combination. combination.
- else - 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
.preview-controls-top .preview-controls-top
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"} %outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
%label.play-pause-control-button.button %label.play-pause-control-button.button
%input{type: "checkbox"} %input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
%span.paused-label Paused %span.paused-label Paused
%span.playing-label Playing %span.playing-label Playing
.preview-controls-bottom .preview-controls-bottom
= render "species_color_picker" = render "appearance/species_color_picker"
- if @pet_type - if @pet_type
= render "pose_picker" = render "appearance/pose_picker"
.outfit-controls-section .outfit-controls-section
.item-search-form .item-search-form
- if @search_mode - 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] = 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 = outfit_state_params
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items" = f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
= f.submit "Search" = f.submit "Search"
- if @search_mode - if @search_mode
= render "search_results" = render "items/search_results"
- else - else
%h1 Untitled outfit .outfit-header
- if @outfit.worn_items.any? - if @saved_outfit && !@is_owner
.worn-items .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| - outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group .zone-group
%h3.zone-label= zone_group[:zone_label] %h3.zone-label= zone_group[:zone_label]
%ul.items-list %ul.items-list
- zone_group[:items].each do |item| - 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 rank: Reep
user: Meepit user: Meepit
points: Peeps points: Peeps
timeframes:
all_time: All Meep
this_year: Meeps Year
this_month: Meeps Month
this_week: Meeps Week
update: update:
success: Settings successfully meeped. success: Settings successfully meeped.

View file

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

View file

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

View file

@ -499,6 +499,11 @@ pt:
rank: Rank rank: Rank
user: Usuário user: Usuário
points: Pontos points: Pontos
timeframes:
all_time: Todo o Tempo
this_year: Este Ano
this_month: Este Mês
this_week: Esta Semana
update: update:
success: Configurações salvas com sucesso success: Configurações salvas com sucesso
invalid: "Não foi possível salvar as configurações: %{errors}" 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 '/outfits/new', to: 'outfits#edit', as: :wardrobe
get '/wardrobe' => redirect('/outfits/new') get '/wardrobe' => redirect('/outfits/new')
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2 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' get '/start/:color_name/:species_name' => 'outfits#start'
# The outfits users have created! # The outfits users have created!
@ -47,7 +48,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/alt-styles', to: redirect('/rainbow-pool/styles') get '/alt-styles', to: redirect('/rainbow-pool/styles')
# Loading and modeling pets! # 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 get '/modeling' => 'pets#bulk', :as => :bulk_pets
# Contributions to our modeling database! # 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. # 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| 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.datetime "created_at", precision: nil
t.string "encrypted_password", limit: 64 t.datetime "current_sign_in_at", precision: nil
t.string "current_sign_in_ip"
t.string "email", limit: 50 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 "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.string "reset_password_token"
t.integer "sign_in_count", default: 0 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 "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 ["email"], name: "index_users_on_email", unique: true
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", 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 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. # 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| 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 "body_id", null: false
t.integer "color_id", null: false
t.datetime "created_at", precision: nil, 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 "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 ["color_id"], name: "index_alt_styles_on_color_id"
t.index ["species_id"], name: "index_alt_styles_on_species_id" t.index ["species_id"], name: "index_alt_styles_on_species_id"
end end
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "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 "secret", limit: 64, null: false
t.string "short_name", limit: 10, null: false
end end
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.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.boolean "advertised", default: true, null: false
t.datetime "created_at", precision: nil, null: false
t.text "description", size: :long, null: false t.text "description", size: :long, null: false
t.string "purpose", default: "our hosting costs this year", null: false t.integer "goal", null: false
t.string "theme_id", default: "hug", null: false
t.text "thanks", size: :long
t.string "name" 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 end
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "created_at", precision: nil
t.datetime "updated_at", precision: nil t.integer "item_id"
t.boolean "owned", default: true, null: false
t.integer "list_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 ["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 ["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" 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 end
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.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.datetime "updated_at", precision: nil
t.integer "user_id"
t.integer "visibility", default: 1, null: false t.integer "visibility", default: 1, null: false
t.index ["user_id"], name: "index_closet_lists_on_user_id" t.index ["user_id"], name: "index_closet_lists_on_user_id"
end end
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.boolean "basic" t.boolean "basic"
t.boolean "standard"
t.string "name", null: false t.string "name", null: false
t.string "pb_item_name" t.string "pb_item_name"
t.string "pb_item_thumbnail_url" t.string "pb_item_thumbnail_url"
t.boolean "standard"
end end
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "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.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 ["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" t.index ["user_id"], name: "index_contributions_on_user_id"
end end
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "donation_id", null: false
t.integer "outfit_id" t.integer "outfit_id"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
end end
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "amount", null: false t.integer "amount", null: false
t.integer "campaign_id", null: false
t.string "charge_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 "donor_name"
t.string "secret" t.string "secret"
t.datetime "created_at", precision: nil, null: false
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.string "donor_email" t.integer "user_id"
t.integer "campaign_id", null: false
end end
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "item_id"
t.integer "outfit_id" t.integer "outfit_id"
t.boolean "is_worn"
t.datetime "created_at", precision: nil
t.datetime "updated_at", precision: nil t.datetime "updated_at", precision: nil
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id" 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" t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
end end
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "cached_compatible_body_ids", default: ""
t.text "thumbnail_url", size: :long, null: false t.string "cached_occupied_zone_ids", default: ""
t.boolean "cached_predicted_fully_modeled", default: false, null: false
t.string "category", limit: 50 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 "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 "explicitly_body_specific", default: false, null: false
t.boolean "is_manually_nc", default: false, null: false
t.integer "manual_special_color_id" t.integer "manual_special_color_id"
t.column "modeling_status_hint", "enum('done','glitchy')" t.column "modeling_status_hint", "enum('done','glitchy')"
t.boolean "is_manually_nc", default: false, null: false
t.string "name", 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.string "rarity", default: "", null: false
t.integer "dyeworks_base_item_id" t.integer "rarity_index", limit: 2
t.string "cached_occupied_zone_ids", default: "" t.text "species_support_ids", size: :long
t.text "cached_compatible_body_ids", default: "" t.text "thumbnail_url", size: :long, null: false
t.boolean "cached_predicted_fully_modeled", default: false, 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 ["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", "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" 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 end
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "series", null: false
t.integer "token", 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", "series"], name: "login_cookies_user_id_and_series"
t.index ["user_id"], name: "login_cookies_user_id" t.index ["user_id"], name: "login_cookies_user_id"
end end
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
end end
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t| create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
t.integer "item_id", null: false t.datetime "created_at", null: false
t.integer "price", null: false
t.integer "discount_price"
t.datetime "discount_begins_at" t.datetime "discount_begins_at"
t.datetime "discount_ends_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.datetime "updated_at", null: false
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
end end
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.datetime "created_at", precision: nil, null: false
t.string "neopets_username"
t.datetime "updated_at", precision: nil, null: false t.datetime "updated_at", precision: nil, null: false
t.integer "user_id"
end end
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.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 ["alt_style_id"], name: "index_outfits_on_alt_style_id"
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id" t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
t.index ["user_id"], name: "index_outfits_on_user_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| 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 "parent_id", null: false
t.integer "swf_asset_id", null: false
t.string "parent_type", limit: 8, 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", "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 ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id" t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
end end
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.text "amf", size: :long, null: false
t.datetime "created_at", precision: nil, null: false t.datetime "created_at", precision: nil, null: false
t.string "pet_name", limit: 20, null: false
end end
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.string "artist_neopets_username"
t.datetime "created_at" 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.datetime "updated_at"
t.index ["pet_type_id"], name: "pet_states_pet_type_id" t.index ["pet_type_id"], name: "pet_states_pet_type_id"
end end
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.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", "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 ["body_id"], name: "pet_types_body_id"
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_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 end
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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.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.text "url", size: :long, null: false
t.integer "zone_id", null: false t.integer "zone_id", null: false
t.text "zones_restrict", size: :medium, 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 ["body_id"], name: "swf_assets_body_id_and_object_id"
t.index ["type", "remote_id"], name: "swf_assets_type_and_id" t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
t.index ["zone_id"], name: "idx_swf_assets_zone_id" t.index ["zone_id"], name: "idx_swf_assets_zone_id"
end end
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| 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 "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.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.integer "contact_neopets_connection_id"
t.timestamp "last_trade_activity_at" 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 "shadowbanned", default: false, null: false
t.boolean "support_staff", default: false, null: false
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
end end
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t| create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
t.integer "depth" t.integer "depth"
t.integer "type_id"
t.string "label", null: false t.string "label", null: false
t.string "plain_label", null: false t.string "plain_label", null: false
t.integer "type_id"
end end
add_foreign_key "alt_styles", "colors" 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" mode: "755"
state: directory 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 become_user: impress
cron: cron:
state: absent name: "Impress: auto-model items"
name: "Impress: sync NC Mall data" minute: "*/2"
minute: "*/10" job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'"
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
- name: Create 10min cron job to run `rails neopets:import` - name: Create 10min cron job to run `rails neopets:import`
become_user: impress 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 item
end 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 describe "#visible_layers" do
before do before do
# Clean up any existing pet types to avoid conflicts # 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