Compare commits

..

No commits in common. "f4b13091493872b0765f1784fbab24dd60a99b96" and "b03b32c538250ff5b5bf39df13cdc2f0c63a8d6b" have entirely different histories.

59 changed files with 2487 additions and 3581 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,28 @@
/**
* 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

@ -1,37 +0,0 @@
/**
* 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

@ -1,70 +0,0 @@
/**
* 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

@ -1,61 +0,0 @@
/**
* 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,36 +1,6 @@
// Wardrobe v2 - Simple Rails+Turbo outfit editor
//
// This page uses Turbo for instant updates when changing species/color.
// This page uses Turbo Frames for instant updates when changing species/color.
// The outfit_viewer Web Component handles the pet rendering.
// 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();
console.log("Wardrobe v2 loaded!");

View file

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

View file

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

View file

@ -1,10 +1,5 @@
@import "../application/item-badges.css";
/* ===================================================================
Shared Components
Buttons, item cards, pagination, and other reusable patterns.
=================================================================== */
/* Base button defaults - applied to all interactive controls */
button,
input[type="submit"],
@ -15,19 +10,19 @@ select,
border-radius: 0.375rem;
border: 1px solid #ddd;
background: white;
color: var(--color-primary);
color: #448844;
cursor: pointer;
transition: all 0.2s;
&:hover {
background: #f9f9f9;
border-color: var(--color-primary);
border-color: #448844;
}
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-muted);
border-color: #448844;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
}
}
@ -60,12 +55,12 @@ select,
padding: 0.75rem 1.5rem;
font-size: 1rem;
border: none;
background: var(--color-primary);
background: #448844;
color: white;
font-weight: 500;
&:hover {
background: var(--color-primary-hover);
background: #357535;
}
&:focus {
@ -79,9 +74,7 @@ select,
/* Icon button pattern - small action buttons with hover reveals */
.item-remove-button,
.item-add-button,
.item-hide-button,
.item-show-button {
.item-add-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
@ -110,7 +103,7 @@ select,
&:focus {
opacity: 1;
outline: 2px solid var(--color-primary);
outline: 2px solid #448844;
outline-offset: 2px;
}
}
@ -131,140 +124,6 @@ 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 {
a,
@ -275,26 +134,26 @@ item-card:defined:has(input:focus-visible) {
border-radius: 4px;
border: 1px solid #ddd;
background: white;
color: var(--color-primary);
color: #448844;
text-decoration: none;
transition: all 0.2s;
&:hover {
background: #f9f9f9;
border-color: var(--color-primary);
border-color: #448844;
}
}
.current,
em {
background: var(--color-primary);
background: #448844;
color: white;
border-color: var(--color-primary);
border-color: #448844;
font-style: normal;
&:hover {
background: var(--color-primary);
border-color: var(--color-primary);
background: #448844;
border-color: #448844;
}
}
@ -310,31 +169,7 @@ item-card:defined:has(input:focus-visible) {
}
}
/* 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 {
--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;
padding: 0;
height: 100vh;
@ -345,6 +180,7 @@ body.wardrobe-v2 {
.wardrobe-container {
display: grid;
height: 100vh;
background: #000;
/* Mobile: vertical stack with preview on top, controls below */
grid-template-areas:
@ -361,17 +197,12 @@ body.wardrobe-v2 {
}
}
/* ===================================================================
Outfit Preview
Left/top panel: outfit viewer, floating controls, pose picker.
=================================================================== */
.outfit-preview-section {
grid-area: preview;
display: flex;
align-items: center;
justify-content: center;
background: rgb(23, 25, 35);
background: #000;
position: relative;
container-type: size;
@ -461,63 +292,8 @@ body.wardrobe-v2 {
display: none;
}
/* 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 */
.pose-picker-button {
anchor-name: --pose-picker-anchor;
display: flex;
align-items: center;
@ -543,9 +319,10 @@ body.wardrobe-v2 {
&[popovertargetopen] {
border-color: rgba(255, 255, 255, 0.8);
}
}
}
pose-picker-popover {
/* Pose picker popover */
pose-picker-popover {
position: absolute;
position-anchor: --pose-picker-anchor;
margin: 0;
@ -558,7 +335,6 @@ pose-picker-popover {
padding: 1.25rem;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
width: 20rem;
.pose-picker-form {
display: flex;
@ -587,21 +363,19 @@ pose-picker-popover {
user-select: none;
}
.pose-option input[type="radio"],
.style-option input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pose-option {
display: block;
cursor: pointer;
position: relative;
width: 60px;
height: 60px;
margin: 0 auto;
input[type="radio"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
}
.pose-thumbnail {
width: 60px;
@ -638,8 +412,8 @@ pose-picker-popover {
/* Selected state */
input[type="radio"]:checked + .pose-thumbnail {
border-color: var(--color-accent);
box-shadow: 0 0 0 3px var(--color-accent-glow);
border-color: #48BB78;
box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.4);
transform: scale(1.05);
}
@ -666,131 +440,92 @@ pose-picker-popover {
}
}
/* Submit button: progressive enhancement pattern */
.pose-submit-button {
margin-top: 1rem;
width: 100%;
}
/* Tab panel layout */
.tab-list {
display: flex;
gap: 0.25rem;
margin-top: 1rem;
}
.tab-button {
flex: 1;
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);
}
&.active {
background: rgba(255, 255, 255, 0.2);
color: white;
border-color: rgba(255, 255, 255, 0.4);
/* If JS is enabled, hide the submit button initially with a delay */
@media (scripting: enabled) {
.pose-submit-button {
opacity: 0;
animation: fade-in 0.25s forwards;
animation-delay: 0.75s;
}
}
/* Without JS, hide tab buttons and show both panels stacked */
tab-panel:not(:defined) .tab-list {
/* Once auto-submit is enabled, hide the submit button completely */
&:state(auto-loading) .pose-submit-button {
display: none;
}
tab-panel:not(:defined) .tab-content[hidden] {
display: block !important;
}
/* Style picker form */
.style-picker-form {
display: flex;
flex-direction: column;
/* Species/color picker */
species-color-picker {
display: contents;
form {
display: contents;
}
.style-picker-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
max-height: 200px;
overflow-y: auto;
}
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;
.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;
}
}
.style-option-name {
option {
background: #2D3748;
color: white;
font-size: 0.9rem;
}
/* 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);
}
}
.style-submit-button {
margin-top: 1rem;
width: 100%;
/* Submit button: progressive enhancement pattern */
/* If JS is disabled, the button is always visible */
/* 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;
}
}
}
/* 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;
}
}
/* ===================================================================
Outfit Controls
Right/bottom panel: worn items, search, and results.
=================================================================== */
.outfit-controls-section {
grid-area: controls;
background: #fff;
@ -799,9 +534,15 @@ pose-picker-popover {
overflow-y: auto;
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
h1 {
margin-top: 0;
font-size: 1.75rem;
color: #448844;
}
h2 {
font-size: 1.25rem;
color: var(--color-primary);
color: #448844;
margin-top: 2rem;
}
@ -828,7 +569,7 @@ pose-picker-popover {
}
}
.outfit-items {
.worn-items {
margin-top: 2rem;
.items-list {
@ -836,6 +577,70 @@ pose-picker-popover {
padding: 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 {
@ -859,10 +664,12 @@ pose-picker-popover {
&:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px var(--color-primary-muted);
border-color: #448844;
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
}
}
/* input[type="submit"] styles are defined in button system above */
}
}
@ -871,6 +678,69 @@ pose-picker-popover {
list-style: none;
padding: 0;
margin: 1rem 0;
.item-card {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: #f9f9f9;
margin-bottom: 0.5rem;
border-radius: 8px;
color: #333;
transition: background 0.2s, box-shadow 0.2s;
position: relative;
&:hover {
background: #f0f0f0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
&:hover :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 {
@ -884,187 +754,11 @@ pose-picker-popover {
display: flex;
justify-content: center;
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 {
from {
opacity: 0;

View file

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

View file

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

View file

@ -14,10 +14,7 @@ class UsersController < ApplicationController
end
def top_contributors
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)
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
end
def edit

View file

@ -1,21 +1,5 @@
class WardrobeController < ApplicationController
prepend_view_path Rails.root.join("app/views/wardrobe")
def show
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
# If visiting a saved outfit with no state params, redirect with the
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
# the rest of the action always reads from params.
if @saved_outfit && !outfit_state_params_present?
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
return
end
# Set the form target path for all wardrobe forms
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
# Get selected species and color from params, or default to Blue Acara
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
@ -57,45 +41,20 @@ class WardrobeController < ApplicationController
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
end
# Load alt style from params, scoped to the current species
@alt_style = if params[:style].present? && @selected_species
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
end
# Load all available alt styles for this species (for the style picker)
@available_alt_styles = @selected_species ?
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
# Load items from the objects[] and closet[] parameters
worn_item_ids = params[:objects] || []
closeted_item_ids = params[:closet] || []
worn_items = Item.where(id: worn_item_ids)
closeted_items = Item.where(id: closeted_item_ids)
# Load items from the objects[] parameter
item_ids = params[:objects] || []
items = Item.where(id: item_ids)
# Build the outfit
@outfit = Outfit.new(
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
pet_state: @pet_state,
alt_style: @alt_style,
worn_items: worn_items,
closeted_items: closeted_items,
worn_items: items,
)
# Preload the manifests for all visible layers, so they load efficiently
# in parallel rather than sequentially when rendering
SwfAsset.preload_manifests(@outfit.visible_layers)
# Also preload alt style layer manifests for the style picker thumbnails
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
# Compute saved outfit state for the view
if @saved_outfit
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
else
@has_unsaved_changes = false
end
# Handle search mode
@search_mode = params[:q].present?
if @search_mode
@ -136,10 +95,6 @@ class WardrobeController < ApplicationController
poses_hash
end
def outfit_state_params_present?
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
end
def build_search_filters(query_params, outfit)
filters = []

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,17 @@
- 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

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

View file

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

View file

@ -0,0 +1,12 @@
%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

@ -1,22 +0,0 @@
- 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

@ -1,32 +0,0 @@
= 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

@ -1,13 +0,0 @@
.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

@ -1,11 +0,0 @@
= 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

@ -1,22 +0,0 @@
- 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

@ -1,20 +0,0 @@
- 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

@ -1,10 +0,0 @@
= 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

@ -1,29 +0,0 @@
- 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

@ -1,7 +0,0 @@
.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,11 +1,11 @@
- title @outfit.name
- title "Wardrobe v2"
!!! 5
%html
%head
%meta{charset: 'utf-8'}
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
%title #{yield :title} | #{t "app_name"}
%title= yield :title
%link{href: image_path('favicon.png'), rel: 'icon'}
= stylesheet_link_tag "application/hanger-spinner"
= stylesheet_link_tag "application/outfit-viewer"
@ -13,20 +13,13 @@
= javascript_include_tag "application", async: true
= javascript_include_tag "idiomorph", async: true
= javascript_include_tag "outfit-viewer", async: true
= javascript_include_tag "auto-submit-form", async: true
= javascript_include_tag "species-color-picker", async: true
= javascript_include_tag "pose-picker", async: true
= javascript_include_tag "tab-panel", async: true
= javascript_include_tag "outfit-rename-field", async: true
= javascript_include_tag "wardrobe/item-card", async: true
= javascript_include_tag "wardrobe/item-search-keys", async: true
= javascript_include_tag "wardrobe/show", async: true
= csrf_meta_tags
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
%body.wardrobe-v2
- if flash[:alert]
.flash-messages
.flash-alert= flash[:alert]
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
.wardrobe-container
.outfit-preview-section
- if @pet_type.nil?
.no-preview-message
@ -34,48 +27,42 @@
We haven't seen this kind of pet before! Try a different species/color
combination.
- else
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer",
preferred_image_format: :svg # TODO: Make this a selectable option
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
.preview-controls
.preview-controls-top
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
%label.play-pause-control-button.button
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
%input{type: "checkbox"}
%span.paused-label Paused
%span.playing-label Playing
.preview-controls-bottom
= render "appearance/species_color_picker"
= render "species_color_picker"
- if @pet_type
= render "appearance/pose_picker"
= render "pose_picker"
.outfit-controls-section
.item-search-form
- if @search_mode
= button_to @wardrobe_path, method: :get, class: "back-button" do
= button_to wardrobe_v2_path, method: :get, class: "back-button" do
= outfit_state_params except: [:q]
= form_with url: @wardrobe_path, method: :get, class: "search-form" do |f|
= form_with url: wardrobe_v2_path, method: :get, class: "search-form" do |f|
= outfit_state_params
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
= f.submit "Search"
- if @search_mode
= render "items/search_results"
= render "search_results"
- else
.outfit-header
- if @saved_outfit && !@is_owner
.outfit-name-static= @outfit.name
- else
= render "header/outfit_rename_field"
= render "header/save_button"
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
.outfit-items
%h1 Untitled outfit
- if @outfit.worn_items.any?
.worn-items
- outfit_items_by_zone(@outfit).each do |zone_group|
.zone-group
%h3.zone-label= zone_group[:zone_label]
%ul.items-list
- zone_group[:items].each do |item|
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
= render "item_card", item: item

View file

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

View file

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

View file

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

View file

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

View file

@ -12,7 +12,6 @@ OpenneoImpressItems::Application.routes.draw do
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
get '/wardrobe' => redirect('/outfits/new')
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
get '/start/:color_name/:species_name' => 'outfits#start'
# The outfits users have created!
@ -48,7 +47,7 @@ OpenneoImpressItems::Application.routes.draw do
get '/alt-styles', to: redirect('/rainbow-pool/styles')
# Loading and modeling pets!
match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post]
post '/pets/load' => 'pets#load', :as => :load_pet
get '/modeling' => 'pets#bulk', :as => :bulk_pets
# Contributions to our modeling database!

View file

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

View file

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

View file

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

View file

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

File diff suppressed because it is too large Load diff

View file

@ -1,119 +0,0 @@
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,72 +343,6 @@ RSpec.describe Outfit do
item
end
describe "#same_wardrobe_state_as?" do
it "returns true for outfits with identical state" do
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns true even when names differ (name is not part of wardrobe state)" do
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when poses differ" do
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when worn items differ" do
hat = create_item("Hat", zones(:hat1))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns true regardless of worn item order" do
hat = create_item("Hat", zones(:hat1))
shirt = create_item("Shirt", zones(:shirtdress))
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
end
it "returns false when species differ" do
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
it "returns false when alt styles differ" do
alt_style = AltStyle.create!(
species: acara,
color: blue,
body_id: 999,
series_name: "Nostalgic",
thumbnail_url: "https://images.neopets.example/alt.png"
)
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
end
end
describe "#visible_layers" do
before do
# Clean up any existing pet types to avoid conflicts

View file

@ -1,293 +0,0 @@
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