Compare commits
32 commits
b03b32c538
...
f4b1309149
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b1309149 | |||
| f13481783d | |||
| 0691153101 | |||
| f9b040c20b | |||
| 0a9c346fa6 | |||
| 6f7b307e39 | |||
| b462272dc3 | |||
| 10e2140045 | |||
| 36a28cff10 | |||
| 81b60eefad | |||
| 9baa64d39a | |||
| 3582b3674b | |||
| d0acb1c7e5 | |||
| 0a82ed7b68 | |||
| fd881ee31d | |||
| f5ad5d2b17 | |||
| d7c561f91d | |||
| 6fa4e57184 | |||
| 0d4b553162 | |||
| 5e68d3809c | |||
| ff3dd2249e | |||
| 97a035b3a3 | |||
| d7b1f0e067 | |||
| 4503c12a1f | |||
| e694bc5d05 | |||
| fc93239482 | |||
| b7bbd1ace3 | |||
| 3b471fcb05 | |||
| fd2940880f | |||
| df043b939e | |||
| 304a7ac9e1 | |||
| 366158b698 |
59 changed files with 3579 additions and 2485 deletions
1
Gemfile
1
Gemfile
|
|
@ -87,4 +87,5 @@ gem "shell", "~> 0.8.1"
|
|||
|
||||
# For automated tests.
|
||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||
gem 'rails-controller-testing', group: [:test]
|
||||
gem "webmock", "~> 3.24", group: [:test]
|
||||
|
|
|
|||
|
|
@ -341,6 +341,10 @@ GEM
|
|||
activesupport (= 8.1.2)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 8.1.2)
|
||||
rails-controller-testing (1.0.5)
|
||||
actionpack (>= 5.0.1.rc1)
|
||||
actionview (>= 5.0.1.rc1)
|
||||
activesupport (>= 5.0.1.rc1)
|
||||
rails-dom-testing (2.3.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
|
|
@ -505,6 +509,7 @@ DEPENDENCIES
|
|||
rack-attack (~> 6.7)
|
||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||
rails (~> 8.0, >= 8.0.1)
|
||||
rails-controller-testing
|
||||
rails-i18n (~> 8.0, >= 8.0.1)
|
||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||
rspec-rails (~> 8.0, >= 8.0.2)
|
||||
|
|
|
|||
27
app/assets/javascripts/auto-submit-form.js
Normal file
27
app/assets/javascripts/auto-submit-form.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* AutoSubmitForm web component
|
||||
*
|
||||
* Generic progressive enhancement for forms that should auto-submit on change:
|
||||
* - Listens for `change` events on descendant form inputs
|
||||
* - Calls `requestSubmit()` on the nearest `<form>`
|
||||
* - Exposes `:state(auto-loading)` to hide fallback submit buttons via CSS
|
||||
*/
|
||||
class AutoSubmitForm extends HTMLElement {
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
e.target.closest("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("auto-submit-form", AutoSubmitForm);
|
||||
|
|
@ -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]']",
|
||||
|
|
|
|||
47
app/assets/javascripts/outfit-rename-field.js
Normal file
47
app/assets/javascripts/outfit-rename-field.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
/**
|
||||
* OutfitRenameField web component
|
||||
*
|
||||
* Progressive enhancement for the outfit name field:
|
||||
* - Shows a static text header with a pencil icon button
|
||||
* - Pencil appears on hover/focus of the container
|
||||
* - Clicking pencil switches to the editable form
|
||||
* - Enter submits, Escape/blur reverts to static display
|
||||
*
|
||||
* State is managed via the `editing` attribute, which CSS uses to toggle
|
||||
* visibility. Turbo morphs naturally reset this attribute (since it's not in
|
||||
* the server HTML), so no morph-specific handling is needed.
|
||||
*/
|
||||
class OutfitRenameField extends HTMLElement {
|
||||
connectedCallback() {
|
||||
const pencil = this.querySelector(".outfit-rename-pencil");
|
||||
const input = this.querySelector("input[type=text]");
|
||||
if (!pencil || !input) return;
|
||||
|
||||
pencil.addEventListener("click", () => {
|
||||
this.dataset.originalValue = input.value;
|
||||
this.setAttribute("editing", "");
|
||||
input.focus();
|
||||
input.select();
|
||||
});
|
||||
|
||||
this.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && this.hasAttribute("editing")) {
|
||||
e.preventDefault();
|
||||
this.#cancelEditing(input);
|
||||
}
|
||||
});
|
||||
|
||||
this.addEventListener("focusout", (e) => {
|
||||
if (this.hasAttribute("editing") && !this.contains(e.relatedTarget)) {
|
||||
this.#cancelEditing(input);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#cancelEditing(input) {
|
||||
input.value = this.dataset.originalValue ?? input.value;
|
||||
this.removeAttribute("editing");
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("outfit-rename-field", OutfitRenameField);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
class OutfitViewer extends HTMLElement {
|
||||
#internals;
|
||||
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
||||
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -8,7 +9,31 @@ class OutfitViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Set up listener for bubbled hasanimationschange events from layers
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// When a layer is added, update its playing state to match ours.
|
||||
const addedLayers = mutations
|
||||
.flatMap(m => [...m.addedNodes])
|
||||
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||
for (const layer of addedLayers) {
|
||||
if (this.#internals.states.has("playing")) {
|
||||
layer.play();
|
||||
} else {
|
||||
layer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
const removedLayers = mutations
|
||||
.flatMap(m => [...m.removedNodes])
|
||||
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||
|
||||
// If any layers were added or removed, updated our hasAnimations state.
|
||||
if (addedLayers.length > 0 || removedLayers.length > 0) {
|
||||
this.#updateHasAnimations();
|
||||
}
|
||||
});
|
||||
observer.observe(this, { childList: true });
|
||||
|
||||
// When a new layer finishes loading and determines it has animations, update.
|
||||
this.addEventListener("hasanimationschange", (e) => {
|
||||
// Only handle events from outfit-layer children, not from ourselves
|
||||
if (e.target === this) return;
|
||||
|
|
@ -16,23 +41,6 @@ class OutfitViewer extends HTMLElement {
|
|||
this.#updateHasAnimations();
|
||||
});
|
||||
|
||||
// Watch for new layers being added and apply the current playing state
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.tagName === "OUTFIT-LAYER") {
|
||||
// Apply current playing state to the new layer
|
||||
if (this.#internals.states.has("playing")) {
|
||||
node.play();
|
||||
} else {
|
||||
node.pause();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(this, { childList: true });
|
||||
|
||||
// The `<outfit-layer>` is connected to the DOM right before its
|
||||
// children are. So, to engage with the children, wait a tick!
|
||||
setTimeout(() => this.#connectToChildren(), 0);
|
||||
|
|
@ -57,12 +65,12 @@ class OutfitViewer extends HTMLElement {
|
|||
this.querySelector("outfit-layer:state(has-animations)") !== null;
|
||||
|
||||
// Check if state actually changed
|
||||
const hadAnimations = this.#internals.states.has("has-animations");
|
||||
if (hasAnimations === hadAnimations) {
|
||||
if (hasAnimations === this.#hasAnimations) {
|
||||
return; // No change, skip
|
||||
}
|
||||
|
||||
// Update internal state
|
||||
this.#hasAnimations = hasAnimations;
|
||||
if (hasAnimations) {
|
||||
this.#internals.states.add("has-animations");
|
||||
} else {
|
||||
|
|
@ -137,7 +145,7 @@ class OutfitViewer extends HTMLElement {
|
|||
.split("; ")
|
||||
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
|
||||
if (cookie) {
|
||||
return cookie.split("=")[1] === "true";
|
||||
return cookie.split("=")[1] !== "false";
|
||||
}
|
||||
return true; // Default to playing
|
||||
}
|
||||
|
|
@ -388,6 +396,13 @@ class OutfitViewerPlayPauseToggle extends HTMLElement {
|
|||
"outfit-layer:state(has-animations)",
|
||||
) !== null,
|
||||
);
|
||||
|
||||
// After a Turbo morph, Idiomorph may remove our `hidden` attribute
|
||||
// (since the server HTML never includes it). Re-apply visibility
|
||||
// based on the current animation state.
|
||||
document.addEventListener("turbo:render", () => {
|
||||
this.#syncFromOutfitViewer();
|
||||
});
|
||||
}
|
||||
|
||||
#syncFromOutfitViewer() {
|
||||
|
|
|
|||
|
|
@ -1,30 +1,31 @@
|
|||
/**
|
||||
* PosePicker web component
|
||||
* PosePickerPopover web component
|
||||
*
|
||||
* Progressive enhancement for pose picker forms:
|
||||
* - Auto-submits the form when a pose is selected (if JS is enabled)
|
||||
* - Shows a submit button as fallback (if JS is disabled or slow to load)
|
||||
* - Uses Custom Element internals API to communicate state to CSS
|
||||
* Scrolls the selected style into view when the style picker list becomes
|
||||
* visible (e.g. tab switch or popover open).
|
||||
*/
|
||||
class PosePickerPopover extends HTMLElement {
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
#styleListObserver;
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
// When the style picker list becomes visible (e.g. tab switch or
|
||||
// popover open), scroll the selected style into view.
|
||||
const styleList = this.querySelector(".style-picker-list");
|
||||
if (styleList) {
|
||||
this.#styleListObserver = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
const checked = styleList.querySelector("input:checked");
|
||||
checked
|
||||
?.closest("label")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
this.#styleListObserver.observe(styleList);
|
||||
}
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
// Only auto-submit if a radio button was changed
|
||||
if (e.target.type === "radio") {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
disconnectedCallback() {
|
||||
this.#styleListObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
/**
|
||||
* SpeciesColorPicker web component
|
||||
*
|
||||
* Progressive enhancement for species/color picker forms:
|
||||
* - Auto-submits the form when species or color changes (if JS is enabled)
|
||||
* - Shows a submit button as fallback (if JS is disabled or slow to load)
|
||||
* - Uses Custom Element internals API to communicate state to CSS
|
||||
*/
|
||||
class SpeciesColorPicker extends HTMLElement {
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
this.querySelector("form").requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
37
app/assets/javascripts/tab-panel.js
Normal file
37
app/assets/javascripts/tab-panel.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* TabPanel web component
|
||||
*
|
||||
* A simple tab switcher. Reads the `active` attribute to determine which tab
|
||||
* is visible. Without JS, both panels are visible (tab buttons hidden via CSS).
|
||||
*/
|
||||
class TabPanel extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.querySelectorAll(".tab-button").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
this.setAttribute("active", button.dataset.tab);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ["active"];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name) {
|
||||
if (name === "active") this.#updateVisibility();
|
||||
}
|
||||
|
||||
#updateVisibility() {
|
||||
const active = this.getAttribute("active");
|
||||
|
||||
this.querySelectorAll(".tab-button").forEach((button) => {
|
||||
button.classList.toggle("active", button.dataset.tab === active);
|
||||
});
|
||||
|
||||
this.querySelectorAll(".tab-content").forEach((content) => {
|
||||
content.hidden = content.dataset.tab !== active;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("tab-panel", TabPanel);
|
||||
70
app/assets/javascripts/wardrobe/item-card.js
Normal file
70
app/assets/javascripts/wardrobe/item-card.js
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* ItemCard web component
|
||||
*
|
||||
* Progressive enhancement for item cards in both outfit and search views.
|
||||
* Replaces baseline Show/Hide/Add buttons with click-to-toggle behavior:
|
||||
*
|
||||
* Outfit view (radio inputs):
|
||||
* - Clicking a closeted item's label selects its radio and submits the Show form
|
||||
* - Clicking a worn item's label submits the Hide form (un-wears it)
|
||||
* - Arrow keys navigate between items via native radio behavior
|
||||
* - Space on a checked radio submits the Hide form (radios don't toggle natively)
|
||||
*
|
||||
* Search view (checkbox inputs):
|
||||
* - Clicking an unworn item's label checks the checkbox and submits the Add form
|
||||
* - Clicking a worn item's label prevents default and submits the Hide form
|
||||
*/
|
||||
class ItemCard extends HTMLElement {
|
||||
connectedCallback() {
|
||||
this.addEventListener("click", this.#handleClick);
|
||||
this.addEventListener("keydown", this.#handleKeydown);
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
}
|
||||
|
||||
#handleClick = (e) => {
|
||||
const label = e.target.closest(".item-card-label");
|
||||
if (!label) return;
|
||||
|
||||
const input = label.querySelector("input[type=radio], input[type=checkbox]");
|
||||
if (!input) return;
|
||||
|
||||
// If this item is worn, un-wear it by submitting the Hide form
|
||||
if (this.dataset.isWorn != null) {
|
||||
e.preventDefault();
|
||||
const hideButton = this.querySelector(".item-hide-button");
|
||||
if (hideButton) hideButton.closest("form").requestSubmit();
|
||||
}
|
||||
// Otherwise, let the default label click proceed—it checks the input,
|
||||
// which fires the `change` event handled below
|
||||
};
|
||||
|
||||
#handleKeydown = (e) => {
|
||||
// Spacebar on an already-checked radio: un-wear the item
|
||||
if (e.key !== " ") return;
|
||||
|
||||
const input = e.target;
|
||||
if (input.type !== "radio" || !input.checked) return;
|
||||
if (this.dataset.isWorn == null) return;
|
||||
|
||||
e.preventDefault();
|
||||
const hideButton = this.querySelector(".item-hide-button");
|
||||
if (hideButton) hideButton.closest("form").requestSubmit();
|
||||
};
|
||||
|
||||
#handleChange = (e) => {
|
||||
const input = e.target;
|
||||
if (!input.checked) return;
|
||||
if (input.type !== "radio" && input.type !== "checkbox") return;
|
||||
|
||||
// Submit the Show form to wear this item, or the Add form if not closeted
|
||||
const showButton = this.querySelector(".item-show-button");
|
||||
if (showButton) {
|
||||
showButton.closest("form").requestSubmit();
|
||||
} else {
|
||||
const addButton = this.querySelector(".item-add-button");
|
||||
if (addButton) addButton.closest("form").requestSubmit();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
customElements.define("item-card", ItemCard);
|
||||
61
app/assets/javascripts/wardrobe/item-search-keys.js
Normal file
61
app/assets/javascripts/wardrobe/item-search-keys.js
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
/**
|
||||
* Keyboard shortcuts for item search.
|
||||
*
|
||||
* - Up/Down arrows move focus between the search field and the item list,
|
||||
* so keyboard users can quickly browse results without tabbing.
|
||||
* - Escape exits search mode (clicks the back button).
|
||||
*/
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape") {
|
||||
const backButton = document.querySelector(
|
||||
".item-search-form .back-button",
|
||||
);
|
||||
if (!backButton) return;
|
||||
|
||||
// Only act when focus is on the search input or a search result.
|
||||
const section = document.querySelector(".outfit-controls-section");
|
||||
const searchInput = section?.querySelector(
|
||||
'.search-form input[type="text"]',
|
||||
);
|
||||
const isSearchFocused =
|
||||
document.activeElement === searchInput ||
|
||||
document.activeElement?.closest(".search-results-list") != null;
|
||||
if (!isSearchFocused) return;
|
||||
|
||||
e.preventDefault();
|
||||
backButton.click();
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
|
||||
|
||||
const section = document.querySelector(".outfit-controls-section");
|
||||
if (!section) return;
|
||||
|
||||
const searchInput = section.querySelector('.search-form input[type="text"]');
|
||||
if (!searchInput) return;
|
||||
|
||||
// Collect all focusable item inputs in the results list.
|
||||
const itemInputs = [
|
||||
...section.querySelectorAll(
|
||||
'.search-results-list item-card input[type="checkbox"]',
|
||||
),
|
||||
];
|
||||
if (itemInputs.length === 0) return;
|
||||
|
||||
const allTargets = [searchInput, ...itemInputs];
|
||||
const currentIndex = allTargets.indexOf(document.activeElement);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
let nextIndex;
|
||||
if (e.key === "ArrowDown") {
|
||||
nextIndex = Math.min(currentIndex + 1, allTargets.length - 1);
|
||||
} else {
|
||||
nextIndex = Math.max(currentIndex - 1, 0);
|
||||
}
|
||||
|
||||
if (nextIndex !== currentIndex) {
|
||||
e.preventDefault();
|
||||
allTargets[nextIndex].focus();
|
||||
}
|
||||
});
|
||||
|
|
@ -1,6 +1,36 @@
|
|||
// Wardrobe v2 - Simple Rails+Turbo outfit editor
|
||||
//
|
||||
// This page uses Turbo Frames for instant updates when changing species/color.
|
||||
// This page uses Turbo for instant updates when changing species/color.
|
||||
// The outfit_viewer Web Component handles the pet rendering.
|
||||
|
||||
console.log("Wardrobe v2 loaded!");
|
||||
// Unsaved changes warning: use a MutationObserver to watch the
|
||||
// data-has-unsaved-changes attribute on the wardrobe container. This is more
|
||||
// robust than event listeners because it works regardless of how the DOM is
|
||||
// updated (Turbo morph, direct manipulation, etc.).
|
||||
function setupUnsavedChangesObserver() {
|
||||
const container = document.querySelector("[data-has-unsaved-changes]");
|
||||
if (!container) return;
|
||||
|
||||
function update() {
|
||||
if (container.dataset.hasUnsavedChanges === "true") {
|
||||
window.onbeforeunload = (e) => {
|
||||
e.preventDefault();
|
||||
return "";
|
||||
};
|
||||
} else {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
update();
|
||||
|
||||
// Watch for attribute changes
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-has-unsaved-changes"],
|
||||
});
|
||||
}
|
||||
|
||||
setupUnsavedChangesObserver();
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ outfit-viewer
|
|||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
|
@ -130,7 +130,7 @@ species-color-picker
|
|||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
auto-submit-form:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
|
|
@ -296,7 +296,7 @@ species-face-picker
|
|||
width: 380px
|
||||
height: 380px
|
||||
|
||||
species-color-picker
|
||||
.species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
|
|
|
|||
|
|
@ -3,6 +3,14 @@
|
|||
body.users-top_contributors
|
||||
text-align: center
|
||||
|
||||
.timeframe-nav
|
||||
margin: 1em 0
|
||||
display: flex
|
||||
justify-content: center
|
||||
gap: 1em
|
||||
list-style: none
|
||||
padding: 0
|
||||
|
||||
#top-contributors
|
||||
border:
|
||||
spacing: 0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
@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"],
|
||||
|
|
@ -10,19 +15,19 @@ select,
|
|||
border-radius: 0.375rem;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
color: #448844;
|
||||
color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f9f9f9;
|
||||
border-color: #448844;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #448844;
|
||||
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-muted);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -55,12 +60,12 @@ select,
|
|||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
border: none;
|
||||
background: #448844;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
|
||||
&:hover {
|
||||
background: #357535;
|
||||
background: var(--color-primary-hover);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
|
|
@ -74,7 +79,9 @@ select,
|
|||
|
||||
/* Icon button pattern - small action buttons with hover reveals */
|
||||
.item-remove-button,
|
||||
.item-add-button {
|
||||
.item-add-button,
|
||||
.item-hide-button,
|
||||
.item-show-button {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
right: 0.5rem;
|
||||
|
|
@ -103,7 +110,7 @@ select,
|
|||
|
||||
&:focus {
|
||||
opacity: 1;
|
||||
outline: 2px solid #448844;
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
|
@ -124,6 +131,140 @@ select,
|
|||
}
|
||||
}
|
||||
|
||||
.item-hide-button {
|
||||
right: 2.5rem;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.item-show-button {
|
||||
right: 2.5rem;
|
||||
background: rgba(68, 136, 68, 0.9);
|
||||
|
||||
&:hover {
|
||||
background: rgba(68, 136, 68, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Item card - shared layout for worn items and search results */
|
||||
item-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: #f9f9f9;
|
||||
margin-bottom: 0.5rem;
|
||||
border-radius: 8px;
|
||||
color: #333;
|
||||
transition: background 0.2s, box-shadow 0.2s;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: #f0f0f0;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&:hover :is(.item-add-button, .item-remove-button, .item-hide-button, .item-show-button) {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.item-thumbnail {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: white;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.item-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
font-weight: 500;
|
||||
color: #2D3748;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.item-badges {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Worn item emphasis */
|
||||
item-card[data-is-worn] {
|
||||
background: #eef5ee;
|
||||
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
|
||||
|
||||
.item-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* Closeted item de-emphasis */
|
||||
item-card[data-is-closeted] {
|
||||
background: #f5f5f5;
|
||||
border: 1px dashed #ccc;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Visually hidden inputs (radio/checkbox) - accessible but not visible */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* Item card label - click target wrapping thumbnail + info */
|
||||
.item-card-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
cursor: pointer;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* When item-card is defined (JS loaded), hide Show/Hide/Add buttons
|
||||
and make the label the primary interaction. Keep Remove visible. */
|
||||
item-card:defined .item-show-button,
|
||||
item-card:defined .item-hide-button,
|
||||
item-card:defined .item-add-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Focus ring on item card when input is focused (keyboard navigation) */
|
||||
item-card:defined:has(input:focus-visible) {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Pagination links - treated as buttons for consistency */
|
||||
.pagination {
|
||||
a,
|
||||
|
|
@ -134,26 +275,26 @@ select,
|
|||
border-radius: 4px;
|
||||
border: 1px solid #ddd;
|
||||
background: white;
|
||||
color: #448844;
|
||||
color: var(--color-primary);
|
||||
text-decoration: none;
|
||||
transition: all 0.2s;
|
||||
|
||||
&:hover {
|
||||
background: #f9f9f9;
|
||||
border-color: #448844;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.current,
|
||||
em {
|
||||
background: #448844;
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: #448844;
|
||||
border-color: var(--color-primary);
|
||||
font-style: normal;
|
||||
|
||||
&:hover {
|
||||
background: #448844;
|
||||
border-color: #448844;
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -169,7 +310,31 @@ select,
|
|||
}
|
||||
}
|
||||
|
||||
/* Progressive enhancement: submit buttons hidden when JS auto-submits */
|
||||
@media (scripting: enabled) {
|
||||
.progressive-submit {
|
||||
opacity: 0;
|
||||
animation: fade-in 0.25s forwards;
|
||||
animation-delay: 0.75s;
|
||||
}
|
||||
}
|
||||
|
||||
auto-submit-form:state(auto-loading) .progressive-submit {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Page Layout
|
||||
Top-level grid: preview on left/top, controls on right/bottom.
|
||||
=================================================================== */
|
||||
|
||||
body.wardrobe-v2 {
|
||||
--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;
|
||||
|
|
@ -180,7 +345,6 @@ body.wardrobe-v2 {
|
|||
.wardrobe-container {
|
||||
display: grid;
|
||||
height: 100vh;
|
||||
background: #000;
|
||||
|
||||
/* Mobile: vertical stack with preview on top, controls below */
|
||||
grid-template-areas:
|
||||
|
|
@ -197,12 +361,17 @@ 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: #000;
|
||||
background: rgb(23, 25, 35);
|
||||
position: relative;
|
||||
container-type: size;
|
||||
|
||||
|
|
@ -292,7 +461,62 @@ body.wardrobe-v2 {
|
|||
display: none;
|
||||
}
|
||||
|
||||
/* Pose picker button */
|
||||
/* Species/color picker */
|
||||
.species-color-picker {
|
||||
display: contents;
|
||||
|
||||
auto-submit-form,
|
||||
form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
|
||||
option {
|
||||
background: #2D3748;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="submit"] {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
@media (scripting: enabled) {
|
||||
input[type="submit"] {
|
||||
position: absolute;
|
||||
margin-left: 0.5em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Show controls on hover (real hover only, not simulated touch hover) */
|
||||
@media (hover: hover) {
|
||||
&:hover .preview-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show controls when they have focus or when popover is open */
|
||||
&:has(.preview-controls:focus-within) .preview-controls,
|
||||
&:has(.pose-picker-button[popovertargetopen]) .preview-controls {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Pose Picker Popover
|
||||
Floating panel for choosing expression/pose and alt styles.
|
||||
=================================================================== */
|
||||
|
||||
.pose-picker-button {
|
||||
anchor-name: --pose-picker-anchor;
|
||||
display: flex;
|
||||
|
|
@ -321,7 +545,6 @@ body.wardrobe-v2 {
|
|||
}
|
||||
}
|
||||
|
||||
/* Pose picker popover */
|
||||
pose-picker-popover {
|
||||
position: absolute;
|
||||
position-anchor: --pose-picker-anchor;
|
||||
|
|
@ -335,6 +558,7 @@ body.wardrobe-v2 {
|
|||
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;
|
||||
|
|
@ -363,19 +587,21 @@ body.wardrobe-v2 {
|
|||
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;
|
||||
|
||||
input[type="radio"] {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
margin: 0 auto;
|
||||
|
||||
.pose-thumbnail {
|
||||
width: 60px;
|
||||
|
|
@ -412,8 +638,8 @@ body.wardrobe-v2 {
|
|||
|
||||
/* Selected state */
|
||||
input[type="radio"]:checked + .pose-thumbnail {
|
||||
border-color: #48BB78;
|
||||
box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.4);
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 3px var(--color-accent-glow);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
|
|
@ -440,92 +666,131 @@ body.wardrobe-v2 {
|
|||
}
|
||||
}
|
||||
|
||||
/* Submit button: progressive enhancement pattern */
|
||||
.pose-submit-button {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* Tab panel layout */
|
||||
.tab-list {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Once auto-submit is enabled, hide the submit button completely */
|
||||
&:state(auto-loading) .pose-submit-button {
|
||||
display: none;
|
||||
}
|
||||
.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);
|
||||
}
|
||||
|
||||
/* Species/color picker */
|
||||
species-color-picker {
|
||||
display: contents;
|
||||
|
||||
form {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||
font-size: 1rem;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||
background-position: right 0.5rem center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 1.5em 1.5em;
|
||||
|
||||
option {
|
||||
background: #2D3748;
|
||||
&.active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: white;
|
||||
border-color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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"] {
|
||||
/* Without JS, hide tab buttons and show both panels stacked */
|
||||
tab-panel:not(:defined) .tab-list {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tab-panel:not(:defined) .tab-content[hidden] {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
/* Style picker form */
|
||||
.style-picker-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.style-picker-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.style-option {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
|
||||
.style-option-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.style-option-thumbnail {
|
||||
flex-shrink: 0;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
/* Show controls on hover (real hover only, not simulated touch hover) */
|
||||
@media (hover: hover) {
|
||||
&:hover .preview-controls {
|
||||
opacity: 1;
|
||||
.style-option-name {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/* 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;
|
||||
.style-submit-button {
|
||||
margin-top: 1rem;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
Outfit Controls
|
||||
Right/bottom panel: worn items, search, and results.
|
||||
=================================================================== */
|
||||
|
||||
.outfit-controls-section {
|
||||
grid-area: controls;
|
||||
background: #fff;
|
||||
|
|
@ -534,15 +799,9 @@ body.wardrobe-v2 {
|
|||
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: #448844;
|
||||
color: var(--color-primary);
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
|
|
@ -569,7 +828,7 @@ body.wardrobe-v2 {
|
|||
}
|
||||
}
|
||||
|
||||
.worn-items {
|
||||
.outfit-items {
|
||||
margin-top: 2rem;
|
||||
|
||||
.items-list {
|
||||
|
|
@ -577,70 +836,6 @@ body.wardrobe-v2 {
|
|||
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 {
|
||||
|
|
@ -664,12 +859,10 @@ body.wardrobe-v2 {
|
|||
|
||||
&:focus {
|
||||
outline: none;
|
||||
border-color: #448844;
|
||||
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px var(--color-primary-muted);
|
||||
}
|
||||
}
|
||||
|
||||
/* input[type="submit"] styles are defined in button system above */
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -678,69 +871,6 @@ body.wardrobe-v2 {
|
|||
list-style: none;
|
||||
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 {
|
||||
|
|
@ -754,11 +884,187 @@ body.wardrobe-v2 {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
|
|||
@outfit.user = current_user
|
||||
|
||||
if @outfit.save
|
||||
render :json => @outfit
|
||||
respond_to do |format|
|
||||
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
|
||||
format.json { render json: @outfit }
|
||||
end
|
||||
else
|
||||
render_outfit_errors
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back fallback_location: wardrobe_v2_path,
|
||||
alert: @outfit.errors.full_messages.join(", ")
|
||||
end
|
||||
format.json { render_outfit_errors }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -123,9 +132,25 @@ class OutfitsController < ApplicationController
|
|||
|
||||
def update
|
||||
if @outfit.update(outfit_params)
|
||||
render :json => @outfit
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
return_to = params[:return_to]
|
||||
if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//")
|
||||
redirect_to return_to
|
||||
else
|
||||
render_outfit_errors
|
||||
redirect_to wardrobe_v2_outfit_path(@outfit)
|
||||
end
|
||||
end
|
||||
format.json { render json: @outfit }
|
||||
end
|
||||
else
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back fallback_location: wardrobe_v2_outfit_path(@outfit),
|
||||
alert: @outfit.errors.full_messages.join(", ")
|
||||
end
|
||||
format.json { render_outfit_errors }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -34,9 +34,10 @@ class PetsController < ApplicationController
|
|||
end
|
||||
|
||||
def destination
|
||||
case (params[:destination] || params[:origin])
|
||||
when 'wardrobe' then wardrobe_path
|
||||
else root_path
|
||||
if request.get?
|
||||
wardrobe_path
|
||||
else
|
||||
root_path
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,10 @@ class UsersController < ApplicationController
|
|||
end
|
||||
|
||||
def top_contributors
|
||||
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
|
||||
valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
|
||||
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
|
||||
@users = User.top_contributors_for(@timeframe.to_sym)
|
||||
.paginate(page: params[:page], per_page: 20)
|
||||
end
|
||||
|
||||
def edit
|
||||
|
|
|
|||
|
|
@ -1,5 +1,21 @@
|
|||
class WardrobeController < ApplicationController
|
||||
prepend_view_path Rails.root.join("app/views/wardrobe")
|
||||
|
||||
def show
|
||||
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
|
||||
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
|
||||
|
||||
# If visiting a saved outfit with no state params, redirect with the
|
||||
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
|
||||
# the rest of the action always reads from params.
|
||||
if @saved_outfit && !outfit_state_params_present?
|
||||
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
|
||||
return
|
||||
end
|
||||
|
||||
# Set the form target path for all wardrobe forms
|
||||
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
|
||||
|
||||
# Get selected species and color from params, or default to Blue Acara
|
||||
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
|
||||
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
|
||||
|
|
@ -41,20 +57,45 @@ class WardrobeController < ApplicationController
|
|||
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
||||
end
|
||||
|
||||
# Load items from the objects[] parameter
|
||||
item_ids = params[:objects] || []
|
||||
items = Item.where(id: item_ids)
|
||||
# Load alt style from params, scoped to the current species
|
||||
@alt_style = if params[:style].present? && @selected_species
|
||||
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
|
||||
end
|
||||
|
||||
# Load all available alt styles for this species (for the style picker)
|
||||
@available_alt_styles = @selected_species ?
|
||||
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
|
||||
|
||||
# Load items from the objects[] and closet[] parameters
|
||||
worn_item_ids = params[:objects] || []
|
||||
closeted_item_ids = params[:closet] || []
|
||||
worn_items = Item.where(id: worn_item_ids)
|
||||
closeted_items = Item.where(id: closeted_item_ids)
|
||||
|
||||
# Build the outfit
|
||||
@outfit = Outfit.new(
|
||||
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
|
||||
pet_state: @pet_state,
|
||||
worn_items: items,
|
||||
alt_style: @alt_style,
|
||||
worn_items: worn_items,
|
||||
closeted_items: closeted_items,
|
||||
)
|
||||
|
||||
# Preload the manifests for all visible layers, so they load efficiently
|
||||
# in parallel rather than sequentially when rendering
|
||||
SwfAsset.preload_manifests(@outfit.visible_layers)
|
||||
|
||||
# Also preload alt style layer manifests for the style picker thumbnails
|
||||
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
|
||||
|
||||
# Compute saved outfit state for the view
|
||||
if @saved_outfit
|
||||
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
|
||||
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
|
||||
else
|
||||
@has_unsaved_changes = false
|
||||
end
|
||||
|
||||
# Handle search mode
|
||||
@search_mode = params[:q].present?
|
||||
if @search_mode
|
||||
|
|
@ -95,6 +136,10 @@ class WardrobeController < ApplicationController
|
|||
poses_hash
|
||||
end
|
||||
|
||||
def outfit_state_params_present?
|
||||
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
|
||||
end
|
||||
|
||||
def build_search_filters(query_params, outfit)
|
||||
filters = []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,4 @@
|
|||
module OutfitsHelper
|
||||
def destination_tag(value)
|
||||
hidden_field_tag 'destination', value, :id => nil
|
||||
end
|
||||
|
||||
def latest_contribution_description(contribution)
|
||||
user = contribution.user
|
||||
contributed = contribution.contributed
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ module WardrobeHelper
|
|||
def outfit_state_params(outfit = @outfit, except: [])
|
||||
fields = []
|
||||
|
||||
fields << hidden_field_tag(:name, @outfit.name) if !@saved_outfit && @outfit.name.present? && !except.include?(:name)
|
||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
||||
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
|
||||
|
||||
unless except.include?(:worn_items)
|
||||
outfit.worn_items.each do |item|
|
||||
|
|
@ -15,6 +17,12 @@ module WardrobeHelper
|
|||
end
|
||||
end
|
||||
|
||||
unless except.include?(:closeted_items)
|
||||
outfit.closeted_items.each do |item|
|
||||
fields << hidden_field_tag('closet[]', item.id)
|
||||
end
|
||||
end
|
||||
|
||||
unless except.include?(:q)
|
||||
(params[:q] || {}).each do |key, value|
|
||||
fields << hidden_field_tag("q[#{key}]", value) if value.present?
|
||||
|
|
@ -24,8 +32,12 @@ module WardrobeHelper
|
|||
safe_join fields
|
||||
end
|
||||
|
||||
# Get the emoji and label for a pose, for display in the pose picker button
|
||||
def pose_emoji_and_label(pose)
|
||||
# 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
|
||||
case pose
|
||||
when "HAPPY_MASC", "HAPPY_FEM"
|
||||
{ emoji: "😀", label: "Happy" }
|
||||
|
|
@ -37,28 +49,31 @@ module WardrobeHelper
|
|||
{ emoji: "😀", label: "Default" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Group outfit items by zone, applying smart multi-zone simplification.
|
||||
# Returns an array of hashes: {zone:, items:}
|
||||
# Returns an array of hashes: {zone_id:, zone_label:, items: [Item, ...]}
|
||||
# This matches the logic from wardrobe-2020's getZonesAndItems function.
|
||||
def outfit_items_by_zone(outfit)
|
||||
return [] if outfit.pet_type.nil?
|
||||
|
||||
# Get item appearances for this outfit
|
||||
all_items = outfit.worn_items + outfit.closeted_items
|
||||
|
||||
# Get item appearances for all items at once
|
||||
item_appearances = Item.appearances_for(
|
||||
outfit.worn_items,
|
||||
all_items,
|
||||
outfit.pet_type,
|
||||
swf_asset_includes: [:zone]
|
||||
)
|
||||
|
||||
# Separate incompatible items (no layers for this pet)
|
||||
compatible_items = []
|
||||
# Separate compatible and incompatible items
|
||||
compatible = {}
|
||||
incompatible_items = []
|
||||
|
||||
outfit.worn_items.each do |item|
|
||||
all_items.each do |item|
|
||||
appearance = item_appearances[item.id]
|
||||
if appearance&.present?
|
||||
compatible_items << {item: item, appearance: appearance}
|
||||
compatible[item] = appearance
|
||||
else
|
||||
incompatible_items << item
|
||||
end
|
||||
|
|
@ -68,11 +83,7 @@ module WardrobeHelper
|
|||
items_by_zone = Hash.new { |h, k| h[k] = [] }
|
||||
zones_by_id = {}
|
||||
|
||||
compatible_items.each do |item_with_appearance|
|
||||
item = item_with_appearance[:item]
|
||||
appearance = item_with_appearance[:appearance]
|
||||
|
||||
# Get unique zones for this item (an item may have multiple assets per zone)
|
||||
compatible.each do |item, appearance|
|
||||
appearance.swf_assets.map(&:zone).uniq.each do |zone|
|
||||
zones_by_id[zone.id] = zone
|
||||
items_by_zone[zone.id] << item
|
||||
|
|
@ -138,8 +149,7 @@ module WardrobeHelper
|
|||
# For single-item groups, only keep if:
|
||||
# - Item hasn't been seen yet AND
|
||||
# - Item won't appear in a conflict group
|
||||
item = group[:items].first
|
||||
item_id = item.id
|
||||
item_id = group[:items].first.id
|
||||
|
||||
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
|
||||
false
|
||||
|
|
|
|||
|
|
@ -261,17 +261,24 @@ class Outfit < ApplicationRecord
|
|||
(biology_layers + item_layers).sort_by(&:depth)
|
||||
end
|
||||
|
||||
def same_wardrobe_state_as?(other)
|
||||
# Exclude :name because it's managed separately via atomic rename, not URL
|
||||
# state. This also works around the @outfit (new) vs @saved_outfit
|
||||
# (persisted) split in WardrobeController, where only the unpersisted
|
||||
# outfit includes :name. We should consider keeping their names in sync.
|
||||
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
|
||||
end
|
||||
|
||||
def wardrobe_params
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
pose: pose,
|
||||
state: pet_state_id,
|
||||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
objects: worn_item_ids.sort,
|
||||
closet: closeted_item_ids.sort,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params[:name] = name if !persisted? && name.present?
|
||||
params
|
||||
end
|
||||
|
||||
|
|
@ -311,9 +318,23 @@ class Outfit < ApplicationRecord
|
|||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit, but *not* wearing the given item.
|
||||
# Create a copy of this outfit without the given item at all
|
||||
# (removed from both worn and closeted).
|
||||
def without_item(item)
|
||||
dup.tap { |o| o.worn_items.delete(item) }
|
||||
dup.tap do |o|
|
||||
o.worn_items.delete(item)
|
||||
o.closeted_items.delete(item)
|
||||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit with the given item moved from worn to
|
||||
# closeted. If it's not currently worn, returns the outfit unchanged.
|
||||
def hide_item(item)
|
||||
dup.tap do |o|
|
||||
next unless o.worn_item_ids.include?(item.id)
|
||||
o.worn_items.delete(item)
|
||||
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit, additionally wearing the given item.
|
||||
|
|
@ -323,6 +344,9 @@ class Outfit < ApplicationRecord
|
|||
# Skip if item is nil, already worn, or outfit has no pet_state
|
||||
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
|
||||
|
||||
# If the item was closeted, remove it from closet (it's moving to worn)
|
||||
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
|
||||
|
||||
# Load appearances for the new item and all currently worn items
|
||||
all_items = o.worn_items + [item]
|
||||
appearances = Item.appearances_for(all_items, o.pet_type,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,51 @@ class User < ApplicationRecord
|
|||
|
||||
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
||||
|
||||
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
|
||||
|
||||
scope :top_contributors_for, ->(timeframe = :all_time) {
|
||||
case timeframe.to_sym
|
||||
when :all_time
|
||||
top_contributors # Use existing efficient scope
|
||||
else
|
||||
top_contributors_by_period(timeframe)
|
||||
end
|
||||
}
|
||||
|
||||
def self.top_contributors_by_period(timeframe)
|
||||
start_date = case timeframe.to_sym
|
||||
when :this_week then 1.week.ago
|
||||
when :this_month then 1.month.ago
|
||||
when :this_year then 1.year.ago
|
||||
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
|
||||
end
|
||||
|
||||
# Build the CASE statement dynamically from Contribution::POINT_VALUES
|
||||
point_case = Contribution::POINT_VALUES.map { |type, points|
|
||||
"WHEN #{connection.quote(type)} THEN #{points}"
|
||||
}.join("\n ")
|
||||
|
||||
select(
|
||||
'users.*',
|
||||
"COALESCE(SUM(
|
||||
CASE contributions.contributed_type
|
||||
#{point_case}
|
||||
END
|
||||
), 0) AS period_points"
|
||||
)
|
||||
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
|
||||
.where('contributions.created_at >= ?', start_date)
|
||||
.group('users.id')
|
||||
.having('period_points > 0')
|
||||
.order('period_points DESC, users.id ASC')
|
||||
end
|
||||
|
||||
# Virtual attribute reader for dynamically calculated points (from time-period queries).
|
||||
# Falls back to the denormalized `points` column when not calculated.
|
||||
def period_points
|
||||
attributes['period_points'] || points
|
||||
end
|
||||
|
||||
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
||||
after_update :log_trade_activity, if: -> user {
|
||||
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
- html_options = {} unless defined? html_options
|
||||
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
|
||||
- viewer_id = html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
|
||||
= content_tag "outfit-viewer", **html_options do
|
||||
.loading-indicator= render partial: "hanger_spinner"
|
||||
|
||||
- outfit.visible_layers.each do |swf_asset|
|
||||
%outfit-layer{
|
||||
id: "#{viewer_id}-layer-#{swf_asset.id}",
|
||||
data: {
|
||||
"asset-id": swf_asset.id,
|
||||
"zone": swf_asset.zone.label,
|
||||
|
|
|
|||
|
|
@ -33,7 +33,8 @@
|
|||
Customize more
|
||||
= edit_icon
|
||||
|
||||
%species-color-picker
|
||||
.species-color-picker
|
||||
%auto-submit-form
|
||||
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
||||
- if @preview_error == :pet_type_does_not_exist
|
||||
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
||||
|
|
@ -138,5 +139,5 @@
|
|||
- content_for :javascripts do
|
||||
= javascript_include_tag "idiomorph", async: true
|
||||
= javascript_include_tag "outfit-viewer", async: true
|
||||
= javascript_include_tag "species-color-picker", async: true
|
||||
= javascript_include_tag "auto-submit-form", async: true
|
||||
= javascript_include_tag "items/show", async: true
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@
|
|||
%h1= t 'app_name'
|
||||
%h2= t '.tagline'
|
||||
|
||||
= form_tag load_pet_path, method: 'POST', class: 'primary load-pet-to-wardrobe' do
|
||||
= hidden_field_tag 'destination', 'wardrobe'
|
||||
= form_tag load_pet_path, method: 'GET', class: 'primary load-pet-to-wardrobe' do
|
||||
%fieldset
|
||||
%legend= t '.load_pet'
|
||||
= pet_name_tag class: 'main-pet-name'
|
||||
|
|
|
|||
|
|
@ -1,4 +1,13 @@
|
|||
- title t('.title')
|
||||
|
||||
%ul.timeframe-nav
|
||||
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
|
||||
%li
|
||||
- if @timeframe == tf
|
||||
%strong= t(".timeframes.#{tf}")
|
||||
- else
|
||||
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
|
||||
|
||||
= will_paginate @users
|
||||
%table#top-contributors
|
||||
%thead
|
||||
|
|
@ -11,5 +20,5 @@
|
|||
%tr
|
||||
%th{:scope => 'row'}= @users.offset + rank + 1
|
||||
%td= link_to user.name, user_contributions_path(user)
|
||||
%td= user.points
|
||||
%td= user.period_points
|
||||
= will_paginate @users
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
- is_worn = @outfit.worn_items.include?(item)
|
||||
%li.item-card
|
||||
.item-thumbnail
|
||||
= image_tag item.thumbnail_url, alt: item.name, loading: "lazy"
|
||||
.item-info
|
||||
.item-name= item.name
|
||||
.item-badges
|
||||
= render "items/badges/kind", item: item
|
||||
= render "items/badges/first_seen", item: item
|
||||
- if is_worn
|
||||
= button_to wardrobe_v2_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||
❌
|
||||
= outfit_state_params @outfit.without_item(item)
|
||||
- else
|
||||
= button_to wardrobe_v2_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
||||
➕
|
||||
= outfit_state_params @outfit.with_item(item)
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
- pose_info = pose_emoji_and_label(@selected_pose)
|
||||
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
||||
%span.pose-emoji= pose_info[:emoji]
|
||||
%span.pose-label= pose_info[:label]
|
||||
%span.chevron ▾
|
||||
|
||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||
= form_with url: wardrobe_v2_path, method: :get, class: "pose-picker-form" do |f|
|
||||
= outfit_state_params except: [:pose]
|
||||
%table.pose-picker-table
|
||||
%thead
|
||||
%tr
|
||||
%th
|
||||
%th
|
||||
%span.emoji-icon{title: "Happy"} 😀
|
||||
%th
|
||||
%span.emoji-icon{title: "Sad"} 😢
|
||||
%th
|
||||
%span.emoji-icon{title: "Sick"} 🤢
|
||||
%tbody
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Masculine"} 💁♂️
|
||||
%td
|
||||
= render "pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: @selected_pose == "HAPPY_MASC"
|
||||
%td
|
||||
= render "pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: @selected_pose == "SAD_MASC"
|
||||
%td
|
||||
= render "pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: @selected_pose == "SICK_MASC"
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Feminine"} 💁♀️
|
||||
%td
|
||||
= render "pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: @selected_pose == "HAPPY_FEM"
|
||||
%td
|
||||
= render "pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: @selected_pose == "SAD_FEM"
|
||||
%td
|
||||
= render "pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: @selected_pose == "SICK_FEM"
|
||||
= submit_tag "Change pose", name: nil, class: "pose-submit-button"
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
%species-color-picker
|
||||
= form_with url: wardrobe_v2_path, method: :get do |f|
|
||||
= outfit_state_params except: [:color, :species]
|
||||
= select_tag :color,
|
||||
options_from_collection_for_select(@colors, "id", "human_name",
|
||||
@selected_color&.id),
|
||||
"aria-label": "Pet color"
|
||||
= select_tag :species,
|
||||
options_from_collection_for_select(@species, "id", "human_name",
|
||||
@selected_species&.id),
|
||||
"aria-label": "Pet species"
|
||||
= submit_tag "Go", name: nil
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
- if is_available
|
||||
-# Create a minimal outfit with just this pet state for the thumbnail
|
||||
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
|
||||
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer"
|
||||
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer",
|
||||
id: "pose-thumbnail-viewer-#{pet_state.id}"
|
||||
- else
|
||||
.pose-unavailable
|
||||
%span.question-mark{title: "Not available"} ❓
|
||||
22
app/views/wardrobe/appearance/_pose_picker.html.haml
Normal file
22
app/views/wardrobe/appearance/_pose_picker.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
- pose_info = pose_emoji_and_label(@selected_pose, alt_style: @alt_style)
|
||||
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
||||
%span.pose-emoji= pose_info[:emoji]
|
||||
%span.pose-label= pose_info[:label]
|
||||
%span.chevron ▾
|
||||
|
||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||
- active_tab = @alt_style ? "styles" : "expressions"
|
||||
%tab-panel{active: active_tab}
|
||||
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
|
||||
%auto-submit-form
|
||||
= render "appearance/pose_picker_form"
|
||||
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
|
||||
%auto-submit-form
|
||||
= render "appearance/style_picker_form"
|
||||
.tab-list
|
||||
%button.tab-button{"data-tab": "expressions", type: "button",
|
||||
class: ("active" if active_tab == "expressions")}
|
||||
Expressions
|
||||
%button.tab-button{"data-tab": "styles", type: "button",
|
||||
class: ("active" if active_tab == "styles")}
|
||||
Styles
|
||||
32
app/views/wardrobe/appearance/_pose_picker_form.html.haml
Normal file
32
app/views/wardrobe/appearance/_pose_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
= form_with url: @wardrobe_path, method: :get, class: "pose-picker-form" do |f|
|
||||
= outfit_state_params except: [:pose, :style]
|
||||
%table.pose-picker-table
|
||||
%thead
|
||||
%tr
|
||||
%th
|
||||
%th
|
||||
%span.emoji-icon{title: "Happy"} 😀
|
||||
%th
|
||||
%span.emoji-icon{title: "Sad"} 😢
|
||||
%th
|
||||
%span.emoji-icon{title: "Sick"} 🤢
|
||||
%tbody
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Masculine"} 💁♂️
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: !@alt_style && @selected_pose == "HAPPY_MASC"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: !@alt_style && @selected_pose == "SAD_MASC"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: !@alt_style && @selected_pose == "SICK_MASC"
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Feminine"} 💁♀️
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: !@alt_style && @selected_pose == "HAPPY_FEM"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: !@alt_style && @selected_pose == "SAD_FEM"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: !@alt_style && @selected_pose == "SICK_FEM"
|
||||
= submit_tag "Change pose", name: nil, class: "pose-submit-button progressive-submit"
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.species-color-picker
|
||||
%auto-submit-form
|
||||
= form_with url: @wardrobe_path, method: :get do |f|
|
||||
= outfit_state_params except: [:color, :species]
|
||||
= select_tag :color,
|
||||
options_from_collection_for_select(@colors, "id", "human_name",
|
||||
@selected_color&.id),
|
||||
"aria-label": "Pet color"
|
||||
= select_tag :species,
|
||||
options_from_collection_for_select(@species, "id", "human_name",
|
||||
@selected_species&.id),
|
||||
"aria-label": "Pet species"
|
||||
= submit_tag "Go", name: nil, class: "progressive-submit"
|
||||
11
app/views/wardrobe/appearance/_style_picker_form.html.haml
Normal file
11
app/views/wardrobe/appearance/_style_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
= form_with url: @wardrobe_path, method: :get, class: "style-picker-form" do |f|
|
||||
= outfit_state_params except: [:style]
|
||||
.style-picker-list
|
||||
- @available_alt_styles.each do |alt_style|
|
||||
%label.style-option
|
||||
= radio_button_tag :style, alt_style.id, @alt_style&.id == alt_style.id
|
||||
.style-option-content
|
||||
.style-option-thumbnail
|
||||
%img{src: alt_style.thumbnail_url, alt: "", width: 40, height: 40, loading: "lazy"}
|
||||
%span.style-option-name= alt_style.adjective_name
|
||||
= submit_tag "Change style", name: nil, class: "style-submit-button progressive-submit"
|
||||
22
app/views/wardrobe/header/_outfit_rename_field.html.haml
Normal file
22
app/views/wardrobe/header/_outfit_rename_field.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
- if @saved_outfit
|
||||
- form_url = outfit_path(@saved_outfit)
|
||||
- form_method = :patch
|
||||
- field_name = "outfit[name]"
|
||||
- else
|
||||
- form_url = @wardrobe_path
|
||||
- form_method = :get
|
||||
- field_name = :name
|
||||
|
||||
%outfit-rename-field
|
||||
.outfit-rename-static-display
|
||||
%span.outfit-rename-name= @outfit.name.presence || "Untitled outfit"
|
||||
%button.outfit-rename-pencil{type: "button", "aria-label": "Rename outfit"} ✏️
|
||||
= form_with url: form_url, method: form_method, class: "outfit-name-form" do |f|
|
||||
= hidden_field_tag :return_to, request.fullpath
|
||||
- unless @saved_outfit
|
||||
= outfit_state_params except: [:name]
|
||||
= f.text_field field_name, value: @outfit.name,
|
||||
class: "outfit-name-input", placeholder: "Untitled outfit",
|
||||
"aria-label": "Outfit name"
|
||||
= f.submit "Rename", name: nil, class: "outfit-name-submit"
|
||||
|
||||
20
app/views/wardrobe/header/_save_button.html.haml
Normal file
20
app/views/wardrobe/header/_save_button.html.haml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
- if @saved_outfit
|
||||
- if @is_owner
|
||||
- if @has_unsaved_changes
|
||||
= form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save", class: "outfit-save-button"
|
||||
- else
|
||||
%button.outfit-save-button{disabled: true} Saved!
|
||||
- elsif user_signed_in?
|
||||
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save a copy", class: "outfit-save-button"
|
||||
- else
|
||||
= link_to "Log in to save a copy",
|
||||
new_auth_user_session_path(return_to: request.fullpath),
|
||||
class: "outfit-save-button"
|
||||
- elsif user_signed_in?
|
||||
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save", class: "outfit-save-button"
|
||||
10
app/views/wardrobe/header/_save_outfit_fields.html.haml
Normal file
10
app/views/wardrobe/header/_save_outfit_fields.html.haml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
= hidden_field_tag "outfit[name]", @outfit.name
|
||||
= hidden_field_tag "outfit[biology][species_id]", @outfit.species_id
|
||||
= hidden_field_tag "outfit[biology][color_id]", @outfit.color_id
|
||||
= hidden_field_tag "outfit[biology][pose]", @outfit.pet_state.pose
|
||||
- if @alt_style
|
||||
= hidden_field_tag "outfit[alt_style_id]", @alt_style.id
|
||||
- @outfit.worn_items.each do |item|
|
||||
= hidden_field_tag "outfit[item_ids][worn][]", item.id
|
||||
- @outfit.closeted_items.each do |item|
|
||||
= hidden_field_tag "outfit[item_ids][closeted][]", item.id
|
||||
29
app/views/wardrobe/items/_item_card.html.haml
Normal file
29
app/views/wardrobe/items/_item_card.html.haml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
- is_worn = @outfit.worn_items.include?(item)
|
||||
- is_closeted = @outfit.closeted_items.include?(item)
|
||||
%item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}}
|
||||
- if defined?(zone_id) && zone_id
|
||||
%label.item-card-label
|
||||
%input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name}
|
||||
= render "wardrobe/items/item_card_content", item: item
|
||||
- else
|
||||
%label.item-card-label
|
||||
%input.visually-hidden{type: "checkbox", checked: is_worn || nil, "aria-label": item.name}
|
||||
= render "wardrobe/items/item_card_content", item: item
|
||||
- if is_worn
|
||||
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
|
||||
👁️🗨️
|
||||
= outfit_state_params @outfit.hide_item(item)
|
||||
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||
❌
|
||||
= outfit_state_params @outfit.without_item(item)
|
||||
- elsif is_closeted
|
||||
= button_to @wardrobe_path, method: :get, class: "item-show-button", title: "Show #{item.name}", "aria-label": "Show #{item.name}" do
|
||||
👁️
|
||||
= outfit_state_params @outfit.with_item(item)
|
||||
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||
❌
|
||||
= outfit_state_params @outfit.without_item(item)
|
||||
- else
|
||||
= button_to @wardrobe_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
||||
➕
|
||||
= outfit_state_params @outfit.with_item(item)
|
||||
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.item-thumbnail
|
||||
= image_tag item.thumbnail_url, alt: "", loading: "lazy"
|
||||
.item-info
|
||||
.item-name= item.name
|
||||
.item-badges
|
||||
= render "items/badges/kind", item: item
|
||||
= render "items/badges/first_seen", item: item
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
.search-results
|
||||
- if @search_results.any?
|
||||
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
||||
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||
|
||||
%ul.search-results-list
|
||||
- @search_results.each do |item|
|
||||
= render "item_card", item: item
|
||||
= render "items/item_card", item: item
|
||||
|
||||
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
||||
= will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||
|
||||
- else
|
||||
.empty-state
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
- title "Wardrobe v2"
|
||||
- title @outfit.name
|
||||
|
||||
!!! 5
|
||||
%html
|
||||
%head
|
||||
%meta{charset: 'utf-8'}
|
||||
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
|
||||
%title= yield :title
|
||||
%title #{yield :title} | #{t "app_name"}
|
||||
%link{href: image_path('favicon.png'), rel: 'icon'}
|
||||
= stylesheet_link_tag "application/hanger-spinner"
|
||||
= stylesheet_link_tag "application/outfit-viewer"
|
||||
|
|
@ -13,13 +13,20 @@
|
|||
= javascript_include_tag "application", async: true
|
||||
= javascript_include_tag "idiomorph", async: true
|
||||
= javascript_include_tag "outfit-viewer", async: true
|
||||
= javascript_include_tag "species-color-picker", async: true
|
||||
= javascript_include_tag "auto-submit-form", async: true
|
||||
= javascript_include_tag "pose-picker", async: true
|
||||
= javascript_include_tag "tab-panel", async: true
|
||||
= javascript_include_tag "outfit-rename-field", async: true
|
||||
= javascript_include_tag "wardrobe/item-card", async: true
|
||||
= javascript_include_tag "wardrobe/item-search-keys", async: true
|
||||
= javascript_include_tag "wardrobe/show", async: true
|
||||
= csrf_meta_tags
|
||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||
%body.wardrobe-v2
|
||||
.wardrobe-container
|
||||
- if flash[:alert]
|
||||
.flash-messages
|
||||
.flash-alert= flash[:alert]
|
||||
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
|
||||
.outfit-preview-section
|
||||
- if @pet_type.nil?
|
||||
.no-preview-message
|
||||
|
|
@ -27,42 +34,48 @@
|
|||
We haven't seen this kind of pet before! Try a different species/color
|
||||
combination.
|
||||
- else
|
||||
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
|
||||
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer",
|
||||
preferred_image_format: :svg # TODO: Make this a selectable option
|
||||
|
||||
.preview-controls
|
||||
.preview-controls-top
|
||||
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
||||
%label.play-pause-control-button.button
|
||||
%input{type: "checkbox"}
|
||||
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
|
||||
%span.paused-label Paused
|
||||
%span.playing-label Playing
|
||||
|
||||
.preview-controls-bottom
|
||||
= render "species_color_picker"
|
||||
= render "appearance/species_color_picker"
|
||||
|
||||
- if @pet_type
|
||||
= render "pose_picker"
|
||||
= render "appearance/pose_picker"
|
||||
|
||||
.outfit-controls-section
|
||||
.item-search-form
|
||||
- if @search_mode
|
||||
= button_to wardrobe_v2_path, method: :get, class: "back-button" do
|
||||
= button_to @wardrobe_path, method: :get, class: "back-button" do
|
||||
←
|
||||
= outfit_state_params except: [:q]
|
||||
= form_with url: wardrobe_v2_path, method: :get, class: "search-form" do |f|
|
||||
= form_with url: @wardrobe_path, method: :get, class: "search-form" do |f|
|
||||
= outfit_state_params
|
||||
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
|
||||
= f.submit "Search"
|
||||
|
||||
- if @search_mode
|
||||
= render "search_results"
|
||||
= render "items/search_results"
|
||||
- else
|
||||
%h1 Untitled outfit
|
||||
- if @outfit.worn_items.any?
|
||||
.worn-items
|
||||
.outfit-header
|
||||
- if @saved_outfit && !@is_owner
|
||||
.outfit-name-static= @outfit.name
|
||||
- else
|
||||
= render "header/outfit_rename_field"
|
||||
= render "header/save_button"
|
||||
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
|
||||
.outfit-items
|
||||
- outfit_items_by_zone(@outfit).each do |zone_group|
|
||||
.zone-group
|
||||
%h3.zone-label= zone_group[:zone_label]
|
||||
%ul.items-list
|
||||
- zone_group[:items].each do |item|
|
||||
= render "item_card", item: item
|
||||
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
|
||||
|
|
|
|||
|
|
@ -640,6 +640,11 @@ en-MEEP:
|
|||
rank: Reep
|
||||
user: Meepit
|
||||
points: Peeps
|
||||
timeframes:
|
||||
all_time: All Meep
|
||||
this_year: Meeps Year
|
||||
this_month: Meeps Month
|
||||
this_week: Meeps Week
|
||||
|
||||
update:
|
||||
success: Settings successfully meeped.
|
||||
|
|
|
|||
|
|
@ -783,6 +783,11 @@ en:
|
|||
rank: Rank
|
||||
user: User
|
||||
points: Points
|
||||
timeframes:
|
||||
all_time: All Time
|
||||
this_year: This Year
|
||||
this_month: This Month
|
||||
this_week: This Week
|
||||
|
||||
update:
|
||||
success: Settings successfully saved.
|
||||
|
|
|
|||
|
|
@ -505,6 +505,11 @@ es:
|
|||
rank: Puesto
|
||||
user: Usuario
|
||||
points: Puntos
|
||||
timeframes:
|
||||
all_time: Todo el Tiempo
|
||||
this_year: Este Año
|
||||
this_month: Este Mes
|
||||
this_week: Esta Semana
|
||||
update:
|
||||
success: Ajustes guardados correctamente.
|
||||
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
||||
|
|
|
|||
|
|
@ -499,6 +499,11 @@ pt:
|
|||
rank: Rank
|
||||
user: Usuário
|
||||
points: Pontos
|
||||
timeframes:
|
||||
all_time: Todo o Tempo
|
||||
this_year: Este Ano
|
||||
this_month: Este Mês
|
||||
this_week: Esta Semana
|
||||
update:
|
||||
success: Configurações salvas com sucesso
|
||||
invalid: "Não foi possível salvar as configurações: %{errors}"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ OpenneoImpressItems::Application.routes.draw do
|
|||
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
||||
get '/wardrobe' => redirect('/outfits/new')
|
||||
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
|
||||
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
|
||||
get '/start/:color_name/:species_name' => 'outfits#start'
|
||||
|
||||
# The outfits users have created!
|
||||
|
|
@ -47,7 +48,7 @@ OpenneoImpressItems::Application.routes.draw do
|
|||
get '/alt-styles', to: redirect('/rainbow-pool/styles')
|
||||
|
||||
# Loading and modeling pets!
|
||||
post '/pets/load' => 'pets#load', :as => :load_pet
|
||||
match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post]
|
||||
get '/modeling' => 'pets#bulk', :as => :bulk_pets
|
||||
|
||||
# Contributions to our modeling database!
|
||||
|
|
|
|||
|
|
@ -0,0 +1,6 @@
|
|||
class AddIndexToContributionsUserIdAndCreatedAt < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
add_index :contributions, [:user_id, :created_at],
|
||||
name: 'index_contributions_on_user_id_and_created_at'
|
||||
end
|
||||
end
|
||||
|
|
@ -10,28 +10,28 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
|
||||
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||
t.string "name", limit: 30, null: false
|
||||
t.string "encrypted_password", limit: 64
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "current_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "email", limit: 50
|
||||
t.string "encrypted_password", limit: 64
|
||||
t.integer "failed_attempts", default: 0
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "last_sign_in_ip"
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.string "name", limit: 30, null: false
|
||||
t.string "neopass_email"
|
||||
t.string "password_salt", limit: 32
|
||||
t.string "provider"
|
||||
t.datetime "remember_created_at"
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.string "reset_password_token"
|
||||
t.integer "sign_in_count", default: 0
|
||||
t.datetime "current_sign_in_at", precision: nil
|
||||
t.datetime "last_sign_in_at", precision: nil
|
||||
t.string "current_sign_in_ip"
|
||||
t.string "last_sign_in_ip"
|
||||
t.integer "failed_attempts", default: 0
|
||||
t.string "unlock_token"
|
||||
t.datetime "locked_at", precision: nil
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.datetime "reset_password_sent_at", precision: nil
|
||||
t.datetime "remember_created_at"
|
||||
t.string "provider"
|
||||
t.string "uid"
|
||||
t.string "neopass_email"
|
||||
t.string "unlock_token"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.index ["email"], name: "index_users_on_email", unique: true
|
||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||
|
|
|
|||
205
db/schema.rb
205
db/schema.rb
|
|
@ -10,50 +10,50 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
||||
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "species_id", null: false
|
||||
t.integer "color_id", null: false
|
||||
t.integer "body_id", null: false
|
||||
t.integer "color_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "series_name"
|
||||
t.string "thumbnail_url", null: false
|
||||
t.string "full_name"
|
||||
t.string "series_name"
|
||||
t.integer "species_id", null: false
|
||||
t.string "thumbnail_url", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
||||
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
||||
end
|
||||
|
||||
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "short_name", limit: 10, null: false
|
||||
t.string "name", limit: 40, null: false
|
||||
t.text "icon", size: :long, null: false
|
||||
t.text "gateway", size: :long, null: false
|
||||
t.text "icon", size: :long, null: false
|
||||
t.string "name", limit: 40, null: false
|
||||
t.string "secret", limit: 64, null: false
|
||||
t.string "short_name", limit: 10, null: false
|
||||
end
|
||||
|
||||
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "progress", default: 0, null: false
|
||||
t.integer "goal", null: false
|
||||
t.boolean "active", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.boolean "advertised", default: true, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.text "description", size: :long, null: false
|
||||
t.string "purpose", default: "our hosting costs this year", null: false
|
||||
t.string "theme_id", default: "hug", null: false
|
||||
t.text "thanks", size: :long
|
||||
t.integer "goal", null: false
|
||||
t.string "name"
|
||||
t.integer "progress", default: 0, null: false
|
||||
t.string "purpose", default: "our hosting costs this year", null: false
|
||||
t.text "thanks", size: :long
|
||||
t.string "theme_id", default: "hug", null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
end
|
||||
|
||||
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "item_id"
|
||||
t.integer "user_id"
|
||||
t.integer "quantity"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.boolean "owned", default: true, null: false
|
||||
t.integer "item_id"
|
||||
t.integer "list_id"
|
||||
t.boolean "owned", default: true, null: false
|
||||
t.integer "quantity"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
||||
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
||||
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
||||
|
|
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
|||
end
|
||||
|
||||
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "name"
|
||||
t.text "description", size: :long
|
||||
t.integer "user_id"
|
||||
t.boolean "hangers_owned", null: false
|
||||
t.datetime "created_at", precision: nil
|
||||
t.text "description", size: :long
|
||||
t.boolean "hangers_owned", null: false
|
||||
t.string "name"
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.integer "visibility", default: 1, null: false
|
||||
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
||||
end
|
||||
|
||||
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.boolean "basic"
|
||||
t.boolean "standard"
|
||||
t.string "name", null: false
|
||||
t.string "pb_item_name"
|
||||
t.string "pb_item_thumbnail_url"
|
||||
t.boolean "standard"
|
||||
end
|
||||
|
||||
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "contributed_type", limit: 8, null: false
|
||||
t.integer "contributed_id", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.string "contributed_type", limit: 8, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
||||
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
|
||||
t.index ["user_id"], name: "index_contributions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "donation_id", null: false
|
||||
t.integer "outfit_id"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
end
|
||||
|
||||
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "amount", null: false
|
||||
t.integer "campaign_id", null: false
|
||||
t.string "charge_id", null: false
|
||||
t.integer "user_id"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "donor_email"
|
||||
t.string "donor_name"
|
||||
t.string "secret"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.string "donor_email"
|
||||
t.integer "campaign_id", null: false
|
||||
t.integer "user_id"
|
||||
end
|
||||
|
||||
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.datetime "created_at", precision: nil
|
||||
t.boolean "is_worn"
|
||||
t.integer "item_id"
|
||||
t.integer "outfit_id"
|
||||
t.boolean "is_worn"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
||||
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
||||
end
|
||||
|
||||
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.text "thumbnail_url", size: :long, null: false
|
||||
t.text "cached_compatible_body_ids", default: ""
|
||||
t.string "cached_occupied_zone_ids", default: ""
|
||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||
t.string "category", limit: 50
|
||||
t.string "type", limit: 50
|
||||
t.integer "rarity_index", limit: 2
|
||||
t.integer "price", limit: 3, null: false
|
||||
t.integer "weight_lbs", limit: 2
|
||||
t.text "species_support_ids", size: :long
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.text "description", size: :medium, null: false
|
||||
t.integer "dyeworks_base_item_id"
|
||||
t.boolean "explicitly_body_specific", default: false, null: false
|
||||
t.boolean "is_manually_nc", default: false, null: false
|
||||
t.integer "manual_special_color_id"
|
||||
t.column "modeling_status_hint", "enum('done','glitchy')"
|
||||
t.boolean "is_manually_nc", default: false, null: false
|
||||
t.string "name", null: false
|
||||
t.text "description", size: :medium, null: false
|
||||
t.integer "price", limit: 3, null: false
|
||||
t.string "rarity", default: "", null: false
|
||||
t.integer "dyeworks_base_item_id"
|
||||
t.string "cached_occupied_zone_ids", default: ""
|
||||
t.text "cached_compatible_body_ids", default: ""
|
||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||
t.integer "rarity_index", limit: 2
|
||||
t.text "species_support_ids", size: :long
|
||||
t.text "thumbnail_url", size: :long, null: false
|
||||
t.string "type", limit: 50
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "weight_lbs", limit: 2
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
||||
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
||||
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
||||
|
|
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
|||
end
|
||||
|
||||
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "user_id", null: false
|
||||
t.integer "series", null: false
|
||||
t.integer "token", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
||||
t.index ["user_id"], name: "login_cookies_user_id"
|
||||
end
|
||||
|
|
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
|||
end
|
||||
|
||||
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
||||
t.integer "item_id", null: false
|
||||
t.integer "price", null: false
|
||||
t.integer "discount_price"
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "discount_begins_at"
|
||||
t.datetime "discount_ends_at"
|
||||
t.datetime "created_at", null: false
|
||||
t.integer "discount_price"
|
||||
t.integer "item_id", null: false
|
||||
t.integer "price", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
||||
end
|
||||
|
||||
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "user_id"
|
||||
t.string "neopets_username"
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "neopets_username"
|
||||
t.datetime "updated_at", precision: nil, null: false
|
||||
t.integer "user_id"
|
||||
end
|
||||
|
||||
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "pet_state_id"
|
||||
t.integer "user_id"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.string "name"
|
||||
t.boolean "starred", default: false, null: false
|
||||
t.string "image"
|
||||
t.string "image_layers_hash"
|
||||
t.boolean "image_enqueued", default: false, null: false
|
||||
t.bigint "alt_style_id"
|
||||
t.datetime "created_at", precision: nil
|
||||
t.string "image"
|
||||
t.boolean "image_enqueued", default: false, null: false
|
||||
t.string "image_layers_hash"
|
||||
t.string "name"
|
||||
t.integer "pet_state_id"
|
||||
t.boolean "starred", default: false, null: false
|
||||
t.datetime "updated_at", precision: nil
|
||||
t.integer "user_id"
|
||||
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
||||
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
||||
t.index ["user_id"], name: "index_outfits_on_user_id"
|
||||
|
|
@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
|||
|
||||
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "parent_id", null: false
|
||||
t.integer "swf_asset_id", null: false
|
||||
t.string "parent_type", limit: 8, null: false
|
||||
t.integer "swf_asset_id", null: false
|
||||
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
||||
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
||||
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
||||
end
|
||||
|
||||
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "pet_name", limit: 20, null: false
|
||||
t.text "amf", size: :long, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "pet_name", limit: 20, null: false
|
||||
end
|
||||
|
||||
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "pet_type_id", null: false
|
||||
t.text "swf_asset_ids", size: :medium, null: false
|
||||
t.boolean "female"
|
||||
t.integer "mood_id"
|
||||
t.boolean "unconverted"
|
||||
t.boolean "labeled", default: false, null: false
|
||||
t.boolean "glitched", default: false, null: false
|
||||
t.string "artist_neopets_username"
|
||||
t.datetime "created_at"
|
||||
t.boolean "female"
|
||||
t.boolean "glitched", default: false, null: false
|
||||
t.boolean "labeled", default: false, null: false
|
||||
t.integer "mood_id"
|
||||
t.integer "pet_type_id", null: false
|
||||
t.text "swf_asset_ids", size: :medium, null: false
|
||||
t.boolean "unconverted"
|
||||
t.datetime "updated_at"
|
||||
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
||||
end
|
||||
|
||||
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "color_id", null: false
|
||||
t.integer "species_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.string "image_hash", limit: 8
|
||||
t.string "basic_image_hash"
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.integer "color_id", null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.string "image_hash", limit: 8
|
||||
t.integer "species_id", null: false
|
||||
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
||||
t.index ["body_id"], name: "pet_types_body_id"
|
||||
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
||||
|
|
@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
|||
end
|
||||
|
||||
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "type", limit: 7, null: false
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.datetime "converted_at", precision: nil
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.boolean "has_image", default: false, null: false
|
||||
t.boolean "image_manual", default: false, null: false
|
||||
t.boolean "image_requested", default: false, null: false
|
||||
t.string "known_glitches", limit: 128, default: ""
|
||||
t.text "manifest", size: :long
|
||||
t.timestamp "manifest_cached_at"
|
||||
t.datetime "manifest_loaded_at"
|
||||
t.integer "manifest_status_code"
|
||||
t.string "manifest_url"
|
||||
t.integer "remote_id", limit: 3, null: false
|
||||
t.datetime "reported_broken_at", precision: nil
|
||||
t.string "type", limit: 7, null: false
|
||||
t.text "url", size: :long, null: false
|
||||
t.integer "zone_id", null: false
|
||||
t.text "zones_restrict", size: :medium, null: false
|
||||
t.datetime "created_at", precision: nil, null: false
|
||||
t.integer "body_id", limit: 2, null: false
|
||||
t.boolean "has_image", default: false, null: false
|
||||
t.boolean "image_requested", default: false, null: false
|
||||
t.datetime "reported_broken_at", precision: nil
|
||||
t.datetime "converted_at", precision: nil
|
||||
t.boolean "image_manual", default: false, null: false
|
||||
t.text "manifest", size: :long
|
||||
t.timestamp "manifest_cached_at"
|
||||
t.string "known_glitches", limit: 128, default: ""
|
||||
t.string "manifest_url"
|
||||
t.datetime "manifest_loaded_at"
|
||||
t.integer "manifest_status_code"
|
||||
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
||||
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
||||
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
||||
end
|
||||
|
||||
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.string "name", limit: 30, null: false
|
||||
t.integer "auth_server_id", limit: 1, null: false
|
||||
t.integer "remote_id", null: false
|
||||
t.integer "points", default: 0, null: false
|
||||
t.boolean "beta", default: false, null: false
|
||||
t.string "remember_token"
|
||||
t.datetime "remember_created_at", precision: nil
|
||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "contact_neopets_connection_id"
|
||||
t.timestamp "last_trade_activity_at"
|
||||
t.boolean "support_staff", default: false, null: false
|
||||
t.string "name", limit: 30, null: false
|
||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||
t.integer "points", default: 0, null: false
|
||||
t.datetime "remember_created_at", precision: nil
|
||||
t.string "remember_token"
|
||||
t.integer "remote_id", null: false
|
||||
t.boolean "shadowbanned", default: false, null: false
|
||||
t.boolean "support_staff", default: false, null: false
|
||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||
end
|
||||
|
||||
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||
t.integer "depth"
|
||||
t.integer "type_id"
|
||||
t.string "label", null: false
|
||||
t.string "plain_label", null: false
|
||||
t.integer "type_id"
|
||||
end
|
||||
|
||||
add_foreign_key "alt_styles", "colors"
|
||||
|
|
|
|||
72
db/seeds/top_contributors_sample_data.rb
Normal file
72
db/seeds/top_contributors_sample_data.rb
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Sample contributions for testing Top Contributors feature
|
||||
# Run with: rails runner db/seeds/top_contributors_sample_data.rb
|
||||
|
||||
puts "Creating sample contributions for Top Contributors testing..."
|
||||
|
||||
# Find or create test users
|
||||
users = []
|
||||
5.times do |i|
|
||||
name = "TestContributor#{i + 1}"
|
||||
user = User.find_or_create_by!(name: name) do |u|
|
||||
# Create a corresponding auth_user record
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "test#{i + 1}@example.com",
|
||||
password: 'password123',
|
||||
)
|
||||
u.remote_id = auth_user.id
|
||||
u.auth_server_id = 1
|
||||
end
|
||||
users << user
|
||||
end
|
||||
|
||||
# Get some existing items/pet types to contribute
|
||||
items = Item.limit(10).to_a
|
||||
pet_types = PetType.limit(5).to_a
|
||||
swf_assets = SwfAsset.limit(5).to_a
|
||||
|
||||
if items.empty? || pet_types.empty?
|
||||
puts "WARNING: No items or pet types found. Create some first or contributions will be limited."
|
||||
end
|
||||
|
||||
# Create contributions with different time periods
|
||||
# User 1: Heavy contributor this week
|
||||
if items.any?
|
||||
3.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 2.days.ago) }
|
||||
5.times { Contribution.create!(user: users[0], contributed: items.sample, created_at: 5.days.ago) }
|
||||
end
|
||||
|
||||
# User 2: Heavy contributor this month, but not this week
|
||||
if items.any? && pet_types.any?
|
||||
2.times { Contribution.create!(user: users[1], contributed: items.sample, created_at: 15.days.ago) }
|
||||
1.times { Contribution.create!(user: users[1], contributed: pet_types.sample, created_at: 20.days.ago) }
|
||||
end
|
||||
|
||||
# User 3: Heavy contributor this year, but not this month
|
||||
if pet_types.any?
|
||||
3.times { Contribution.create!(user: users[2], contributed: pet_types.sample, created_at: 3.months.ago) }
|
||||
end
|
||||
|
||||
# User 4: Old contributor (only in all-time)
|
||||
if items.any?
|
||||
users[3].update!(points: 500) # Set points directly for all-time view
|
||||
2.times { Contribution.create!(user: users[3], contributed: items.sample, created_at: 2.years.ago) }
|
||||
end
|
||||
|
||||
# User 5: Mixed contributions across all periods
|
||||
if items.any? && pet_types.any?
|
||||
Contribution.create!(user: users[4], contributed: items.sample, created_at: 1.day.ago)
|
||||
Contribution.create!(user: users[4], contributed: pet_types.sample, created_at: 10.days.ago)
|
||||
Contribution.create!(user: users[4], contributed: items.sample, created_at: 2.months.ago)
|
||||
end
|
||||
if swf_assets.any?
|
||||
Contribution.create!(user: users[4], contributed: swf_assets.sample, created_at: 4.days.ago)
|
||||
end
|
||||
|
||||
puts "Created sample contributions:"
|
||||
puts "- #{users[0].name}: #{users[0].contributions.count} contributions (focus: this week)"
|
||||
puts "- #{users[1].name}: #{users[1].contributions.count} contributions (focus: this month)"
|
||||
puts "- #{users[2].name}: #{users[2].contributions.count} contributions (focus: this year)"
|
||||
puts "- #{users[3].name}: #{users[3].contributions.count} contributions (focus: all-time, #{users[3].points} points)"
|
||||
puts "- #{users[4].name}: #{users[4].contributions.count} contributions (mixed periods)"
|
||||
puts "\nTest the feature at: http://localhost:3000/users/top-contributors"
|
||||
|
|
@ -442,13 +442,12 @@
|
|||
mode: "755"
|
||||
state: directory
|
||||
|
||||
- name: Remove 10min cron job to run `rails nc_mall:sync`
|
||||
- name: Create 2min cron job to run `rails items:auto_model`
|
||||
become_user: impress
|
||||
cron:
|
||||
state: absent
|
||||
name: "Impress: sync NC Mall data"
|
||||
minute: "*/10"
|
||||
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
|
||||
name: "Impress: auto-model items"
|
||||
minute: "*/2"
|
||||
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'"
|
||||
|
||||
- name: Create 10min cron job to run `rails neopets:import`
|
||||
become_user: impress
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
119
spec/controllers/users_controller_spec.rb
Normal file
119
spec/controllers/users_controller_spec.rb
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe UsersController, type: :controller do
|
||||
include Devise::Test::ControllerHelpers
|
||||
|
||||
describe 'GET #top_contributors' do
|
||||
let!(:user1) { create_user('Alice', 100) }
|
||||
let!(:user2) { create_user('Bob', 50) }
|
||||
let!(:user3) { create_user('Charlie', 0) }
|
||||
|
||||
context 'without timeframe parameter' do
|
||||
it 'defaults to all_time timeframe' do
|
||||
get :top_contributors
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'returns users ordered by points' do
|
||||
get :top_contributors
|
||||
users = assigns(:users)
|
||||
expect(users.to_a.map(&:id)).to eq([user1.id, user2.id])
|
||||
end
|
||||
|
||||
it 'paginates results' do
|
||||
get :top_contributors, params: { page: 1 }
|
||||
users = assigns(:users)
|
||||
expect(users).to respond_to(:total_pages)
|
||||
expect(users).to respond_to(:current_page)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with valid timeframe parameter' do
|
||||
it 'accepts all_time' do
|
||||
get :top_contributors, params: { timeframe: 'all_time' }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'accepts this_year' do
|
||||
get :top_contributors, params: { timeframe: 'this_year' }
|
||||
expect(assigns(:timeframe)).to eq('this_year')
|
||||
end
|
||||
|
||||
it 'accepts this_month' do
|
||||
get :top_contributors, params: { timeframe: 'this_month' }
|
||||
expect(assigns(:timeframe)).to eq('this_month')
|
||||
end
|
||||
|
||||
it 'accepts this_week' do
|
||||
get :top_contributors, params: { timeframe: 'this_week' }
|
||||
expect(assigns(:timeframe)).to eq('this_week')
|
||||
end
|
||||
|
||||
it 'calls User.top_contributors_for with the timeframe' do
|
||||
expect(User).to receive(:top_contributors_for).with(:this_week).and_call_original
|
||||
get :top_contributors, params: { timeframe: 'this_week' }
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid timeframe parameter' do
|
||||
it 'defaults to all_time' do
|
||||
get :top_contributors, params: { timeframe: 'invalid' }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
end
|
||||
|
||||
it 'does not raise an error' do
|
||||
expect {
|
||||
get :top_contributors, params: { timeframe: 'invalid' }
|
||||
}.not_to raise_error
|
||||
end
|
||||
end
|
||||
|
||||
context 'with pagination' do
|
||||
before do
|
||||
# Create 25 users to test pagination (per_page is 20)
|
||||
25.times do |i|
|
||||
create_user("User#{i}", 100 - i)
|
||||
end
|
||||
end
|
||||
|
||||
it 'paginates with 20 users per page' do
|
||||
get :top_contributors
|
||||
expect(assigns(:users).size).to eq(20)
|
||||
end
|
||||
|
||||
it 'supports page parameter' do
|
||||
get :top_contributors, params: { page: 2 }
|
||||
expect(assigns(:users).current_page).to eq(2)
|
||||
end
|
||||
|
||||
it 'works with timeframe and pagination together' do
|
||||
get :top_contributors, params: { timeframe: 'all_time', page: 2 }
|
||||
expect(assigns(:timeframe)).to eq('all_time')
|
||||
expect(assigns(:users).current_page).to eq(2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'renders the correct template' do
|
||||
it 'renders the top_contributors template' do
|
||||
get :top_contributors
|
||||
expect(response).to render_template('top_contributors')
|
||||
end
|
||||
|
||||
it 'returns HTTP success' do
|
||||
get :top_contributors
|
||||
expect(response).to have_http_status(:success)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
def create_user(name, points = 0)
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "#{name.downcase}@example.com",
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123'
|
||||
)
|
||||
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1, points: points)
|
||||
end
|
||||
end
|
||||
|
|
@ -343,6 +343,72 @@ RSpec.describe Outfit do
|
|||
item
|
||||
end
|
||||
|
||||
describe "#same_wardrobe_state_as?" do
|
||||
it "returns true for outfits with identical state" do
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns true even when names differ (name is not part of wardrobe state)" do
|
||||
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when poses differ" do
|
||||
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns false when worn items differ" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns true regardless of worn item order" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when species differ" do
|
||||
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
|
||||
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
|
||||
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns false when alt styles differ" do
|
||||
alt_style = AltStyle.create!(
|
||||
species: acara,
|
||||
color: blue,
|
||||
body_id: 999,
|
||||
series_name: "Nostalgic",
|
||||
thumbnail_url: "https://images.neopets.example/alt.png"
|
||||
)
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe "#visible_layers" do
|
||||
before do
|
||||
# Clean up any existing pet types to avoid conflicts
|
||||
|
|
|
|||
293
spec/models/user_spec.rb
Normal file
293
spec/models/user_spec.rb
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe User do
|
||||
describe '.top_contributors_for' do
|
||||
let!(:user1) { create_user('Alice') }
|
||||
let!(:user2) { create_user('Bob') }
|
||||
let!(:user3) { create_user('Charlie') }
|
||||
|
||||
context 'with all_time timeframe' do
|
||||
it 'uses the denormalized points column' do
|
||||
user1.update!(points: 100)
|
||||
user2.update!(points: 50)
|
||||
user3.update!(points: 0)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results.map(&:id)).to eq([user1.id, user2.id])
|
||||
expect(results.first.points).to eq(100)
|
||||
expect(results.second.points).to eq(50)
|
||||
end
|
||||
|
||||
it 'excludes users with zero points' do
|
||||
user1.update!(points: 100)
|
||||
user2.update!(points: 0)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
|
||||
it 'orders by points descending' do
|
||||
user1.update!(points: 50)
|
||||
user2.update!(points: 100)
|
||||
user3.update!(points: 75)
|
||||
|
||||
results = User.top_contributors_for(:all_time)
|
||||
expect(results.map(&:id)).to eq([user2.id, user3.id, user1.id])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_week timeframe' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# Create contributions from this week
|
||||
create_contribution(user1, item, 3.days.ago) # 3 points
|
||||
create_contribution(user1, item, 2.days.ago) # 3 points
|
||||
|
||||
# Create contributions from last month (should be excluded)
|
||||
create_contribution(user2, item, 1.month.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last week' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(6)
|
||||
end
|
||||
|
||||
it 'excludes users with no recent contributions' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one week' do
|
||||
create_contribution(user3, item, 8.days.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user3)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_month timeframe' do
|
||||
let(:item) { create_item }
|
||||
let(:pet_type) { create_pet_type }
|
||||
|
||||
before do
|
||||
# User 1: contributions from this month
|
||||
create_contribution(user1, item, 15.days.ago) # 3 points
|
||||
create_contribution(user1, pet_type, 20.days.ago) # 15 points
|
||||
|
||||
# User 2: contributions older than one month
|
||||
create_contribution(user2, item, 35.days.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last month' do
|
||||
results = User.top_contributors_for(:this_month)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(18)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one month' do
|
||||
results = User.top_contributors_for(:this_month)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with this_year timeframe' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# User 1: contributions from this year
|
||||
create_contribution(user1, item, 3.months.ago) # 3 points
|
||||
create_contribution(user1, item, 6.months.ago) # 3 points
|
||||
|
||||
# User 2: contributions older than one year
|
||||
create_contribution(user2, item, 13.months.ago) # 3 points (excluded)
|
||||
end
|
||||
|
||||
it 'calculates points from contributions in the last year' do
|
||||
results = User.top_contributors_for(:this_year)
|
||||
expect(results.first).to eq(user1)
|
||||
expect(results.first.period_points).to eq(6)
|
||||
end
|
||||
|
||||
it 'excludes contributions older than one year' do
|
||||
results = User.top_contributors_for(:this_year)
|
||||
expect(results).not_to include(user2)
|
||||
end
|
||||
end
|
||||
|
||||
context 'point value calculations' do
|
||||
let(:item) { create_item }
|
||||
let(:pet_type) { create_pet_type }
|
||||
let(:alt_style) { create_alt_style }
|
||||
|
||||
it 'assigns 3 points for Item contributions' do
|
||||
create_contribution(user1, item, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(3)
|
||||
end
|
||||
|
||||
it 'assigns 15 points for PetType contributions' do
|
||||
create_contribution(user1, pet_type, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(15)
|
||||
end
|
||||
|
||||
it 'assigns 30 points for AltStyle contributions' do
|
||||
create_contribution(user1, alt_style, 1.day.ago)
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(30)
|
||||
end
|
||||
|
||||
it 'sums multiple contribution types correctly' do
|
||||
create_contribution(user1, item, 1.day.ago) # 3 points
|
||||
create_contribution(user1, pet_type, 2.days.ago) # 15 points
|
||||
create_contribution(user1, alt_style, 3.days.ago) # 30 points
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.first.period_points).to eq(48)
|
||||
end
|
||||
end
|
||||
|
||||
context 'ordering and filtering' do
|
||||
let(:item) { create_item }
|
||||
|
||||
before do
|
||||
# Create various contributions
|
||||
3.times { create_contribution(user1, item, 1.day.ago) } # 9 points
|
||||
5.times { create_contribution(user2, item, 2.days.ago) } # 15 points
|
||||
2.times { create_contribution(user3, item, 3.days.ago) } # 6 points
|
||||
end
|
||||
|
||||
it 'orders by period_points descending' do
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results.map(&:id)).to eq([user2.id, user1.id, user3.id])
|
||||
end
|
||||
|
||||
it 'uses user.id as secondary sort for tied scores' do
|
||||
# Create two users with same points
|
||||
user4 = create_user('Dave')
|
||||
user5 = create_user('Eve')
|
||||
|
||||
create_contribution(user4, item, 1.day.ago) # 3 points
|
||||
create_contribution(user5, item, 1.day.ago) # 3 points
|
||||
|
||||
results = User.top_contributors_for(:this_week).where(id: [user4.id, user5.id])
|
||||
# Should be ordered by user.id ASC when points are tied
|
||||
expect(results.first.id).to be < results.second.id
|
||||
end
|
||||
|
||||
it 'excludes users with zero contributions in period' do
|
||||
# user3 has no contributions this week
|
||||
user4 = create_user('Dave')
|
||||
|
||||
results = User.top_contributors_for(:this_week)
|
||||
expect(results).not_to include(user4)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid timeframe' do
|
||||
it 'raises ArgumentError' do
|
||||
expect { User.top_contributors_by_period(:invalid) }.
|
||||
to raise_error(ArgumentError, /Invalid timeframe/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#period_points' do
|
||||
let(:user) { create_user('Alice') }
|
||||
|
||||
context 'when period_points attribute is set' do
|
||||
it 'returns the calculated period_points' do
|
||||
# Simulate a query that sets period_points
|
||||
user_with_period = User.select('users.*, 42 AS period_points').find(user.id)
|
||||
expect(user_with_period.period_points).to eq(42)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when period_points attribute is not set' do
|
||||
it 'falls back to denormalized points column' do
|
||||
user.update!(points: 100)
|
||||
expect(user.period_points).to eq(100)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper methods
|
||||
def create_user(name)
|
||||
auth_user = AuthUser.create!(
|
||||
name: name,
|
||||
email: "#{name.downcase}@example.com",
|
||||
password: 'password123',
|
||||
password_confirmation: 'password123'
|
||||
)
|
||||
User.create!(name: name, remote_id: auth_user.id, auth_server_id: 1)
|
||||
end
|
||||
|
||||
def create_contribution(user, contributed, created_at)
|
||||
Contribution.create!(
|
||||
user: user,
|
||||
contributed: contributed,
|
||||
created_at: created_at
|
||||
)
|
||||
end
|
||||
|
||||
def create_item
|
||||
# Create a minimal item for testing
|
||||
Item.create!(
|
||||
name: "Test Item #{SecureRandom.hex(4)}",
|
||||
description: "Test item",
|
||||
thumbnail_url: "http://example.com/thumb.png",
|
||||
rarity: "",
|
||||
price: 0,
|
||||
zones_restrict: ""
|
||||
)
|
||||
end
|
||||
|
||||
def create_swf_asset
|
||||
# Create a minimal swf_asset for testing
|
||||
zone = Zone.first || Zone.create!(id: 1, label: "Test Zone", plain_label: "Test Zone", type_id: 1)
|
||||
SwfAsset.create!(
|
||||
type: 'object',
|
||||
remote_id: SecureRandom.random_number(100000),
|
||||
url: "http://example.com/test.swf",
|
||||
zone_id: zone.id,
|
||||
body_id: 0
|
||||
)
|
||||
end
|
||||
|
||||
def create_pet_type
|
||||
# Use find_or_create_by to avoid duplicate key errors
|
||||
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||
PetType.create!(
|
||||
species_id: species.id,
|
||||
color_id: color.id,
|
||||
body_id: 0
|
||||
)
|
||||
end
|
||||
|
||||
def create_pet_state
|
||||
pet_type = create_pet_type
|
||||
PetState.create!(
|
||||
pet_type: pet_type,
|
||||
swf_asset_ids: []
|
||||
)
|
||||
end
|
||||
|
||||
def create_alt_style
|
||||
# Use find_or_create_by to avoid duplicate key errors
|
||||
species = Species.find_or_create_by!(name: "Test Species #{SecureRandom.hex(4)}")
|
||||
color = Color.find_or_create_by!(name: "Test Color #{SecureRandom.hex(4)}")
|
||||
AltStyle.create!(
|
||||
species_id: species.id,
|
||||
color_id: color.id,
|
||||
body_id: 0,
|
||||
series_name: "Test Series",
|
||||
thumbnail_url: "http://example.com/thumb.png"
|
||||
)
|
||||
end
|
||||
end
|
||||
BIN
vendor/cache/rails-controller-testing-1.0.5.gem
vendored
Normal file
BIN
vendor/cache/rails-controller-testing-1.0.5.gem
vendored
Normal file
Binary file not shown.
1785
vendor/javascript/idiomorph.js
vendored
1785
vendor/javascript/idiomorph.js
vendored
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue