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.
|
# For automated tests.
|
||||||
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
gem 'rspec-rails', '~> 8.0', '>= 8.0.2', group: [:development, :test]
|
||||||
|
gem 'rails-controller-testing', group: [:test]
|
||||||
gem "webmock", "~> 3.24", group: [:test]
|
gem "webmock", "~> 3.24", group: [:test]
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,10 @@ GEM
|
||||||
activesupport (= 8.1.2)
|
activesupport (= 8.1.2)
|
||||||
bundler (>= 1.15.0)
|
bundler (>= 1.15.0)
|
||||||
railties (= 8.1.2)
|
railties (= 8.1.2)
|
||||||
|
rails-controller-testing (1.0.5)
|
||||||
|
actionpack (>= 5.0.1.rc1)
|
||||||
|
actionview (>= 5.0.1.rc1)
|
||||||
|
activesupport (>= 5.0.1.rc1)
|
||||||
rails-dom-testing (2.3.0)
|
rails-dom-testing (2.3.0)
|
||||||
activesupport (>= 5.0.0)
|
activesupport (>= 5.0.0)
|
||||||
minitest
|
minitest
|
||||||
|
|
@ -505,6 +509,7 @@ DEPENDENCIES
|
||||||
rack-attack (~> 6.7)
|
rack-attack (~> 6.7)
|
||||||
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
rack-mini-profiler (~> 4.0, >= 4.0.1)
|
||||||
rails (~> 8.0, >= 8.0.1)
|
rails (~> 8.0, >= 8.0.1)
|
||||||
|
rails-controller-testing
|
||||||
rails-i18n (~> 8.0, >= 8.0.1)
|
rails-i18n (~> 8.0, >= 8.0.1)
|
||||||
rdiscount (~> 2.2, >= 2.2.7.1)
|
rdiscount (~> 2.2, >= 2.2.7.1)
|
||||||
rspec-rails (~> 8.0, >= 8.0.2)
|
rspec-rails (~> 8.0, >= 8.0.2)
|
||||||
|
|
|
||||||
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 {
|
try {
|
||||||
const mainPickerForm = document.querySelector(
|
const mainPickerForm = document.querySelector(
|
||||||
"#item-preview species-color-picker form",
|
"#item-preview .species-color-picker form",
|
||||||
);
|
);
|
||||||
const mainSpeciesField = mainPickerForm.querySelector(
|
const mainSpeciesField = mainPickerForm.querySelector(
|
||||||
"[name='preview[species_id]']",
|
"[name='preview[species_id]']",
|
||||||
|
|
|
||||||
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 {
|
class OutfitViewer extends HTMLElement {
|
||||||
#internals;
|
#internals;
|
||||||
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
||||||
|
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
@ -8,7 +9,31 @@ class OutfitViewer extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Set up listener for bubbled hasanimationschange events from layers
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
// When a layer is added, update its playing state to match ours.
|
||||||
|
const addedLayers = mutations
|
||||||
|
.flatMap(m => [...m.addedNodes])
|
||||||
|
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||||
|
for (const layer of addedLayers) {
|
||||||
|
if (this.#internals.states.has("playing")) {
|
||||||
|
layer.play();
|
||||||
|
} else {
|
||||||
|
layer.pause();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const removedLayers = mutations
|
||||||
|
.flatMap(m => [...m.removedNodes])
|
||||||
|
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||||
|
|
||||||
|
// If any layers were added or removed, updated our hasAnimations state.
|
||||||
|
if (addedLayers.length > 0 || removedLayers.length > 0) {
|
||||||
|
this.#updateHasAnimations();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
observer.observe(this, { childList: true });
|
||||||
|
|
||||||
|
// When a new layer finishes loading and determines it has animations, update.
|
||||||
this.addEventListener("hasanimationschange", (e) => {
|
this.addEventListener("hasanimationschange", (e) => {
|
||||||
// Only handle events from outfit-layer children, not from ourselves
|
// Only handle events from outfit-layer children, not from ourselves
|
||||||
if (e.target === this) return;
|
if (e.target === this) return;
|
||||||
|
|
@ -16,23 +41,6 @@ class OutfitViewer extends HTMLElement {
|
||||||
this.#updateHasAnimations();
|
this.#updateHasAnimations();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Watch for new layers being added and apply the current playing state
|
|
||||||
const observer = new MutationObserver((mutations) => {
|
|
||||||
for (const mutation of mutations) {
|
|
||||||
for (const node of mutation.addedNodes) {
|
|
||||||
if (node.tagName === "OUTFIT-LAYER") {
|
|
||||||
// Apply current playing state to the new layer
|
|
||||||
if (this.#internals.states.has("playing")) {
|
|
||||||
node.play();
|
|
||||||
} else {
|
|
||||||
node.pause();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
observer.observe(this, { childList: true });
|
|
||||||
|
|
||||||
// The `<outfit-layer>` is connected to the DOM right before its
|
// The `<outfit-layer>` is connected to the DOM right before its
|
||||||
// children are. So, to engage with the children, wait a tick!
|
// children are. So, to engage with the children, wait a tick!
|
||||||
setTimeout(() => this.#connectToChildren(), 0);
|
setTimeout(() => this.#connectToChildren(), 0);
|
||||||
|
|
@ -57,12 +65,12 @@ class OutfitViewer extends HTMLElement {
|
||||||
this.querySelector("outfit-layer:state(has-animations)") !== null;
|
this.querySelector("outfit-layer:state(has-animations)") !== null;
|
||||||
|
|
||||||
// Check if state actually changed
|
// Check if state actually changed
|
||||||
const hadAnimations = this.#internals.states.has("has-animations");
|
if (hasAnimations === this.#hasAnimations) {
|
||||||
if (hasAnimations === hadAnimations) {
|
|
||||||
return; // No change, skip
|
return; // No change, skip
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update internal state
|
// Update internal state
|
||||||
|
this.#hasAnimations = hasAnimations;
|
||||||
if (hasAnimations) {
|
if (hasAnimations) {
|
||||||
this.#internals.states.add("has-animations");
|
this.#internals.states.add("has-animations");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -137,7 +145,7 @@ class OutfitViewer extends HTMLElement {
|
||||||
.split("; ")
|
.split("; ")
|
||||||
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
|
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
|
||||||
if (cookie) {
|
if (cookie) {
|
||||||
return cookie.split("=")[1] === "true";
|
return cookie.split("=")[1] !== "false";
|
||||||
}
|
}
|
||||||
return true; // Default to playing
|
return true; // Default to playing
|
||||||
}
|
}
|
||||||
|
|
@ -388,6 +396,13 @@ class OutfitViewerPlayPauseToggle extends HTMLElement {
|
||||||
"outfit-layer:state(has-animations)",
|
"outfit-layer:state(has-animations)",
|
||||||
) !== null,
|
) !== null,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// After a Turbo morph, Idiomorph may remove our `hidden` attribute
|
||||||
|
// (since the server HTML never includes it). Re-apply visibility
|
||||||
|
// based on the current animation state.
|
||||||
|
document.addEventListener("turbo:render", () => {
|
||||||
|
this.#syncFromOutfitViewer();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
#syncFromOutfitViewer() {
|
#syncFromOutfitViewer() {
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,31 @@
|
||||||
/**
|
/**
|
||||||
* PosePicker web component
|
* PosePickerPopover web component
|
||||||
*
|
*
|
||||||
* Progressive enhancement for pose picker forms:
|
* Scrolls the selected style into view when the style picker list becomes
|
||||||
* - Auto-submits the form when a pose is selected (if JS is enabled)
|
* visible (e.g. tab switch or popover open).
|
||||||
* - Shows a submit button as fallback (if JS is disabled or slow to load)
|
|
||||||
* - Uses Custom Element internals API to communicate state to CSS
|
|
||||||
*/
|
*/
|
||||||
class PosePickerPopover extends HTMLElement {
|
class PosePickerPopover extends HTMLElement {
|
||||||
#internals;
|
#styleListObserver;
|
||||||
|
|
||||||
constructor() {
|
|
||||||
super();
|
|
||||||
this.#internals = this.attachInternals();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
// Listen for changes to auto-submit the form, then tell CSS about it!
|
// When the style picker list becomes visible (e.g. tab switch or
|
||||||
this.addEventListener("change", this.#handleChange);
|
// popover open), scroll the selected style into view.
|
||||||
this.#internals.states.add("auto-loading");
|
const styleList = this.querySelector(".style-picker-list");
|
||||||
|
if (styleList) {
|
||||||
|
this.#styleListObserver = new IntersectionObserver(([entry]) => {
|
||||||
|
if (entry.isIntersecting) {
|
||||||
|
const checked = styleList.querySelector("input:checked");
|
||||||
|
checked
|
||||||
|
?.closest("label")
|
||||||
|
?.scrollIntoView({ block: "nearest" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.#styleListObserver.observe(styleList);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#handleChange(e) {
|
disconnectedCallback() {
|
||||||
// Only auto-submit if a radio button was changed
|
this.#styleListObserver?.disconnect();
|
||||||
if (e.target.type === "radio") {
|
|
||||||
this.querySelector("form").requestSubmit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
// Wardrobe v2 - Simple Rails+Turbo outfit editor
|
||||||
//
|
//
|
||||||
// This page uses Turbo Frames for instant updates when changing species/color.
|
// This page uses Turbo for instant updates when changing species/color.
|
||||||
// The outfit_viewer Web Component handles the pet rendering.
|
// The outfit_viewer Web Component handles the pet rendering.
|
||||||
|
|
||||||
console.log("Wardrobe v2 loaded!");
|
// Unsaved changes warning: use a MutationObserver to watch the
|
||||||
|
// data-has-unsaved-changes attribute on the wardrobe container. This is more
|
||||||
|
// robust than event listeners because it works regardless of how the DOM is
|
||||||
|
// updated (Turbo morph, direct manipulation, etc.).
|
||||||
|
function setupUnsavedChangesObserver() {
|
||||||
|
const container = document.querySelector("[data-has-unsaved-changes]");
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
function update() {
|
||||||
|
if (container.dataset.hasUnsavedChanges === "true") {
|
||||||
|
window.onbeforeunload = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
window.onbeforeunload = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set initial state
|
||||||
|
update();
|
||||||
|
|
||||||
|
// Watch for attribute changes
|
||||||
|
const observer = new MutationObserver(update);
|
||||||
|
observer.observe(container, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ["data-has-unsaved-changes"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setupUnsavedChangesObserver();
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ outfit-viewer
|
||||||
.error-indicator
|
.error-indicator
|
||||||
display: block
|
display: block
|
||||||
|
|
||||||
species-color-picker
|
.species-color-picker
|
||||||
.error-icon
|
.error-icon
|
||||||
cursor: help
|
cursor: help
|
||||||
margin-right: .25em
|
margin-right: .25em
|
||||||
|
|
@ -130,7 +130,7 @@ species-color-picker
|
||||||
animation-delay: .75s
|
animation-delay: .75s
|
||||||
|
|
||||||
// Once the auto-loading behavior is ready, remove the submit button.
|
// Once the auto-loading behavior is ready, remove the submit button.
|
||||||
&:state(auto-loading)
|
auto-submit-form:state(auto-loading)
|
||||||
input[type=submit]
|
input[type=submit]
|
||||||
display: none
|
display: none
|
||||||
|
|
||||||
|
|
@ -296,7 +296,7 @@ species-face-picker
|
||||||
width: 380px
|
width: 380px
|
||||||
height: 380px
|
height: 380px
|
||||||
|
|
||||||
species-color-picker
|
.species-color-picker
|
||||||
grid-area: picker
|
grid-area: picker
|
||||||
|
|
||||||
species-face-picker
|
species-face-picker
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,14 @@
|
||||||
body.users-top_contributors
|
body.users-top_contributors
|
||||||
text-align: center
|
text-align: center
|
||||||
|
|
||||||
|
.timeframe-nav
|
||||||
|
margin: 1em 0
|
||||||
|
display: flex
|
||||||
|
justify-content: center
|
||||||
|
gap: 1em
|
||||||
|
list-style: none
|
||||||
|
padding: 0
|
||||||
|
|
||||||
#top-contributors
|
#top-contributors
|
||||||
border:
|
border:
|
||||||
spacing: 0
|
spacing: 0
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
@import "../application/item-badges.css";
|
@import "../application/item-badges.css";
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Shared Components
|
||||||
|
Buttons, item cards, pagination, and other reusable patterns.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
/* Base button defaults - applied to all interactive controls */
|
/* Base button defaults - applied to all interactive controls */
|
||||||
button,
|
button,
|
||||||
input[type="submit"],
|
input[type="submit"],
|
||||||
|
|
@ -10,19 +15,19 @@ select,
|
||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
background: white;
|
background: white;
|
||||||
color: #448844;
|
color: var(--color-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
box-shadow: 0 0 0 3px var(--color-primary-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -55,12 +60,12 @@ select,
|
||||||
padding: 0.75rem 1.5rem;
|
padding: 0.75rem 1.5rem;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
border: none;
|
border: none;
|
||||||
background: #448844;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #357535;
|
background: var(--color-primary-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
|
|
@ -74,7 +79,9 @@ select,
|
||||||
|
|
||||||
/* Icon button pattern - small action buttons with hover reveals */
|
/* Icon button pattern - small action buttons with hover reveals */
|
||||||
.item-remove-button,
|
.item-remove-button,
|
||||||
.item-add-button {
|
.item-add-button,
|
||||||
|
.item-hide-button,
|
||||||
|
.item-show-button {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
right: 0.5rem;
|
right: 0.5rem;
|
||||||
|
|
@ -103,7 +110,7 @@ select,
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
outline: 2px solid #448844;
|
outline: 2px solid var(--color-primary);
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,6 +131,140 @@ select,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-hide-button {
|
||||||
|
right: 2.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-show-button {
|
||||||
|
right: 2.5rem;
|
||||||
|
background: rgba(68, 136, 68, 0.9);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(68, 136, 68, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item card - shared layout for worn items and search results */
|
||||||
|
item-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background: #f9f9f9;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #333;
|
||||||
|
transition: background 0.2s, box-shadow 0.2s;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover :is(.item-add-button, .item-remove-button, .item-hide-button, .item-show-button) {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #2D3748;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-badges {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.375rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worn item emphasis */
|
||||||
|
item-card[data-is-worn] {
|
||||||
|
background: #eef5ee;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(68, 136, 68, 0.2);
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Closeted item de-emphasis */
|
||||||
|
item-card[data-is-closeted] {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually hidden inputs (radio/checkbox) - accessible but not visible */
|
||||||
|
.visually-hidden {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Item card label - click target wrapping thumbnail + info */
|
||||||
|
.item-card-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When item-card is defined (JS loaded), hide Show/Hide/Add buttons
|
||||||
|
and make the label the primary interaction. Keep Remove visible. */
|
||||||
|
item-card:defined .item-show-button,
|
||||||
|
item-card:defined .item-hide-button,
|
||||||
|
item-card:defined .item-add-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus ring on item card when input is focused (keyboard navigation) */
|
||||||
|
item-card:defined:has(input:focus-visible) {
|
||||||
|
outline: 2px solid var(--color-primary);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Pagination links - treated as buttons for consistency */
|
/* Pagination links - treated as buttons for consistency */
|
||||||
.pagination {
|
.pagination {
|
||||||
a,
|
a,
|
||||||
|
|
@ -134,26 +275,26 @@ select,
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
background: white;
|
background: white;
|
||||||
color: #448844;
|
color: var(--color-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #f9f9f9;
|
background: #f9f9f9;
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.current,
|
.current,
|
||||||
em {
|
em {
|
||||||
background: #448844;
|
background: var(--color-primary);
|
||||||
color: white;
|
color: white;
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: #448844;
|
background: var(--color-primary);
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -169,7 +310,31 @@ select,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Progressive enhancement: submit buttons hidden when JS auto-submits */
|
||||||
|
@media (scripting: enabled) {
|
||||||
|
.progressive-submit {
|
||||||
|
opacity: 0;
|
||||||
|
animation: fade-in 0.25s forwards;
|
||||||
|
animation-delay: 0.75s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
auto-submit-form:state(auto-loading) .progressive-submit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Page Layout
|
||||||
|
Top-level grid: preview on left/top, controls on right/bottom.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
body.wardrobe-v2 {
|
body.wardrobe-v2 {
|
||||||
|
--color-primary: #448844;
|
||||||
|
--color-primary-hover: #357535;
|
||||||
|
--color-primary-muted: rgba(68, 136, 68, 0.1);
|
||||||
|
--color-accent: #48BB78;
|
||||||
|
--color-accent-glow: rgba(72, 187, 120, 0.4);
|
||||||
|
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
|
@ -180,7 +345,6 @@ body.wardrobe-v2 {
|
||||||
.wardrobe-container {
|
.wardrobe-container {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
background: #000;
|
|
||||||
|
|
||||||
/* Mobile: vertical stack with preview on top, controls below */
|
/* Mobile: vertical stack with preview on top, controls below */
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
|
|
@ -197,12 +361,17 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Outfit Preview
|
||||||
|
Left/top panel: outfit viewer, floating controls, pose picker.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
.outfit-preview-section {
|
.outfit-preview-section {
|
||||||
grid-area: preview;
|
grid-area: preview;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #000;
|
background: rgb(23, 25, 35);
|
||||||
position: relative;
|
position: relative;
|
||||||
container-type: size;
|
container-type: size;
|
||||||
|
|
||||||
|
|
@ -292,7 +461,62 @@ body.wardrobe-v2 {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pose picker button */
|
/* Species/color picker */
|
||||||
|
.species-color-picker {
|
||||||
|
display: contents;
|
||||||
|
|
||||||
|
auto-submit-form,
|
||||||
|
form {
|
||||||
|
display: contents;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
||||||
|
background-position: right 0.5rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.5em 1.5em;
|
||||||
|
|
||||||
|
option {
|
||||||
|
background: #2D3748;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="submit"] {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (scripting: enabled) {
|
||||||
|
input[type="submit"] {
|
||||||
|
position: absolute;
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show controls on hover (real hover only, not simulated touch hover) */
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover .preview-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show controls when they have focus or when popover is open */
|
||||||
|
&:has(.preview-controls:focus-within) .preview-controls,
|
||||||
|
&:has(.pose-picker-button[popovertargetopen]) .preview-controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Pose Picker Popover
|
||||||
|
Floating panel for choosing expression/pose and alt styles.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
.pose-picker-button {
|
.pose-picker-button {
|
||||||
anchor-name: --pose-picker-anchor;
|
anchor-name: --pose-picker-anchor;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -321,7 +545,6 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Pose picker popover */
|
|
||||||
pose-picker-popover {
|
pose-picker-popover {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
position-anchor: --pose-picker-anchor;
|
position-anchor: --pose-picker-anchor;
|
||||||
|
|
@ -335,6 +558,7 @@ body.wardrobe-v2 {
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
|
||||||
|
width: 20rem;
|
||||||
|
|
||||||
.pose-picker-form {
|
.pose-picker-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -363,19 +587,21 @@ body.wardrobe-v2 {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pose-option input[type="radio"],
|
||||||
|
.style-option input[type="radio"] {
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.pose-option {
|
.pose-option {
|
||||||
display: block;
|
display: block;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
|
margin: 0 auto;
|
||||||
input[type="radio"] {
|
|
||||||
position: absolute;
|
|
||||||
opacity: 0;
|
|
||||||
width: 0;
|
|
||||||
height: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pose-thumbnail {
|
.pose-thumbnail {
|
||||||
width: 60px;
|
width: 60px;
|
||||||
|
|
@ -412,8 +638,8 @@ body.wardrobe-v2 {
|
||||||
|
|
||||||
/* Selected state */
|
/* Selected state */
|
||||||
input[type="radio"]:checked + .pose-thumbnail {
|
input[type="radio"]:checked + .pose-thumbnail {
|
||||||
border-color: #48BB78;
|
border-color: var(--color-accent);
|
||||||
box-shadow: 0 0 0 3px rgba(72, 187, 120, 0.4);
|
box-shadow: 0 0 0 3px var(--color-accent-glow);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -440,92 +666,131 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Submit button: progressive enhancement pattern */
|
|
||||||
.pose-submit-button {
|
.pose-submit-button {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* If JS is enabled, hide the submit button initially with a delay */
|
/* Tab panel layout */
|
||||||
@media (scripting: enabled) {
|
.tab-list {
|
||||||
.pose-submit-button {
|
display: flex;
|
||||||
opacity: 0;
|
gap: 0.25rem;
|
||||||
animation: fade-in 0.25s forwards;
|
margin-top: 1rem;
|
||||||
animation-delay: 0.75s;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Once auto-submit is enabled, hide the submit button completely */
|
.tab-button {
|
||||||
&:state(auto-loading) .pose-submit-button {
|
flex: 1;
|
||||||
display: none;
|
padding: 0.4rem 0.75rem;
|
||||||
}
|
font-size: 0.85rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Species/color picker */
|
&.active {
|
||||||
species-color-picker {
|
background: rgba(255, 255, 255, 0.2);
|
||||||
display: contents;
|
|
||||||
|
|
||||||
form {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
padding: 0.5rem 2rem 0.5rem 0.75rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
appearance: none;
|
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3E%3Cpath stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3E%3C/svg%3E");
|
|
||||||
background-position: right 0.5rem center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
background-size: 1.5em 1.5em;
|
|
||||||
|
|
||||||
option {
|
|
||||||
background: #2D3748;
|
|
||||||
color: white;
|
color: white;
|
||||||
|
border-color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Submit button: progressive enhancement pattern */
|
/* Without JS, hide tab buttons and show both panels stacked */
|
||||||
/* If JS is disabled, the button is always visible */
|
tab-panel:not(:defined) .tab-list {
|
||||||
/* If JS is enabled but slow to load, fade in after 0.75s */
|
|
||||||
/* Once the web component loads, hide the button completely */
|
|
||||||
input[type="submit"] {
|
|
||||||
padding: 0.5rem 1rem;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* If JS is enabled, hide the submit button initially with a delay */
|
|
||||||
@media (scripting: enabled) {
|
|
||||||
input[type="submit"] {
|
|
||||||
position: absolute;
|
|
||||||
margin-left: 0.5em;
|
|
||||||
opacity: 0;
|
|
||||||
animation: fade-in 0.25s forwards;
|
|
||||||
animation-delay: 0.75s;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Once auto-loading is ready, hide the submit button completely */
|
|
||||||
&:state(auto-loading) {
|
|
||||||
input[type="submit"] {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tab-panel:not(:defined) .tab-content[hidden] {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Style picker form */
|
||||||
|
.style-picker-form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option {
|
||||||
|
display: block;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.style-option-content {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.375rem 0.5rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.style-option-thumbnail {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show controls on hover (real hover only, not simulated touch hover) */
|
.style-option-name {
|
||||||
@media (hover: hover) {
|
color: white;
|
||||||
&:hover .preview-controls {
|
font-size: 0.9rem;
|
||||||
opacity: 1;
|
}
|
||||||
|
|
||||||
|
/* Hover */
|
||||||
|
&:hover .style-option-content {
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selected state */
|
||||||
|
input[type="radio"]:checked + .style-option-content {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
box-shadow: 0 0 0 2px rgba(72, 187, 120, 0.3);
|
||||||
|
background: rgba(72, 187, 120, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus state */
|
||||||
|
input[type="radio"]:focus + .style-option-content {
|
||||||
|
border-color: rgba(255, 255, 255, 0.8);
|
||||||
|
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Show controls when they have focus or when popover is open */
|
.style-submit-button {
|
||||||
&:has(.preview-controls:focus-within) .preview-controls,
|
margin-top: 1rem;
|
||||||
&:has(.pose-picker-button[popovertargetopen]) .preview-controls {
|
width: 100%;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Outfit Controls
|
||||||
|
Right/bottom panel: worn items, search, and results.
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
.outfit-controls-section {
|
.outfit-controls-section {
|
||||||
grid-area: controls;
|
grid-area: controls;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|
@ -534,15 +799,9 @@ body.wardrobe-v2 {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
|
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.3);
|
||||||
|
|
||||||
h1 {
|
|
||||||
margin-top: 0;
|
|
||||||
font-size: 1.75rem;
|
|
||||||
color: #448844;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
h2 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
color: #448844;
|
color: var(--color-primary);
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -569,7 +828,7 @@ body.wardrobe-v2 {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.worn-items {
|
.outfit-items {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
|
||||||
.items-list {
|
.items-list {
|
||||||
|
|
@ -577,70 +836,6 @@ body.wardrobe-v2 {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 0.5rem 0;
|
margin: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #f9f9f9;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #333;
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover .item-remove-button {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-thumbnail {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
/* Allow text to truncate */
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2D3748;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.375rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .item-remove-button styles are defined in button system above */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.item-search-form {
|
.item-search-form {
|
||||||
|
|
@ -664,12 +859,10 @@ body.wardrobe-v2 {
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: #448844;
|
border-color: var(--color-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(68, 136, 68, 0.1);
|
box-shadow: 0 0 0 3px var(--color-primary-muted);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* input[type="submit"] styles are defined in button system above */
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -678,69 +871,6 @@ body.wardrobe-v2 {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
|
|
||||||
.item-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
padding: 0.75rem;
|
|
||||||
background: #f9f9f9;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
border-radius: 8px;
|
|
||||||
color: #333;
|
|
||||||
transition: background 0.2s, box-shadow 0.2s;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: #f0f0f0;
|
|
||||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover :is(.item-add-button, .item-remove-button) {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-thumbnail {
|
|
||||||
flex-shrink: 0;
|
|
||||||
width: 50px;
|
|
||||||
height: 50px;
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
background: white;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
img {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: contain;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-info {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-name {
|
|
||||||
font-weight: 500;
|
|
||||||
color: #2D3748;
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.item-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 0.375rem;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* .item-add-button styles are defined in button system above */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
|
|
@ -754,11 +884,187 @@ body.wardrobe-v2 {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Pagination link styles are defined in button system above */
|
/* ===================================================================
|
||||||
|
Flash Messages
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
.flash-messages {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash-alert {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #842029;
|
||||||
|
border: 1px solid #f5c2c7;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Outfit Header (name + save button)
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
|
.outfit-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-name-form {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-name-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background: transparent;
|
||||||
|
transition: border-color 0.2s, background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: #ddd;
|
||||||
|
background: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--color-primary);
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 0 0 3px var(--color-primary-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
font-weight: normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Rename button: hidden by default, shown on hover/focus */
|
||||||
|
.outfit-name-submit {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-name-form:focus-within .outfit-name-submit,
|
||||||
|
.outfit-name-form:hover .outfit-name-submit {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Static name display for non-owners */
|
||||||
|
.outfit-name-static {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Web component: static display with pencil icon */
|
||||||
|
outfit-rename-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
outfit-rename-field .outfit-name-form {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
outfit-rename-field[editing] .outfit-rename-static-display {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
outfit-rename-field[editing] .outfit-name-form {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-rename-static-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-rename-name {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: bold;
|
||||||
|
color: var(--color-primary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-rename-pencil {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
padding: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-rename-static-display:hover .outfit-rename-pencil,
|
||||||
|
.outfit-rename-pencil:focus {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide save button when rename is in editing state */
|
||||||
|
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-form,
|
||||||
|
.outfit-header:has(outfit-rename-field[editing]) .outfit-save-button:disabled {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-save-form {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.outfit-save-button {
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: default;
|
||||||
|
color: #888;
|
||||||
|
border-color: #ddd;
|
||||||
|
background: #f5f5f5;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: #f5f5f5;
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
a.outfit-save-button {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===================================================================
|
||||||
|
Animations
|
||||||
|
=================================================================== */
|
||||||
|
|
||||||
@keyframes fade-in {
|
@keyframes fade-in {
|
||||||
from {
|
from {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
|
||||||
@outfit.user = current_user
|
@outfit.user = current_user
|
||||||
|
|
||||||
if @outfit.save
|
if @outfit.save
|
||||||
render :json => @outfit
|
respond_to do |format|
|
||||||
|
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
|
||||||
|
format.json { render json: @outfit }
|
||||||
|
end
|
||||||
else
|
else
|
||||||
render_outfit_errors
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
redirect_back fallback_location: wardrobe_v2_path,
|
||||||
|
alert: @outfit.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
format.json { render_outfit_errors }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -123,9 +132,25 @@ class OutfitsController < ApplicationController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
if @outfit.update(outfit_params)
|
if @outfit.update(outfit_params)
|
||||||
render :json => @outfit
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
return_to = params[:return_to]
|
||||||
|
if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//")
|
||||||
|
redirect_to return_to
|
||||||
else
|
else
|
||||||
render_outfit_errors
|
redirect_to wardrobe_v2_outfit_path(@outfit)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
format.json { render json: @outfit }
|
||||||
|
end
|
||||||
|
else
|
||||||
|
respond_to do |format|
|
||||||
|
format.html do
|
||||||
|
redirect_back fallback_location: wardrobe_v2_outfit_path(@outfit),
|
||||||
|
alert: @outfit.errors.full_messages.join(", ")
|
||||||
|
end
|
||||||
|
format.json { render_outfit_errors }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,9 +34,10 @@ class PetsController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def destination
|
def destination
|
||||||
case (params[:destination] || params[:origin])
|
if request.get?
|
||||||
when 'wardrobe' then wardrobe_path
|
wardrobe_path
|
||||||
else root_path
|
else
|
||||||
|
root_path
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,10 @@ class UsersController < ApplicationController
|
||||||
end
|
end
|
||||||
|
|
||||||
def top_contributors
|
def top_contributors
|
||||||
@users = User.top_contributors.paginate :page => params[:page], :per_page => 20
|
valid_timeframes = User::VALID_TIMEFRAMES.map(&:to_s)
|
||||||
|
@timeframe = params[:timeframe].presence_in(valid_timeframes) || 'all_time'
|
||||||
|
@users = User.top_contributors_for(@timeframe.to_sym)
|
||||||
|
.paginate(page: params[:page], per_page: 20)
|
||||||
end
|
end
|
||||||
|
|
||||||
def edit
|
def edit
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
class WardrobeController < ApplicationController
|
class WardrobeController < ApplicationController
|
||||||
|
prepend_view_path Rails.root.join("app/views/wardrobe")
|
||||||
|
|
||||||
def show
|
def show
|
||||||
|
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
|
||||||
|
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
|
||||||
|
|
||||||
|
# If visiting a saved outfit with no state params, redirect with the
|
||||||
|
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
|
||||||
|
# the rest of the action always reads from params.
|
||||||
|
if @saved_outfit && !outfit_state_params_present?
|
||||||
|
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
# Set the form target path for all wardrobe forms
|
||||||
|
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
|
||||||
|
|
||||||
# Get selected species and color from params, or default to Blue Acara
|
# Get selected species and color from params, or default to Blue Acara
|
||||||
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
|
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
|
||||||
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
|
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
|
||||||
|
|
@ -41,20 +57,45 @@ class WardrobeController < ApplicationController
|
||||||
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
||||||
end
|
end
|
||||||
|
|
||||||
# Load items from the objects[] parameter
|
# Load alt style from params, scoped to the current species
|
||||||
item_ids = params[:objects] || []
|
@alt_style = if params[:style].present? && @selected_species
|
||||||
items = Item.where(id: item_ids)
|
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Load all available alt styles for this species (for the style picker)
|
||||||
|
@available_alt_styles = @selected_species ?
|
||||||
|
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
|
||||||
|
|
||||||
|
# Load items from the objects[] and closet[] parameters
|
||||||
|
worn_item_ids = params[:objects] || []
|
||||||
|
closeted_item_ids = params[:closet] || []
|
||||||
|
worn_items = Item.where(id: worn_item_ids)
|
||||||
|
closeted_items = Item.where(id: closeted_item_ids)
|
||||||
|
|
||||||
# Build the outfit
|
# Build the outfit
|
||||||
@outfit = Outfit.new(
|
@outfit = Outfit.new(
|
||||||
|
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
|
||||||
pet_state: @pet_state,
|
pet_state: @pet_state,
|
||||||
worn_items: items,
|
alt_style: @alt_style,
|
||||||
|
worn_items: worn_items,
|
||||||
|
closeted_items: closeted_items,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preload the manifests for all visible layers, so they load efficiently
|
# Preload the manifests for all visible layers, so they load efficiently
|
||||||
# in parallel rather than sequentially when rendering
|
# in parallel rather than sequentially when rendering
|
||||||
SwfAsset.preload_manifests(@outfit.visible_layers)
|
SwfAsset.preload_manifests(@outfit.visible_layers)
|
||||||
|
|
||||||
|
# Also preload alt style layer manifests for the style picker thumbnails
|
||||||
|
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
|
||||||
|
|
||||||
|
# Compute saved outfit state for the view
|
||||||
|
if @saved_outfit
|
||||||
|
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
|
||||||
|
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
|
||||||
|
else
|
||||||
|
@has_unsaved_changes = false
|
||||||
|
end
|
||||||
|
|
||||||
# Handle search mode
|
# Handle search mode
|
||||||
@search_mode = params[:q].present?
|
@search_mode = params[:q].present?
|
||||||
if @search_mode
|
if @search_mode
|
||||||
|
|
@ -95,6 +136,10 @@ class WardrobeController < ApplicationController
|
||||||
poses_hash
|
poses_hash
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def outfit_state_params_present?
|
||||||
|
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
|
||||||
|
end
|
||||||
|
|
||||||
def build_search_filters(query_params, outfit)
|
def build_search_filters(query_params, outfit)
|
||||||
filters = []
|
filters = []
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,4 @@
|
||||||
module OutfitsHelper
|
module OutfitsHelper
|
||||||
def destination_tag(value)
|
|
||||||
hidden_field_tag 'destination', value, :id => nil
|
|
||||||
end
|
|
||||||
|
|
||||||
def latest_contribution_description(contribution)
|
def latest_contribution_description(contribution)
|
||||||
user = contribution.user
|
user = contribution.user
|
||||||
contributed = contribution.contributed
|
contributed = contribution.contributed
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,11 @@ module WardrobeHelper
|
||||||
def outfit_state_params(outfit = @outfit, except: [])
|
def outfit_state_params(outfit = @outfit, except: [])
|
||||||
fields = []
|
fields = []
|
||||||
|
|
||||||
|
fields << hidden_field_tag(:name, @outfit.name) if !@saved_outfit && @outfit.name.present? && !except.include?(:name)
|
||||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||||
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||||
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
||||||
|
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
|
||||||
|
|
||||||
unless except.include?(:worn_items)
|
unless except.include?(:worn_items)
|
||||||
outfit.worn_items.each do |item|
|
outfit.worn_items.each do |item|
|
||||||
|
|
@ -15,6 +17,12 @@ module WardrobeHelper
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
unless except.include?(:closeted_items)
|
||||||
|
outfit.closeted_items.each do |item|
|
||||||
|
fields << hidden_field_tag('closet[]', item.id)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
unless except.include?(:q)
|
unless except.include?(:q)
|
||||||
(params[:q] || {}).each do |key, value|
|
(params[:q] || {}).each do |key, value|
|
||||||
fields << hidden_field_tag("q[#{key}]", value) if value.present?
|
fields << hidden_field_tag("q[#{key}]", value) if value.present?
|
||||||
|
|
@ -24,8 +32,12 @@ module WardrobeHelper
|
||||||
safe_join fields
|
safe_join fields
|
||||||
end
|
end
|
||||||
|
|
||||||
# Get the emoji and label for a pose, for display in the pose picker button
|
# Get the emoji and label for the pose picker button.
|
||||||
def pose_emoji_and_label(pose)
|
# Shows the alt style name when one is active, otherwise the pose name.
|
||||||
|
def pose_emoji_and_label(pose, alt_style: nil)
|
||||||
|
if alt_style
|
||||||
|
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
|
||||||
|
else
|
||||||
case pose
|
case pose
|
||||||
when "HAPPY_MASC", "HAPPY_FEM"
|
when "HAPPY_MASC", "HAPPY_FEM"
|
||||||
{ emoji: "😀", label: "Happy" }
|
{ emoji: "😀", label: "Happy" }
|
||||||
|
|
@ -37,28 +49,31 @@ module WardrobeHelper
|
||||||
{ emoji: "😀", label: "Default" }
|
{ emoji: "😀", label: "Default" }
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Group outfit items by zone, applying smart multi-zone simplification.
|
# Group outfit items by zone, applying smart multi-zone simplification.
|
||||||
# Returns an array of hashes: {zone:, items:}
|
# Returns an array of hashes: {zone_id:, zone_label:, items: [Item, ...]}
|
||||||
# This matches the logic from wardrobe-2020's getZonesAndItems function.
|
# This matches the logic from wardrobe-2020's getZonesAndItems function.
|
||||||
def outfit_items_by_zone(outfit)
|
def outfit_items_by_zone(outfit)
|
||||||
return [] if outfit.pet_type.nil?
|
return [] if outfit.pet_type.nil?
|
||||||
|
|
||||||
# Get item appearances for this outfit
|
all_items = outfit.worn_items + outfit.closeted_items
|
||||||
|
|
||||||
|
# Get item appearances for all items at once
|
||||||
item_appearances = Item.appearances_for(
|
item_appearances = Item.appearances_for(
|
||||||
outfit.worn_items,
|
all_items,
|
||||||
outfit.pet_type,
|
outfit.pet_type,
|
||||||
swf_asset_includes: [:zone]
|
swf_asset_includes: [:zone]
|
||||||
)
|
)
|
||||||
|
|
||||||
# Separate incompatible items (no layers for this pet)
|
# Separate compatible and incompatible items
|
||||||
compatible_items = []
|
compatible = {}
|
||||||
incompatible_items = []
|
incompatible_items = []
|
||||||
|
|
||||||
outfit.worn_items.each do |item|
|
all_items.each do |item|
|
||||||
appearance = item_appearances[item.id]
|
appearance = item_appearances[item.id]
|
||||||
if appearance&.present?
|
if appearance&.present?
|
||||||
compatible_items << {item: item, appearance: appearance}
|
compatible[item] = appearance
|
||||||
else
|
else
|
||||||
incompatible_items << item
|
incompatible_items << item
|
||||||
end
|
end
|
||||||
|
|
@ -68,11 +83,7 @@ module WardrobeHelper
|
||||||
items_by_zone = Hash.new { |h, k| h[k] = [] }
|
items_by_zone = Hash.new { |h, k| h[k] = [] }
|
||||||
zones_by_id = {}
|
zones_by_id = {}
|
||||||
|
|
||||||
compatible_items.each do |item_with_appearance|
|
compatible.each do |item, appearance|
|
||||||
item = item_with_appearance[:item]
|
|
||||||
appearance = item_with_appearance[:appearance]
|
|
||||||
|
|
||||||
# Get unique zones for this item (an item may have multiple assets per zone)
|
|
||||||
appearance.swf_assets.map(&:zone).uniq.each do |zone|
|
appearance.swf_assets.map(&:zone).uniq.each do |zone|
|
||||||
zones_by_id[zone.id] = zone
|
zones_by_id[zone.id] = zone
|
||||||
items_by_zone[zone.id] << item
|
items_by_zone[zone.id] << item
|
||||||
|
|
@ -138,8 +149,7 @@ module WardrobeHelper
|
||||||
# For single-item groups, only keep if:
|
# For single-item groups, only keep if:
|
||||||
# - Item hasn't been seen yet AND
|
# - Item hasn't been seen yet AND
|
||||||
# - Item won't appear in a conflict group
|
# - Item won't appear in a conflict group
|
||||||
item = group[:items].first
|
item_id = group[:items].first.id
|
||||||
item_id = item.id
|
|
||||||
|
|
||||||
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
|
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
|
||||||
false
|
false
|
||||||
|
|
|
||||||
|
|
@ -261,17 +261,24 @@ class Outfit < ApplicationRecord
|
||||||
(biology_layers + item_layers).sort_by(&:depth)
|
(biology_layers + item_layers).sort_by(&:depth)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def same_wardrobe_state_as?(other)
|
||||||
|
# Exclude :name because it's managed separately via atomic rename, not URL
|
||||||
|
# state. This also works around the @outfit (new) vs @saved_outfit
|
||||||
|
# (persisted) split in WardrobeController, where only the unpersisted
|
||||||
|
# outfit includes :name. We should consider keeping their names in sync.
|
||||||
|
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
|
||||||
|
end
|
||||||
|
|
||||||
def wardrobe_params
|
def wardrobe_params
|
||||||
params = {
|
params = {
|
||||||
name: name,
|
|
||||||
color: color_id,
|
color: color_id,
|
||||||
species: species_id,
|
species: species_id,
|
||||||
pose: pose,
|
pose: pose,
|
||||||
state: pet_state_id,
|
objects: worn_item_ids.sort,
|
||||||
objects: worn_item_ids,
|
closet: closeted_item_ids.sort,
|
||||||
closet: closeted_item_ids,
|
|
||||||
}
|
}
|
||||||
params[:style] = alt_style_id if alt_style_id.present?
|
params[:style] = alt_style_id if alt_style_id.present?
|
||||||
|
params[:name] = name if !persisted? && name.present?
|
||||||
params
|
params
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -311,9 +318,23 @@ class Outfit < ApplicationRecord
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a copy of this outfit, but *not* wearing the given item.
|
# Create a copy of this outfit without the given item at all
|
||||||
|
# (removed from both worn and closeted).
|
||||||
def without_item(item)
|
def without_item(item)
|
||||||
dup.tap { |o| o.worn_items.delete(item) }
|
dup.tap do |o|
|
||||||
|
o.worn_items.delete(item)
|
||||||
|
o.closeted_items.delete(item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Create a copy of this outfit with the given item moved from worn to
|
||||||
|
# closeted. If it's not currently worn, returns the outfit unchanged.
|
||||||
|
def hide_item(item)
|
||||||
|
dup.tap do |o|
|
||||||
|
next unless o.worn_item_ids.include?(item.id)
|
||||||
|
o.worn_items.delete(item)
|
||||||
|
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Create a copy of this outfit, additionally wearing the given item.
|
# Create a copy of this outfit, additionally wearing the given item.
|
||||||
|
|
@ -323,6 +344,9 @@ class Outfit < ApplicationRecord
|
||||||
# Skip if item is nil, already worn, or outfit has no pet_state
|
# Skip if item is nil, already worn, or outfit has no pet_state
|
||||||
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
|
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
|
||||||
|
|
||||||
|
# If the item was closeted, remove it from closet (it's moving to worn)
|
||||||
|
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
|
||||||
|
|
||||||
# Load appearances for the new item and all currently worn items
|
# Load appearances for the new item and all currently worn items
|
||||||
all_items = o.worn_items + [item]
|
all_items = o.worn_items + [item]
|
||||||
appearances = Item.appearances_for(all_items, o.pet_type,
|
appearances = Item.appearances_for(all_items, o.pet_type,
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,51 @@ class User < ApplicationRecord
|
||||||
|
|
||||||
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
scope :top_contributors, -> { order('points DESC').where('points > 0') }
|
||||||
|
|
||||||
|
VALID_TIMEFRAMES = [:all_time, :this_year, :this_month, :this_week]
|
||||||
|
|
||||||
|
scope :top_contributors_for, ->(timeframe = :all_time) {
|
||||||
|
case timeframe.to_sym
|
||||||
|
when :all_time
|
||||||
|
top_contributors # Use existing efficient scope
|
||||||
|
else
|
||||||
|
top_contributors_by_period(timeframe)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
|
||||||
|
def self.top_contributors_by_period(timeframe)
|
||||||
|
start_date = case timeframe.to_sym
|
||||||
|
when :this_week then 1.week.ago
|
||||||
|
when :this_month then 1.month.ago
|
||||||
|
when :this_year then 1.year.ago
|
||||||
|
else raise ArgumentError, "Invalid timeframe: #{timeframe}"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Build the CASE statement dynamically from Contribution::POINT_VALUES
|
||||||
|
point_case = Contribution::POINT_VALUES.map { |type, points|
|
||||||
|
"WHEN #{connection.quote(type)} THEN #{points}"
|
||||||
|
}.join("\n ")
|
||||||
|
|
||||||
|
select(
|
||||||
|
'users.*',
|
||||||
|
"COALESCE(SUM(
|
||||||
|
CASE contributions.contributed_type
|
||||||
|
#{point_case}
|
||||||
|
END
|
||||||
|
), 0) AS period_points"
|
||||||
|
)
|
||||||
|
.joins('INNER JOIN contributions ON contributions.user_id = users.id')
|
||||||
|
.where('contributions.created_at >= ?', start_date)
|
||||||
|
.group('users.id')
|
||||||
|
.having('period_points > 0')
|
||||||
|
.order('period_points DESC, users.id ASC')
|
||||||
|
end
|
||||||
|
|
||||||
|
# Virtual attribute reader for dynamically calculated points (from time-period queries).
|
||||||
|
# Falls back to the denormalized `points` column when not calculated.
|
||||||
|
def period_points
|
||||||
|
attributes['period_points'] || points
|
||||||
|
end
|
||||||
|
|
||||||
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
after_update :sync_name_with_auth_user!, if: :saved_change_to_name?
|
||||||
after_update :log_trade_activity, if: -> user {
|
after_update :log_trade_activity, if: -> user {
|
||||||
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
(user.saved_change_to_owned_closet_hangers_visibility? &&
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
- html_options = {} unless defined? html_options
|
- html_options = {} unless defined? html_options
|
||||||
- html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
|
- viewer_id = html_options[:id] ||= "outfit-viewer-#{SecureRandom.hex(8)}"
|
||||||
= content_tag "outfit-viewer", **html_options do
|
= content_tag "outfit-viewer", **html_options do
|
||||||
.loading-indicator= render partial: "hanger_spinner"
|
.loading-indicator= render partial: "hanger_spinner"
|
||||||
|
|
||||||
- outfit.visible_layers.each do |swf_asset|
|
- outfit.visible_layers.each do |swf_asset|
|
||||||
%outfit-layer{
|
%outfit-layer{
|
||||||
|
id: "#{viewer_id}-layer-#{swf_asset.id}",
|
||||||
data: {
|
data: {
|
||||||
"asset-id": swf_asset.id,
|
"asset-id": swf_asset.id,
|
||||||
"zone": swf_asset.zone.label,
|
"zone": swf_asset.zone.label,
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,8 @@
|
||||||
Customize more
|
Customize more
|
||||||
= edit_icon
|
= edit_icon
|
||||||
|
|
||||||
%species-color-picker
|
.species-color-picker
|
||||||
|
%auto-submit-form
|
||||||
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
||||||
- if @preview_error == :pet_type_does_not_exist
|
- if @preview_error == :pet_type_does_not_exist
|
||||||
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
||||||
|
|
@ -138,5 +139,5 @@
|
||||||
- content_for :javascripts do
|
- content_for :javascripts do
|
||||||
= javascript_include_tag "idiomorph", async: true
|
= javascript_include_tag "idiomorph", async: true
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
= javascript_include_tag "species-color-picker", async: true
|
= javascript_include_tag "auto-submit-form", async: true
|
||||||
= javascript_include_tag "items/show", async: true
|
= javascript_include_tag "items/show", async: true
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,7 @@
|
||||||
%h1= t 'app_name'
|
%h1= t 'app_name'
|
||||||
%h2= t '.tagline'
|
%h2= t '.tagline'
|
||||||
|
|
||||||
= form_tag load_pet_path, method: 'POST', class: 'primary load-pet-to-wardrobe' do
|
= form_tag load_pet_path, method: 'GET', class: 'primary load-pet-to-wardrobe' do
|
||||||
= hidden_field_tag 'destination', 'wardrobe'
|
|
||||||
%fieldset
|
%fieldset
|
||||||
%legend= t '.load_pet'
|
%legend= t '.load_pet'
|
||||||
= pet_name_tag class: 'main-pet-name'
|
= pet_name_tag class: 'main-pet-name'
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,13 @@
|
||||||
- title t('.title')
|
- title t('.title')
|
||||||
|
|
||||||
|
%ul.timeframe-nav
|
||||||
|
- ['all_time', 'this_year', 'this_month', 'this_week'].each do |tf|
|
||||||
|
%li
|
||||||
|
- if @timeframe == tf
|
||||||
|
%strong= t(".timeframes.#{tf}")
|
||||||
|
- else
|
||||||
|
= link_to t(".timeframes.#{tf}"), top_contributors_path(timeframe: tf)
|
||||||
|
|
||||||
= will_paginate @users
|
= will_paginate @users
|
||||||
%table#top-contributors
|
%table#top-contributors
|
||||||
%thead
|
%thead
|
||||||
|
|
@ -11,5 +20,5 @@
|
||||||
%tr
|
%tr
|
||||||
%th{:scope => 'row'}= @users.offset + rank + 1
|
%th{:scope => 'row'}= @users.offset + rank + 1
|
||||||
%td= link_to user.name, user_contributions_path(user)
|
%td= link_to user.name, user_contributions_path(user)
|
||||||
%td= user.points
|
%td= user.period_points
|
||||||
= will_paginate @users
|
= will_paginate @users
|
||||||
|
|
|
||||||
|
|
@ -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
|
- if is_available
|
||||||
-# Create a minimal outfit with just this pet state for the thumbnail
|
-# Create a minimal outfit with just this pet state for the thumbnail
|
||||||
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
|
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
|
||||||
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer"
|
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer",
|
||||||
|
id: "pose-thumbnail-viewer-#{pet_state.id}"
|
||||||
- else
|
- else
|
||||||
.pose-unavailable
|
.pose-unavailable
|
||||||
%span.question-mark{title: "Not available"} ❓
|
%span.question-mark{title: "Not available"} ❓
|
||||||
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
|
.search-results
|
||||||
- if @search_results.any?
|
- if @search_results.any?
|
||||||
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||||
|
|
||||||
%ul.search-results-list
|
%ul.search-results-list
|
||||||
- @search_results.each do |item|
|
- @search_results.each do |item|
|
||||||
= render "item_card", item: item
|
= render "items/item_card", item: item
|
||||||
|
|
||||||
= will_paginate @search_results, param_name: "q[page]", params: { q: params[:q], species: @outfit.species_id, color: @outfit.color_id, objects: params[:objects] }
|
= will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||||
|
|
||||||
- else
|
- else
|
||||||
.empty-state
|
.empty-state
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
- title "Wardrobe v2"
|
- title @outfit.name
|
||||||
|
|
||||||
!!! 5
|
!!! 5
|
||||||
%html
|
%html
|
||||||
%head
|
%head
|
||||||
%meta{charset: 'utf-8'}
|
%meta{charset: 'utf-8'}
|
||||||
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
|
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
|
||||||
%title= yield :title
|
%title #{yield :title} | #{t "app_name"}
|
||||||
%link{href: image_path('favicon.png'), rel: 'icon'}
|
%link{href: image_path('favicon.png'), rel: 'icon'}
|
||||||
= stylesheet_link_tag "application/hanger-spinner"
|
= stylesheet_link_tag "application/hanger-spinner"
|
||||||
= stylesheet_link_tag "application/outfit-viewer"
|
= stylesheet_link_tag "application/outfit-viewer"
|
||||||
|
|
@ -13,13 +13,20 @@
|
||||||
= javascript_include_tag "application", async: true
|
= javascript_include_tag "application", async: true
|
||||||
= javascript_include_tag "idiomorph", async: true
|
= javascript_include_tag "idiomorph", async: true
|
||||||
= javascript_include_tag "outfit-viewer", async: true
|
= javascript_include_tag "outfit-viewer", async: true
|
||||||
= javascript_include_tag "species-color-picker", async: true
|
= javascript_include_tag "auto-submit-form", async: true
|
||||||
= javascript_include_tag "pose-picker", async: true
|
= javascript_include_tag "pose-picker", async: true
|
||||||
|
= javascript_include_tag "tab-panel", async: true
|
||||||
|
= javascript_include_tag "outfit-rename-field", async: true
|
||||||
|
= javascript_include_tag "wardrobe/item-card", async: true
|
||||||
|
= javascript_include_tag "wardrobe/item-search-keys", async: true
|
||||||
= javascript_include_tag "wardrobe/show", async: true
|
= javascript_include_tag "wardrobe/show", async: true
|
||||||
= csrf_meta_tags
|
= csrf_meta_tags
|
||||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||||
%body.wardrobe-v2
|
%body.wardrobe-v2
|
||||||
.wardrobe-container
|
- if flash[:alert]
|
||||||
|
.flash-messages
|
||||||
|
.flash-alert= flash[:alert]
|
||||||
|
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
|
||||||
.outfit-preview-section
|
.outfit-preview-section
|
||||||
- if @pet_type.nil?
|
- if @pet_type.nil?
|
||||||
.no-preview-message
|
.no-preview-message
|
||||||
|
|
@ -27,42 +34,48 @@
|
||||||
We haven't seen this kind of pet before! Try a different species/color
|
We haven't seen this kind of pet before! Try a different species/color
|
||||||
combination.
|
combination.
|
||||||
- else
|
- else
|
||||||
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer"
|
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer",
|
||||||
|
preferred_image_format: :svg # TODO: Make this a selectable option
|
||||||
|
|
||||||
.preview-controls
|
.preview-controls
|
||||||
.preview-controls-top
|
.preview-controls-top
|
||||||
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
||||||
%label.play-pause-control-button.button
|
%label.play-pause-control-button.button
|
||||||
%input{type: "checkbox"}
|
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
|
||||||
%span.paused-label Paused
|
%span.paused-label Paused
|
||||||
%span.playing-label Playing
|
%span.playing-label Playing
|
||||||
|
|
||||||
.preview-controls-bottom
|
.preview-controls-bottom
|
||||||
= render "species_color_picker"
|
= render "appearance/species_color_picker"
|
||||||
|
|
||||||
- if @pet_type
|
- if @pet_type
|
||||||
= render "pose_picker"
|
= render "appearance/pose_picker"
|
||||||
|
|
||||||
.outfit-controls-section
|
.outfit-controls-section
|
||||||
.item-search-form
|
.item-search-form
|
||||||
- if @search_mode
|
- if @search_mode
|
||||||
= button_to wardrobe_v2_path, method: :get, class: "back-button" do
|
= button_to @wardrobe_path, method: :get, class: "back-button" do
|
||||||
←
|
←
|
||||||
= outfit_state_params except: [:q]
|
= outfit_state_params except: [:q]
|
||||||
= form_with url: wardrobe_v2_path, method: :get, class: "search-form" do |f|
|
= form_with url: @wardrobe_path, method: :get, class: "search-form" do |f|
|
||||||
= outfit_state_params
|
= outfit_state_params
|
||||||
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
|
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
|
||||||
= f.submit "Search"
|
= f.submit "Search"
|
||||||
|
|
||||||
- if @search_mode
|
- if @search_mode
|
||||||
= render "search_results"
|
= render "items/search_results"
|
||||||
- else
|
- else
|
||||||
%h1 Untitled outfit
|
.outfit-header
|
||||||
- if @outfit.worn_items.any?
|
- if @saved_outfit && !@is_owner
|
||||||
.worn-items
|
.outfit-name-static= @outfit.name
|
||||||
|
- else
|
||||||
|
= render "header/outfit_rename_field"
|
||||||
|
= render "header/save_button"
|
||||||
|
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
|
||||||
|
.outfit-items
|
||||||
- outfit_items_by_zone(@outfit).each do |zone_group|
|
- outfit_items_by_zone(@outfit).each do |zone_group|
|
||||||
.zone-group
|
.zone-group
|
||||||
%h3.zone-label= zone_group[:zone_label]
|
%h3.zone-label= zone_group[:zone_label]
|
||||||
%ul.items-list
|
%ul.items-list
|
||||||
- zone_group[:items].each do |item|
|
- zone_group[:items].each do |item|
|
||||||
= render "item_card", item: item
|
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
|
||||||
|
|
|
||||||
|
|
@ -640,6 +640,11 @@ en-MEEP:
|
||||||
rank: Reep
|
rank: Reep
|
||||||
user: Meepit
|
user: Meepit
|
||||||
points: Peeps
|
points: Peeps
|
||||||
|
timeframes:
|
||||||
|
all_time: All Meep
|
||||||
|
this_year: Meeps Year
|
||||||
|
this_month: Meeps Month
|
||||||
|
this_week: Meeps Week
|
||||||
|
|
||||||
update:
|
update:
|
||||||
success: Settings successfully meeped.
|
success: Settings successfully meeped.
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,11 @@ en:
|
||||||
rank: Rank
|
rank: Rank
|
||||||
user: User
|
user: User
|
||||||
points: Points
|
points: Points
|
||||||
|
timeframes:
|
||||||
|
all_time: All Time
|
||||||
|
this_year: This Year
|
||||||
|
this_month: This Month
|
||||||
|
this_week: This Week
|
||||||
|
|
||||||
update:
|
update:
|
||||||
success: Settings successfully saved.
|
success: Settings successfully saved.
|
||||||
|
|
|
||||||
|
|
@ -505,6 +505,11 @@ es:
|
||||||
rank: Puesto
|
rank: Puesto
|
||||||
user: Usuario
|
user: Usuario
|
||||||
points: Puntos
|
points: Puntos
|
||||||
|
timeframes:
|
||||||
|
all_time: Todo el Tiempo
|
||||||
|
this_year: Este Año
|
||||||
|
this_month: Este Mes
|
||||||
|
this_week: Esta Semana
|
||||||
update:
|
update:
|
||||||
success: Ajustes guardados correctamente.
|
success: Ajustes guardados correctamente.
|
||||||
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
invalid: "No hemos podido guardar los ajustes: %{errors}"
|
||||||
|
|
|
||||||
|
|
@ -499,6 +499,11 @@ pt:
|
||||||
rank: Rank
|
rank: Rank
|
||||||
user: Usuário
|
user: Usuário
|
||||||
points: Pontos
|
points: Pontos
|
||||||
|
timeframes:
|
||||||
|
all_time: Todo o Tempo
|
||||||
|
this_year: Este Ano
|
||||||
|
this_month: Este Mês
|
||||||
|
this_week: Esta Semana
|
||||||
update:
|
update:
|
||||||
success: Configurações salvas com sucesso
|
success: Configurações salvas com sucesso
|
||||||
invalid: "Não foi possível salvar as configurações: %{errors}"
|
invalid: "Não foi possível salvar as configurações: %{errors}"
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ OpenneoImpressItems::Application.routes.draw do
|
||||||
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
||||||
get '/wardrobe' => redirect('/outfits/new')
|
get '/wardrobe' => redirect('/outfits/new')
|
||||||
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
|
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
|
||||||
|
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
|
||||||
get '/start/:color_name/:species_name' => 'outfits#start'
|
get '/start/:color_name/:species_name' => 'outfits#start'
|
||||||
|
|
||||||
# The outfits users have created!
|
# The outfits users have created!
|
||||||
|
|
@ -47,7 +48,7 @@ OpenneoImpressItems::Application.routes.draw do
|
||||||
get '/alt-styles', to: redirect('/rainbow-pool/styles')
|
get '/alt-styles', to: redirect('/rainbow-pool/styles')
|
||||||
|
|
||||||
# Loading and modeling pets!
|
# Loading and modeling pets!
|
||||||
post '/pets/load' => 'pets#load', :as => :load_pet
|
match '/pets/load' => 'pets#load', :as => :load_pet, via: [:get, :post]
|
||||||
get '/modeling' => 'pets#bulk', :as => :bulk_pets
|
get '/modeling' => 'pets#bulk', :as => :bulk_pets
|
||||||
|
|
||||||
# Contributions to our modeling database!
|
# Contributions to our modeling database!
|
||||||
|
|
|
||||||
|
|
@ -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.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2024_04_08_120359) do
|
ActiveRecord::Schema[8.1].define(version: 2024_04_08_120359) do
|
||||||
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
create_table "users", id: { type: :integer, unsigned: true }, charset: "utf8mb3", collation: "utf8mb3_general_ci", force: :cascade do |t|
|
||||||
t.string "name", limit: 30, null: false
|
t.datetime "created_at", precision: nil
|
||||||
t.string "encrypted_password", limit: 64
|
t.datetime "current_sign_in_at", precision: nil
|
||||||
|
t.string "current_sign_in_ip"
|
||||||
t.string "email", limit: 50
|
t.string "email", limit: 50
|
||||||
|
t.string "encrypted_password", limit: 64
|
||||||
|
t.integer "failed_attempts", default: 0
|
||||||
|
t.datetime "last_sign_in_at", precision: nil
|
||||||
|
t.string "last_sign_in_ip"
|
||||||
|
t.datetime "locked_at", precision: nil
|
||||||
|
t.string "name", limit: 30, null: false
|
||||||
|
t.string "neopass_email"
|
||||||
t.string "password_salt", limit: 32
|
t.string "password_salt", limit: 32
|
||||||
|
t.string "provider"
|
||||||
|
t.datetime "remember_created_at"
|
||||||
|
t.datetime "reset_password_sent_at", precision: nil
|
||||||
t.string "reset_password_token"
|
t.string "reset_password_token"
|
||||||
t.integer "sign_in_count", default: 0
|
t.integer "sign_in_count", default: 0
|
||||||
t.datetime "current_sign_in_at", precision: nil
|
|
||||||
t.datetime "last_sign_in_at", precision: nil
|
|
||||||
t.string "current_sign_in_ip"
|
|
||||||
t.string "last_sign_in_ip"
|
|
||||||
t.integer "failed_attempts", default: 0
|
|
||||||
t.string "unlock_token"
|
|
||||||
t.datetime "locked_at", precision: nil
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
|
||||||
t.datetime "reset_password_sent_at", precision: nil
|
|
||||||
t.datetime "remember_created_at"
|
|
||||||
t.string "provider"
|
|
||||||
t.string "uid"
|
t.string "uid"
|
||||||
t.string "neopass_email"
|
t.string "unlock_token"
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
t.index ["email"], name: "index_users_on_email", unique: true
|
t.index ["email"], name: "index_users_on_email", unique: true
|
||||||
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
t.index ["provider", "uid"], name: "index_users_on_provider_and_uid", unique: true
|
||||||
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
t.index ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true
|
||||||
|
|
|
||||||
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.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
ActiveRecord::Schema[8.1].define(version: 2026_01_21_031001) do
|
||||||
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "alt_styles", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "species_id", null: false
|
|
||||||
t.integer "color_id", null: false
|
|
||||||
t.integer "body_id", null: false
|
t.integer "body_id", null: false
|
||||||
|
t.integer "color_id", null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
|
||||||
t.string "series_name"
|
|
||||||
t.string "thumbnail_url", null: false
|
|
||||||
t.string "full_name"
|
t.string "full_name"
|
||||||
|
t.string "series_name"
|
||||||
|
t.integer "species_id", null: false
|
||||||
|
t.string "thumbnail_url", null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
t.index ["color_id"], name: "index_alt_styles_on_color_id"
|
||||||
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
t.index ["species_id"], name: "index_alt_styles_on_species_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "auth_servers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "short_name", limit: 10, null: false
|
|
||||||
t.string "name", limit: 40, null: false
|
|
||||||
t.text "icon", size: :long, null: false
|
|
||||||
t.text "gateway", size: :long, null: false
|
t.text "gateway", size: :long, null: false
|
||||||
|
t.text "icon", size: :long, null: false
|
||||||
|
t.string "name", limit: 40, null: false
|
||||||
t.string "secret", limit: 64, null: false
|
t.string "secret", limit: 64, null: false
|
||||||
|
t.string "short_name", limit: 10, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "campaigns", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "progress", default: 0, null: false
|
|
||||||
t.integer "goal", null: false
|
|
||||||
t.boolean "active", null: false
|
t.boolean "active", null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
|
||||||
t.boolean "advertised", default: true, null: false
|
t.boolean "advertised", default: true, null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.text "description", size: :long, null: false
|
t.text "description", size: :long, null: false
|
||||||
t.string "purpose", default: "our hosting costs this year", null: false
|
t.integer "goal", null: false
|
||||||
t.string "theme_id", default: "hug", null: false
|
|
||||||
t.text "thanks", size: :long
|
|
||||||
t.string "name"
|
t.string "name"
|
||||||
|
t.integer "progress", default: 0, null: false
|
||||||
|
t.string "purpose", default: "our hosting costs this year", null: false
|
||||||
|
t.text "thanks", size: :long
|
||||||
|
t.string "theme_id", default: "hug", null: false
|
||||||
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "closet_hangers", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "item_id"
|
|
||||||
t.integer "user_id"
|
|
||||||
t.integer "quantity"
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
t.datetime "updated_at", precision: nil
|
t.integer "item_id"
|
||||||
t.boolean "owned", default: true, null: false
|
|
||||||
t.integer "list_id"
|
t.integer "list_id"
|
||||||
|
t.boolean "owned", default: true, null: false
|
||||||
|
t.integer "quantity"
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
t.index ["item_id", "owned"], name: "index_closet_hangers_on_item_id_and_owned"
|
||||||
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
t.index ["list_id"], name: "index_closet_hangers_on_list_id"
|
||||||
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
t.index ["user_id", "list_id", "item_id", "owned", "created_at"], name: "index_closet_hangers_test_20131226"
|
||||||
|
|
@ -63,84 +63,85 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "closet_lists", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "name"
|
|
||||||
t.text "description", size: :long
|
|
||||||
t.integer "user_id"
|
|
||||||
t.boolean "hangers_owned", null: false
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
|
t.text "description", size: :long
|
||||||
|
t.boolean "hangers_owned", null: false
|
||||||
|
t.string "name"
|
||||||
t.datetime "updated_at", precision: nil
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.integer "visibility", default: 1, null: false
|
t.integer "visibility", default: 1, null: false
|
||||||
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
t.index ["user_id"], name: "index_closet_lists_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "colors", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.boolean "basic"
|
t.boolean "basic"
|
||||||
t.boolean "standard"
|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.string "pb_item_name"
|
t.string "pb_item_name"
|
||||||
t.string "pb_item_thumbnail_url"
|
t.string "pb_item_thumbnail_url"
|
||||||
|
t.boolean "standard"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "contributions", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "contributed_type", limit: 8, null: false
|
|
||||||
t.integer "contributed_id", null: false
|
t.integer "contributed_id", null: false
|
||||||
t.integer "user_id", null: false
|
t.string "contributed_type", limit: 8, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
t.index ["contributed_id", "contributed_type"], name: "index_contributions_on_contributed_id_and_contributed_type"
|
||||||
|
t.index ["user_id", "created_at"], name: "index_contributions_on_user_id_and_created_at"
|
||||||
t.index ["user_id"], name: "index_contributions_on_user_id"
|
t.index ["user_id"], name: "index_contributions_on_user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "donation_features", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
t.integer "donation_id", null: false
|
t.integer "donation_id", null: false
|
||||||
t.integer "outfit_id"
|
t.integer "outfit_id"
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "donations", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "amount", null: false
|
t.integer "amount", null: false
|
||||||
|
t.integer "campaign_id", null: false
|
||||||
t.string "charge_id", null: false
|
t.string "charge_id", null: false
|
||||||
t.integer "user_id"
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "donor_email"
|
||||||
t.string "donor_name"
|
t.string "donor_name"
|
||||||
t.string "secret"
|
t.string "secret"
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
t.string "donor_email"
|
t.integer "user_id"
|
||||||
t.integer "campaign_id", null: false
|
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "item_outfit_relationships", charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
|
t.datetime "created_at", precision: nil
|
||||||
|
t.boolean "is_worn"
|
||||||
t.integer "item_id"
|
t.integer "item_id"
|
||||||
t.integer "outfit_id"
|
t.integer "outfit_id"
|
||||||
t.boolean "is_worn"
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
t.datetime "updated_at", precision: nil
|
||||||
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
t.index ["item_id"], name: "index_item_outfit_relationships_on_item_id"
|
||||||
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
t.index ["outfit_id", "is_worn"], name: "index_item_outfit_relationships_on_outfit_id_and_is_worn"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "items", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.text "zones_restrict", size: :medium, null: false
|
t.text "cached_compatible_body_ids", default: ""
|
||||||
t.text "thumbnail_url", size: :long, null: false
|
t.string "cached_occupied_zone_ids", default: ""
|
||||||
|
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
||||||
t.string "category", limit: 50
|
t.string "category", limit: 50
|
||||||
t.string "type", limit: 50
|
|
||||||
t.integer "rarity_index", limit: 2
|
|
||||||
t.integer "price", limit: 3, null: false
|
|
||||||
t.integer "weight_lbs", limit: 2
|
|
||||||
t.text "species_support_ids", size: :long
|
|
||||||
t.datetime "created_at", precision: nil
|
t.datetime "created_at", precision: nil
|
||||||
t.datetime "updated_at", precision: nil
|
t.text "description", size: :medium, null: false
|
||||||
|
t.integer "dyeworks_base_item_id"
|
||||||
t.boolean "explicitly_body_specific", default: false, null: false
|
t.boolean "explicitly_body_specific", default: false, null: false
|
||||||
|
t.boolean "is_manually_nc", default: false, null: false
|
||||||
t.integer "manual_special_color_id"
|
t.integer "manual_special_color_id"
|
||||||
t.column "modeling_status_hint", "enum('done','glitchy')"
|
t.column "modeling_status_hint", "enum('done','glitchy')"
|
||||||
t.boolean "is_manually_nc", default: false, null: false
|
|
||||||
t.string "name", null: false
|
t.string "name", null: false
|
||||||
t.text "description", size: :medium, null: false
|
t.integer "price", limit: 3, null: false
|
||||||
t.string "rarity", default: "", null: false
|
t.string "rarity", default: "", null: false
|
||||||
t.integer "dyeworks_base_item_id"
|
t.integer "rarity_index", limit: 2
|
||||||
t.string "cached_occupied_zone_ids", default: ""
|
t.text "species_support_ids", size: :long
|
||||||
t.text "cached_compatible_body_ids", default: ""
|
t.text "thumbnail_url", size: :long, null: false
|
||||||
t.boolean "cached_predicted_fully_modeled", default: false, null: false
|
t.string "type", limit: 50
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "weight_lbs", limit: 2
|
||||||
|
t.text "zones_restrict", size: :medium, null: false
|
||||||
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
t.index ["dyeworks_base_item_id"], name: "index_items_on_dyeworks_base_item_id"
|
||||||
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
t.index ["modeling_status_hint", "created_at", "id"], name: "items_modeling_status_hint_and_created_at_and_id"
|
||||||
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
t.index ["modeling_status_hint", "created_at"], name: "items_modeling_status_hint_and_created_at"
|
||||||
|
|
@ -150,9 +151,9 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "login_cookies", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "user_id", null: false
|
|
||||||
t.integer "series", null: false
|
t.integer "series", null: false
|
||||||
t.integer "token", null: false
|
t.integer "token", null: false
|
||||||
|
t.integer "user_id", null: false
|
||||||
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
t.index ["user_id", "series"], name: "login_cookies_user_id_and_series"
|
||||||
t.index ["user_id"], name: "login_cookies_user_id"
|
t.index ["user_id"], name: "login_cookies_user_id"
|
||||||
end
|
end
|
||||||
|
|
@ -164,34 +165,34 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
create_table "nc_mall_records", charset: "utf8mb4", collation: "utf8mb4_general_ci", force: :cascade do |t|
|
||||||
t.integer "item_id", null: false
|
t.datetime "created_at", null: false
|
||||||
t.integer "price", null: false
|
|
||||||
t.integer "discount_price"
|
|
||||||
t.datetime "discount_begins_at"
|
t.datetime "discount_begins_at"
|
||||||
t.datetime "discount_ends_at"
|
t.datetime "discount_ends_at"
|
||||||
t.datetime "created_at", null: false
|
t.integer "discount_price"
|
||||||
|
t.integer "item_id", null: false
|
||||||
|
t.integer "price", null: false
|
||||||
t.datetime "updated_at", null: false
|
t.datetime "updated_at", null: false
|
||||||
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
t.index ["item_id"], name: "index_nc_mall_records_on_item_id", unique: true
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "neopets_connections", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "user_id"
|
|
||||||
t.string "neopets_username"
|
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "neopets_username"
|
||||||
t.datetime "updated_at", precision: nil, null: false
|
t.datetime "updated_at", precision: nil, null: false
|
||||||
|
t.integer "user_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "outfits", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "pet_state_id"
|
|
||||||
t.integer "user_id"
|
|
||||||
t.datetime "created_at", precision: nil
|
|
||||||
t.datetime "updated_at", precision: nil
|
|
||||||
t.string "name"
|
|
||||||
t.boolean "starred", default: false, null: false
|
|
||||||
t.string "image"
|
|
||||||
t.string "image_layers_hash"
|
|
||||||
t.boolean "image_enqueued", default: false, null: false
|
|
||||||
t.bigint "alt_style_id"
|
t.bigint "alt_style_id"
|
||||||
|
t.datetime "created_at", precision: nil
|
||||||
|
t.string "image"
|
||||||
|
t.boolean "image_enqueued", default: false, null: false
|
||||||
|
t.string "image_layers_hash"
|
||||||
|
t.string "name"
|
||||||
|
t.integer "pet_state_id"
|
||||||
|
t.boolean "starred", default: false, null: false
|
||||||
|
t.datetime "updated_at", precision: nil
|
||||||
|
t.integer "user_id"
|
||||||
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
t.index ["alt_style_id"], name: "index_outfits_on_alt_style_id"
|
||||||
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
t.index ["pet_state_id"], name: "index_outfits_on_pet_state_id"
|
||||||
t.index ["user_id"], name: "index_outfits_on_user_id"
|
t.index ["user_id"], name: "index_outfits_on_user_id"
|
||||||
|
|
@ -199,40 +200,40 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
|
|
||||||
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "parents_swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "parent_id", null: false
|
t.integer "parent_id", null: false
|
||||||
t.integer "swf_asset_id", null: false
|
|
||||||
t.string "parent_type", limit: 8, null: false
|
t.string "parent_type", limit: 8, null: false
|
||||||
|
t.integer "swf_asset_id", null: false
|
||||||
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
t.index ["parent_id", "parent_type"], name: "index_parents_swf_assets_on_parent_id_and_parent_type"
|
||||||
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
t.index ["parent_id", "swf_asset_id"], name: "unique_parents_swf_assets", unique: true
|
||||||
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
t.index ["swf_asset_id"], name: "parents_swf_assets_swf_asset_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_loads", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "pet_name", limit: 20, null: false
|
|
||||||
t.text "amf", size: :long, null: false
|
t.text "amf", size: :long, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "pet_name", limit: 20, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_states", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "pet_type_id", null: false
|
|
||||||
t.text "swf_asset_ids", size: :medium, null: false
|
|
||||||
t.boolean "female"
|
|
||||||
t.integer "mood_id"
|
|
||||||
t.boolean "unconverted"
|
|
||||||
t.boolean "labeled", default: false, null: false
|
|
||||||
t.boolean "glitched", default: false, null: false
|
|
||||||
t.string "artist_neopets_username"
|
t.string "artist_neopets_username"
|
||||||
t.datetime "created_at"
|
t.datetime "created_at"
|
||||||
|
t.boolean "female"
|
||||||
|
t.boolean "glitched", default: false, null: false
|
||||||
|
t.boolean "labeled", default: false, null: false
|
||||||
|
t.integer "mood_id"
|
||||||
|
t.integer "pet_type_id", null: false
|
||||||
|
t.text "swf_asset_ids", size: :medium, null: false
|
||||||
|
t.boolean "unconverted"
|
||||||
t.datetime "updated_at"
|
t.datetime "updated_at"
|
||||||
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
t.index ["pet_type_id"], name: "pet_states_pet_type_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "pet_types", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "color_id", null: false
|
|
||||||
t.integer "species_id", null: false
|
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.integer "body_id", limit: 2, null: false
|
|
||||||
t.string "image_hash", limit: 8
|
|
||||||
t.string "basic_image_hash"
|
t.string "basic_image_hash"
|
||||||
|
t.integer "body_id", limit: 2, null: false
|
||||||
|
t.integer "color_id", null: false
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.string "image_hash", limit: 8
|
||||||
|
t.integer "species_id", null: false
|
||||||
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
t.index ["body_id", "color_id", "species_id"], name: "pet_types_body_id_and_color_id_and_species_id"
|
||||||
t.index ["body_id"], name: "pet_types_body_id"
|
t.index ["body_id"], name: "pet_types_body_id"
|
||||||
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
t.index ["color_id", "species_id"], name: "pet_types_color_id_and_species_id"
|
||||||
|
|
@ -252,50 +253,50 @@ ActiveRecord::Schema[8.0].define(version: 2025_02_16_041650) do
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "swf_assets", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "type", limit: 7, null: false
|
t.integer "body_id", limit: 2, null: false
|
||||||
|
t.datetime "converted_at", precision: nil
|
||||||
|
t.datetime "created_at", precision: nil, null: false
|
||||||
|
t.boolean "has_image", default: false, null: false
|
||||||
|
t.boolean "image_manual", default: false, null: false
|
||||||
|
t.boolean "image_requested", default: false, null: false
|
||||||
|
t.string "known_glitches", limit: 128, default: ""
|
||||||
|
t.text "manifest", size: :long
|
||||||
|
t.timestamp "manifest_cached_at"
|
||||||
|
t.datetime "manifest_loaded_at"
|
||||||
|
t.integer "manifest_status_code"
|
||||||
|
t.string "manifest_url"
|
||||||
t.integer "remote_id", limit: 3, null: false
|
t.integer "remote_id", limit: 3, null: false
|
||||||
|
t.datetime "reported_broken_at", precision: nil
|
||||||
|
t.string "type", limit: 7, null: false
|
||||||
t.text "url", size: :long, null: false
|
t.text "url", size: :long, null: false
|
||||||
t.integer "zone_id", null: false
|
t.integer "zone_id", null: false
|
||||||
t.text "zones_restrict", size: :medium, null: false
|
t.text "zones_restrict", size: :medium, null: false
|
||||||
t.datetime "created_at", precision: nil, null: false
|
|
||||||
t.integer "body_id", limit: 2, null: false
|
|
||||||
t.boolean "has_image", default: false, null: false
|
|
||||||
t.boolean "image_requested", default: false, null: false
|
|
||||||
t.datetime "reported_broken_at", precision: nil
|
|
||||||
t.datetime "converted_at", precision: nil
|
|
||||||
t.boolean "image_manual", default: false, null: false
|
|
||||||
t.text "manifest", size: :long
|
|
||||||
t.timestamp "manifest_cached_at"
|
|
||||||
t.string "known_glitches", limit: 128, default: ""
|
|
||||||
t.string "manifest_url"
|
|
||||||
t.datetime "manifest_loaded_at"
|
|
||||||
t.integer "manifest_status_code"
|
|
||||||
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
t.index ["body_id"], name: "swf_assets_body_id_and_object_id"
|
||||||
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
t.index ["type", "remote_id"], name: "swf_assets_type_and_id"
|
||||||
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
t.index ["zone_id"], name: "idx_swf_assets_zone_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "users", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.string "name", limit: 30, null: false
|
|
||||||
t.integer "auth_server_id", limit: 1, null: false
|
t.integer "auth_server_id", limit: 1, null: false
|
||||||
t.integer "remote_id", null: false
|
|
||||||
t.integer "points", default: 0, null: false
|
|
||||||
t.boolean "beta", default: false, null: false
|
t.boolean "beta", default: false, null: false
|
||||||
t.string "remember_token"
|
|
||||||
t.datetime "remember_created_at", precision: nil
|
|
||||||
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
|
||||||
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
|
||||||
t.integer "contact_neopets_connection_id"
|
t.integer "contact_neopets_connection_id"
|
||||||
t.timestamp "last_trade_activity_at"
|
t.timestamp "last_trade_activity_at"
|
||||||
t.boolean "support_staff", default: false, null: false
|
t.string "name", limit: 30, null: false
|
||||||
|
t.integer "owned_closet_hangers_visibility", default: 1, null: false
|
||||||
|
t.integer "points", default: 0, null: false
|
||||||
|
t.datetime "remember_created_at", precision: nil
|
||||||
|
t.string "remember_token"
|
||||||
|
t.integer "remote_id", null: false
|
||||||
t.boolean "shadowbanned", default: false, null: false
|
t.boolean "shadowbanned", default: false, null: false
|
||||||
|
t.boolean "support_staff", default: false, null: false
|
||||||
|
t.integer "wanted_closet_hangers_visibility", default: 1, null: false
|
||||||
end
|
end
|
||||||
|
|
||||||
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
create_table "zones", id: :integer, charset: "utf8mb4", collation: "utf8mb4_unicode_520_ci", force: :cascade do |t|
|
||||||
t.integer "depth"
|
t.integer "depth"
|
||||||
t.integer "type_id"
|
|
||||||
t.string "label", null: false
|
t.string "label", null: false
|
||||||
t.string "plain_label", null: false
|
t.string "plain_label", null: false
|
||||||
|
t.integer "type_id"
|
||||||
end
|
end
|
||||||
|
|
||||||
add_foreign_key "alt_styles", "colors"
|
add_foreign_key "alt_styles", "colors"
|
||||||
|
|
|
||||||
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"
|
mode: "755"
|
||||||
state: directory
|
state: directory
|
||||||
|
|
||||||
- name: Remove 10min cron job to run `rails nc_mall:sync`
|
- name: Create 2min cron job to run `rails items:auto_model`
|
||||||
become_user: impress
|
become_user: impress
|
||||||
cron:
|
cron:
|
||||||
state: absent
|
name: "Impress: auto-model items"
|
||||||
name: "Impress: sync NC Mall data"
|
minute: "*/2"
|
||||||
minute: "*/10"
|
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails items:auto_model'"
|
||||||
job: "bash -c 'source /etc/profile && source ~/.bash_profile && cd /srv/impress/current && bin/rails nc_mall:sync'"
|
|
||||||
|
|
||||||
- name: Create 10min cron job to run `rails neopets:import`
|
- name: Create 10min cron job to run `rails neopets:import`
|
||||||
become_user: impress
|
become_user: impress
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
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
|
item
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "#same_wardrobe_state_as?" do
|
||||||
|
it "returns true for outfits with identical state" do
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true even when names differ (name is not part of wardrobe state)" do
|
||||||
|
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
|
||||||
|
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when poses differ" do
|
||||||
|
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when worn items differ" do
|
||||||
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns true regardless of worn item order" do
|
||||||
|
hat = create_item("Hat", zones(:hat1))
|
||||||
|
shirt = create_item("Shirt", zones(:shirtdress))
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when species differ" do
|
||||||
|
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
|
||||||
|
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
|
||||||
|
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
it "returns false when alt styles differ" do
|
||||||
|
alt_style = AltStyle.create!(
|
||||||
|
species: acara,
|
||||||
|
color: blue,
|
||||||
|
body_id: 999,
|
||||||
|
series_name: "Nostalgic",
|
||||||
|
thumbnail_url: "https://images.neopets.example/alt.png"
|
||||||
|
)
|
||||||
|
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
|
||||||
|
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||||
|
|
||||||
|
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||||
|
end
|
||||||
|
|
||||||
|
end
|
||||||
|
|
||||||
describe "#visible_layers" do
|
describe "#visible_layers" do
|
||||||
before do
|
before do
|
||||||
# Clean up any existing pet types to avoid conflicts
|
# Clean up any existing pet types to avoid conflicts
|
||||||
|
|
|
||||||
293
spec/models/user_spec.rb
Normal file
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