Compare commits
62 commits
main
...
feature/wa
| Author | SHA1 | Date | |
|---|---|---|---|
| f4b1309149 | |||
| f13481783d | |||
| 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 | |||
| b03b32c538 | |||
| f545510edc | |||
| cbf69e1189 | |||
| 9ea48f6e8c | |||
| 812e8226bb | |||
| 955aeb984e | |||
| 74386b45d7 | |||
| ba0612b694 | |||
| b36b1577b5 | |||
| fb8fb4b27e | |||
| 02a64ef639 | |||
| d23f16c217 | |||
| 7459037c8a | |||
| 6eace54c34 | |||
| 76496f8a6d | |||
| 78931ddb47 | |||
| 811bb3e036 | |||
| ab46d90d6a | |||
| e72a0ec72f | |||
| c4290980ed | |||
| 80db7ad3bf | |||
| 481fbce6ce | |||
| 88797bc165 | |||
| 079bcc8d1d | |||
| f4417f7fb0 | |||
| e8d768961b | |||
| dad185150c | |||
| f96569b2bf | |||
| 58fabad3c2 | |||
| ddb89dc2fa | |||
| 14298fafa9 | |||
| 2dc5505147 | |||
| 0651a2871c | |||
| a00d57bcbb | |||
| 276cc1b5ea |
44 changed files with 3444 additions and 124 deletions
27
app/assets/javascripts/auto-submit-form.js
Normal file
27
app/assets/javascripts/auto-submit-form.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/**
|
||||
* AutoSubmitForm web component
|
||||
*
|
||||
* Generic progressive enhancement for forms that should auto-submit on change:
|
||||
* - Listens for `change` events on descendant form inputs
|
||||
* - Calls `requestSubmit()` on the nearest `<form>`
|
||||
* - Exposes `:state(auto-loading)` to hide fallback submit buttons via CSS
|
||||
*/
|
||||
class AutoSubmitForm extends HTMLElement {
|
||||
#internals;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.#internals = this.attachInternals();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.addEventListener("change", this.#handleChange);
|
||||
this.#internals.states.add("auto-loading");
|
||||
}
|
||||
|
||||
#handleChange(e) {
|
||||
e.target.closest("form")?.requestSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("auto-submit-form", AutoSubmitForm);
|
||||
|
|
@ -4,7 +4,7 @@ document.addEventListener("change", (e) => {
|
|||
|
||||
try {
|
||||
const mainPickerForm = document.querySelector(
|
||||
"#item-preview species-color-picker form",
|
||||
"#item-preview .species-color-picker form",
|
||||
);
|
||||
const mainSpeciesField = mainPickerForm.querySelector(
|
||||
"[name='preview[species_id]']",
|
||||
|
|
@ -24,24 +24,6 @@ document.addEventListener("turbo:frame-missing", (e) => {
|
|||
e.preventDefault();
|
||||
});
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
class SpeciesFacePicker extends HTMLElement {
|
||||
connectedCallback() {
|
||||
|
|
@ -109,7 +91,6 @@ class MeasuredContainer extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
customElements.define("species-color-picker", SpeciesColorPicker);
|
||||
customElements.define("species-face-picker", SpeciesFacePicker);
|
||||
customElements.define("species-face-picker-options", SpeciesFacePickerOptions);
|
||||
customElements.define("measured-container", MeasuredContainer);
|
||||
|
|
|
|||
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,5 +1,7 @@
|
|||
class OutfitViewer extends HTMLElement {
|
||||
#internals;
|
||||
#isPlaying = true; // Track playing state internally (Safari CustomStateSet bug workaround)
|
||||
#hasAnimations = false; // Track hasAnimations state internally (Safari CustomStateSet bug workaround)
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -7,26 +9,113 @@ class OutfitViewer extends HTMLElement {
|
|||
}
|
||||
|
||||
connectedCallback() {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
// When a layer is added, update its playing state to match ours.
|
||||
const addedLayers = mutations
|
||||
.flatMap(m => [...m.addedNodes])
|
||||
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||
for (const layer of addedLayers) {
|
||||
if (this.#internals.states.has("playing")) {
|
||||
layer.play();
|
||||
} else {
|
||||
layer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
const removedLayers = mutations
|
||||
.flatMap(m => [...m.removedNodes])
|
||||
.filter(n => n.tagName === "OUTFIT-LAYER");
|
||||
|
||||
// If any layers were added or removed, updated our hasAnimations state.
|
||||
if (addedLayers.length > 0 || removedLayers.length > 0) {
|
||||
this.#updateHasAnimations();
|
||||
}
|
||||
});
|
||||
observer.observe(this, { childList: true });
|
||||
|
||||
// When a new layer finishes loading and determines it has animations, update.
|
||||
this.addEventListener("hasanimationschange", (e) => {
|
||||
// Only handle events from outfit-layer children, not from ourselves
|
||||
if (e.target === this) return;
|
||||
|
||||
this.#updateHasAnimations();
|
||||
});
|
||||
|
||||
// The `<outfit-layer>` is connected to the DOM right before its
|
||||
// children are. So, to engage with the children, wait a tick!
|
||||
setTimeout(() => this.#connectToChildren(), 0);
|
||||
}
|
||||
|
||||
#connectToChildren() {
|
||||
const playPauseToggle = document.querySelector(".play-pause-toggle");
|
||||
// Read initial playing state from cookie and initialize
|
||||
const isPlayingFromCookie = this.#getIsPlayingCookie();
|
||||
|
||||
// Read our initial playing state from the toggle, and subscribe to changes.
|
||||
this.#setIsPlaying(playPauseToggle.checked);
|
||||
playPauseToggle.addEventListener("change", () => {
|
||||
this.#setIsPlaying(playPauseToggle.checked);
|
||||
this.#setIsPlayingCookie(playPauseToggle.checked);
|
||||
});
|
||||
// Initialize the boolean before calling #setIsPlaying
|
||||
// (We set it to the opposite first so #setIsPlaying detects a change)
|
||||
this.#isPlaying = !isPlayingFromCookie;
|
||||
this.#setIsPlaying(isPlayingFromCookie);
|
||||
|
||||
// Check initial animation state
|
||||
this.#updateHasAnimations();
|
||||
}
|
||||
|
||||
#updateHasAnimations() {
|
||||
// Check if any layer has animations
|
||||
const hasAnimations =
|
||||
this.querySelector("outfit-layer:state(has-animations)") !== null;
|
||||
|
||||
// Check if state actually changed
|
||||
if (hasAnimations === this.#hasAnimations) {
|
||||
return; // No change, skip
|
||||
}
|
||||
|
||||
// Update internal state
|
||||
this.#hasAnimations = hasAnimations;
|
||||
if (hasAnimations) {
|
||||
this.#internals.states.add("has-animations");
|
||||
} else {
|
||||
this.#internals.states.delete("has-animations");
|
||||
}
|
||||
|
||||
// Dispatch event so external components can react
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hasanimationschange", {
|
||||
detail: { hasAnimations },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Start playing animations
|
||||
*/
|
||||
play() {
|
||||
this.#setIsPlaying(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Pause animations
|
||||
*/
|
||||
pause() {
|
||||
this.#setIsPlaying(false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public API: Check if currently playing
|
||||
*/
|
||||
get isPlaying() {
|
||||
return this.#isPlaying;
|
||||
}
|
||||
|
||||
#setIsPlaying(isPlaying) {
|
||||
// TODO: Listen for changes to the child list, and add `playing` when new
|
||||
// nodes arrive, if playing.
|
||||
const thirtyDays = 60 * 60 * 24 * 30;
|
||||
// Skip if already in this state
|
||||
if (this.#isPlaying === isPlaying) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update internal state (both boolean and CustomStateSet for CSS)
|
||||
this.#isPlaying = isPlaying;
|
||||
|
||||
if (isPlaying) {
|
||||
this.#internals.states.add("playing");
|
||||
for (const layer of this.querySelectorAll("outfit-layer")) {
|
||||
|
|
@ -38,6 +127,27 @@ class OutfitViewer extends HTMLElement {
|
|||
layer.pause();
|
||||
}
|
||||
}
|
||||
|
||||
// Persist to cookie
|
||||
this.#setIsPlayingCookie(isPlaying);
|
||||
|
||||
// Dispatch event so external components can react
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("playingchange", {
|
||||
detail: { isPlaying },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#getIsPlayingCookie() {
|
||||
const cookie = document.cookie
|
||||
.split("; ")
|
||||
.find((row) => row.startsWith("DTIOutfitViewerIsPlaying="));
|
||||
if (cookie) {
|
||||
return cookie.split("=")[1] !== "false";
|
||||
}
|
||||
return true; // Default to playing
|
||||
}
|
||||
|
||||
#setIsPlayingCookie(isPlaying) {
|
||||
|
|
@ -49,6 +159,7 @@ class OutfitViewer extends HTMLElement {
|
|||
|
||||
class OutfitLayer extends HTMLElement {
|
||||
#internals;
|
||||
#pendingMessage = null; // Queue one message to send when iframe loads
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
|
@ -105,6 +216,12 @@ class OutfitLayer extends HTMLElement {
|
|||
this.#sendMessageToIframe({ type: "requestStatus" });
|
||||
window.addEventListener("message", (m) => this.#onMessage(m));
|
||||
this.iframe.addEventListener("error", () => this.#setStatus("error"));
|
||||
|
||||
// If there was a pending play/pause message, send it now
|
||||
if (this.#pendingMessage) {
|
||||
this.#sendMessageToIframe(this.#pendingMessage);
|
||||
this.#pendingMessage = null;
|
||||
}
|
||||
} else {
|
||||
console.warn(`<outfit-layer> contained no image or iframe: `, this);
|
||||
}
|
||||
|
|
@ -126,7 +243,7 @@ class OutfitLayer extends HTMLElement {
|
|||
} else {
|
||||
throw new Error(
|
||||
`<outfit-layer> got unexpected status: ` +
|
||||
JSON.stringify(data.status),
|
||||
JSON.stringify(data.status),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
|
@ -151,24 +268,45 @@ class OutfitLayer extends HTMLElement {
|
|||
* Set whether CSS selector `:state(has-animations)` matches this element.
|
||||
*/
|
||||
#setHasAnimations(hasAnimations) {
|
||||
// Check if state actually changed
|
||||
const hadAnimations = this.#internals.states.has("has-animations");
|
||||
if (hasAnimations === hadAnimations) {
|
||||
return; // No change, skip
|
||||
}
|
||||
|
||||
// Update internal state
|
||||
if (hasAnimations) {
|
||||
this.#internals.states.add("has-animations");
|
||||
} else {
|
||||
this.#internals.states.delete("has-animations");
|
||||
}
|
||||
|
||||
// Dispatch event so parent OutfitViewer can react
|
||||
this.dispatchEvent(
|
||||
new CustomEvent("hasanimationschange", {
|
||||
detail: { hasAnimations },
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#sendMessageToIframe(message) {
|
||||
// If we have no frame or it hasn't loaded, ignore this message.
|
||||
// If we have no frame, queue play/pause messages for later
|
||||
if (this.iframe == null) {
|
||||
if (message.type === "play" || message.type === "pause") {
|
||||
this.#pendingMessage = message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.iframe.contentWindow == null) {
|
||||
console.debug(
|
||||
`Ignoring message, frame not loaded yet: `,
|
||||
`Queueing message, frame not loaded yet: `,
|
||||
this.iframe,
|
||||
message,
|
||||
);
|
||||
if (message.type === "play" || message.type === "pause") {
|
||||
this.#pendingMessage = message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -177,8 +315,119 @@ class OutfitLayer extends HTMLElement {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OutfitViewerPlayPauseToggle is a web component that creates a play/pause
|
||||
* toggle button for an outfit-viewer, referenced by the `for` attribute.
|
||||
*
|
||||
* Usage:
|
||||
* <outfit-viewer id="my-viewer">...</outfit-viewer>
|
||||
* <outfit-viewer-play-pause-toggle for="my-viewer">
|
||||
* <input type="checkbox" class="toggle-input">
|
||||
* <span class="playing-label">Playing</span>
|
||||
* <span class="paused-label">Paused</span>
|
||||
* </outfit-viewer-play-pause-toggle>
|
||||
*
|
||||
* The toggle will:
|
||||
* - Read initial state from the outfit-viewer
|
||||
* - Update the checkbox when the outfit-viewer state changes
|
||||
* - Call play()/pause() on the outfit-viewer when the checkbox changes
|
||||
* - Show/hide based on whether the outfit has animations
|
||||
*/
|
||||
class OutfitViewerPlayPauseToggle extends HTMLElement {
|
||||
#outfitViewer = null;
|
||||
#checkbox = null;
|
||||
|
||||
connectedCallback() {
|
||||
setTimeout(() => this.#connect(), 0);
|
||||
}
|
||||
|
||||
#connect() {
|
||||
// Find the outfit-viewer referenced by the `for` attribute
|
||||
const forId = this.getAttribute("for");
|
||||
if (!forId) {
|
||||
console.warn(
|
||||
"<outfit-viewer-play-pause-toggle> requires a 'for' attribute",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#outfitViewer = document.getElementById(forId);
|
||||
if (!this.#outfitViewer) {
|
||||
console.warn(
|
||||
`<outfit-viewer-play-pause-toggle> could not find outfit-viewer with id="${forId}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the checkbox input
|
||||
this.#checkbox = this.querySelector('input[type="checkbox"]');
|
||||
if (!this.#checkbox) {
|
||||
console.warn(
|
||||
"<outfit-viewer-play-pause-toggle> requires a checkbox input child",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sync initial state from outfit-viewer to checkbox
|
||||
this.#syncFromOutfitViewer();
|
||||
|
||||
// Listen for checkbox changes and update outfit-viewer
|
||||
this.#checkbox.addEventListener("change", () => {
|
||||
if (this.#checkbox.checked) {
|
||||
this.#outfitViewer.play();
|
||||
} else {
|
||||
this.#outfitViewer.pause();
|
||||
}
|
||||
});
|
||||
|
||||
// Listen for outfit-viewer state changes and update checkbox
|
||||
this.#outfitViewer.addEventListener("playingchange", (e) => {
|
||||
this.#checkbox.checked = e.detail.isPlaying;
|
||||
});
|
||||
|
||||
// Listen for animation availability changes and update visibility
|
||||
this.#outfitViewer.addEventListener("hasanimationschange", (e) => {
|
||||
this.#updateVisibility(e.detail.hasAnimations);
|
||||
});
|
||||
|
||||
// Set initial visibility based on current state
|
||||
this.#updateVisibility(
|
||||
this.#outfitViewer.querySelector(
|
||||
"outfit-layer:state(has-animations)",
|
||||
) !== null,
|
||||
);
|
||||
|
||||
// After a Turbo morph, Idiomorph may remove our `hidden` attribute
|
||||
// (since the server HTML never includes it). Re-apply visibility
|
||||
// based on the current animation state.
|
||||
document.addEventListener("turbo:render", () => {
|
||||
this.#syncFromOutfitViewer();
|
||||
});
|
||||
}
|
||||
|
||||
#syncFromOutfitViewer() {
|
||||
if (this.#outfitViewer && this.#checkbox) {
|
||||
this.#checkbox.checked = this.#outfitViewer.isPlaying;
|
||||
}
|
||||
}
|
||||
|
||||
#updateVisibility(hasAnimations) {
|
||||
// Show/hide the toggle based on whether there are animations
|
||||
// Use a custom state so CSS can control the display
|
||||
if (hasAnimations) {
|
||||
this.removeAttribute("hidden");
|
||||
} else {
|
||||
this.setAttribute("hidden", "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("outfit-viewer", OutfitViewer);
|
||||
customElements.define("outfit-layer", OutfitLayer);
|
||||
customElements.define(
|
||||
"outfit-viewer-play-pause-toggle",
|
||||
OutfitViewerPlayPauseToggle,
|
||||
);
|
||||
|
||||
// Morph turbo-frames on this page, to reuse asset nodes when we want to—very
|
||||
// important for movies!—but ensure that it *doesn't* do its usual behavior of
|
||||
|
|
@ -196,7 +445,7 @@ function morphWithOutfitLayers(currentElement, newElement) {
|
|||
if (
|
||||
newNode.tagName === "OUTFIT-LAYER" &&
|
||||
newNode.getAttribute("data-asset-id") !==
|
||||
currentNode.getAttribute("data-asset-id")
|
||||
currentNode.getAttribute("data-asset-id")
|
||||
) {
|
||||
currentNode.replaceWith(newNode);
|
||||
return false;
|
||||
|
|
@ -205,10 +454,19 @@ function morphWithOutfitLayers(currentElement, newElement) {
|
|||
},
|
||||
});
|
||||
}
|
||||
addEventListener("turbo:before-frame-render", (event) => {
|
||||
|
||||
function onTurboRender(event) {
|
||||
// Rather than enforce Idiomorph must be loaded, let's just be resilient
|
||||
// and only bother if we have it. (Replacing content is not *that* bad!)
|
||||
if (typeof Idiomorph !== "undefined") {
|
||||
event.detail.render = (a, b) => morphWithOutfitLayers(a, b);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// On most pages, we only apply this to Turbo frames, to be conservative. (Morphing the whole page is hard!)
|
||||
addEventListener("turbo:before-frame-render", onTurboRender);
|
||||
|
||||
// But on pages that opt into it (namely the wardrobe), we do it for the full page too.
|
||||
if (document.querySelector('meta[name=outfit-viewer-morph-mode][value=full-page]') !== null) {
|
||||
addEventListener("turbo:before-render", onTurboRender);
|
||||
}
|
||||
|
|
|
|||
32
app/assets/javascripts/pose-picker.js
Normal file
32
app/assets/javascripts/pose-picker.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/**
|
||||
* PosePickerPopover web component
|
||||
*
|
||||
* Scrolls the selected style into view when the style picker list becomes
|
||||
* visible (e.g. tab switch or popover open).
|
||||
*/
|
||||
class PosePickerPopover extends HTMLElement {
|
||||
#styleListObserver;
|
||||
|
||||
connectedCallback() {
|
||||
// When the style picker list becomes visible (e.g. tab switch or
|
||||
// popover open), scroll the selected style into view.
|
||||
const styleList = this.querySelector(".style-picker-list");
|
||||
if (styleList) {
|
||||
this.#styleListObserver = new IntersectionObserver(([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
const checked = styleList.querySelector("input:checked");
|
||||
checked
|
||||
?.closest("label")
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
});
|
||||
this.#styleListObserver.observe(styleList);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#styleListObserver?.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define("pose-picker-popover", PosePickerPopover);
|
||||
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();
|
||||
}
|
||||
});
|
||||
36
app/assets/javascripts/wardrobe/show.js
Normal file
36
app/assets/javascripts/wardrobe/show.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Wardrobe v2 - Simple Rails+Turbo outfit editor
|
||||
//
|
||||
// This page uses Turbo for instant updates when changing species/color.
|
||||
// The outfit_viewer Web Component handles the pet rendering.
|
||||
|
||||
// Unsaved changes warning: use a MutationObserver to watch the
|
||||
// data-has-unsaved-changes attribute on the wardrobe container. This is more
|
||||
// robust than event listeners because it works regardless of how the DOM is
|
||||
// updated (Turbo morph, direct manipulation, etc.).
|
||||
function setupUnsavedChangesObserver() {
|
||||
const container = document.querySelector("[data-has-unsaved-changes]");
|
||||
if (!container) return;
|
||||
|
||||
function update() {
|
||||
if (container.dataset.hasUnsavedChanges === "true") {
|
||||
window.onbeforeunload = (e) => {
|
||||
e.preventDefault();
|
||||
return "";
|
||||
};
|
||||
} else {
|
||||
window.onbeforeunload = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Set initial state
|
||||
update();
|
||||
|
||||
// Watch for attribute changes
|
||||
const observer = new MutationObserver(update);
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
attributeFilter: ["data-has-unsaved-changes"],
|
||||
});
|
||||
}
|
||||
|
||||
setupUnsavedChangesObserver();
|
||||
32
app/assets/stylesheets/application/item-badges.css
Normal file
32
app/assets/stylesheets/application/item-badges.css
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/*
|
||||
* Shared item badge styles for NC/NP/PB badges
|
||||
* Used across item pages, wardrobe, search results, etc.
|
||||
*
|
||||
* These colors are from DTI 2020, based on Chakra UI's color palette.
|
||||
*/
|
||||
|
||||
.item-badge {
|
||||
padding: 0.25em 0.5em;
|
||||
border-radius: 0.25em;
|
||||
text-decoration: none;
|
||||
font-size: .75rem;
|
||||
font-weight: bold;
|
||||
line-height: 1;
|
||||
|
||||
background: #E2E8F0;
|
||||
color: #1A202C;
|
||||
|
||||
&[data-item-kind="nc"] {
|
||||
background: #E9D8FD;
|
||||
color: #44337A;
|
||||
}
|
||||
|
||||
&[data-item-kind="pb"] {
|
||||
background: #FEEBC8;
|
||||
color: #7B341E;
|
||||
}
|
||||
|
||||
.icon {
|
||||
vertical-align: middle;
|
||||
}
|
||||
}
|
||||
|
|
@ -46,6 +46,7 @@ outfit-viewer
|
|||
img, iframe
|
||||
width: 100%
|
||||
height: 100%
|
||||
border: 0
|
||||
|
||||
.loading-indicator
|
||||
position: absolute
|
||||
|
|
@ -72,6 +73,7 @@ outfit-viewer
|
|||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
cursor: pointer
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
|
|
@ -102,7 +104,12 @@ outfit-viewer
|
|||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
&:has(outfit-layer:state(has-animations))
|
||||
// Hide the play-pause toggle when there are no animations
|
||||
outfit-viewer-play-pause-toggle[hidden]
|
||||
display: none
|
||||
|
||||
// Show the play-pause button when visible
|
||||
outfit-viewer-play-pause-toggle:not([hidden])
|
||||
.play-pause-button
|
||||
display: flex
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@
|
|||
.preview-area
|
||||
margin: 0 auto
|
||||
position: relative
|
||||
width: 300px
|
||||
height: 300px
|
||||
|
||||
.customize-more
|
||||
position: absolute
|
||||
|
|
@ -107,7 +109,7 @@ outfit-viewer
|
|||
.error-indicator
|
||||
display: block
|
||||
|
||||
species-color-picker
|
||||
.species-color-picker
|
||||
.error-icon
|
||||
cursor: help
|
||||
margin-right: .25em
|
||||
|
|
@ -128,7 +130,7 @@ species-color-picker
|
|||
animation-delay: .75s
|
||||
|
||||
// Once the auto-loading behavior is ready, remove the submit button.
|
||||
&:state(auto-loading)
|
||||
auto-submit-form:state(auto-loading)
|
||||
input[type=submit]
|
||||
display: none
|
||||
|
||||
|
|
@ -287,11 +289,14 @@ species-face-picker
|
|||
|
||||
.preview-area
|
||||
grid-area: viewer
|
||||
width: 380px
|
||||
height: 380px
|
||||
|
||||
outfit-viewer
|
||||
width: 380px
|
||||
height: 380px
|
||||
|
||||
species-color-picker
|
||||
.species-color-picker
|
||||
grid-area: picker
|
||||
|
||||
species-face-picker
|
||||
|
|
@ -302,6 +307,52 @@ species-face-picker
|
|||
.item-preview-meta-info
|
||||
grid-area: meta
|
||||
|
||||
.play-pause-button
|
||||
position: absolute
|
||||
z-index: 1001
|
||||
left: 8px
|
||||
bottom: 8px
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
color: white
|
||||
background: rgba(0, 0, 0, 0.64)
|
||||
width: 2.5em
|
||||
height: 2.5em
|
||||
border-radius: 100%
|
||||
border: 2px solid transparent
|
||||
transition: all .25s
|
||||
cursor: pointer
|
||||
|
||||
.playing-label, .paused-label
|
||||
display: none
|
||||
width: 1em
|
||||
height: 1em
|
||||
|
||||
input[type=checkbox]
|
||||
// Visually hidden
|
||||
clip: rect(0 0 0 0)
|
||||
clip-path: inset(50%)
|
||||
height: 1px
|
||||
overflow: hidden
|
||||
position: absolute
|
||||
white-space: nowrap
|
||||
width: 1px
|
||||
|
||||
&:checked ~ .playing-label
|
||||
display: block
|
||||
|
||||
&:not(:checked) ~ .paused-label
|
||||
display: block
|
||||
|
||||
&:hover, &:has(.play-pause-toggle:focus)
|
||||
border: 2px solid $module-border-color
|
||||
background: $module-bg-color
|
||||
color: $text-color
|
||||
|
||||
&:has(.play-pause-toggle:active)
|
||||
transform: translateY(2px)
|
||||
|
||||
@keyframes fade-in
|
||||
from
|
||||
opacity: 0
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
@import "../partials/clean/constants"
|
||||
@import "../partials/clean/mixins"
|
||||
@import "../application/item-badges"
|
||||
|
||||
=item-header
|
||||
border-bottom: 1px solid $module-border-color
|
||||
|
|
@ -27,7 +28,7 @@
|
|||
text-align: left
|
||||
line-height: 100%
|
||||
margin-bottom: 0
|
||||
|
||||
|
||||
.item-links
|
||||
grid-area: links
|
||||
|
||||
|
|
@ -41,32 +42,6 @@
|
|||
abbr
|
||||
cursor: help
|
||||
|
||||
.item-kind, .first-seen-at
|
||||
padding: .25em .5em
|
||||
border-radius: .25em
|
||||
|
||||
text-decoration: none
|
||||
font-weight: bold
|
||||
line-height: 1
|
||||
|
||||
background: #E2E8F0
|
||||
color: #1A202C
|
||||
|
||||
.icon
|
||||
vertical-align: middle
|
||||
|
||||
.item-kind
|
||||
// These colors are copied from DTI 2020, for initial consistency!
|
||||
// They're based on the Chakra UI colors, which I think are in turn the
|
||||
// Bootstrap colors? Or something?
|
||||
// NOTE: For the data-type=np case, we use the default gray colors.
|
||||
&[data-type=nc]
|
||||
background: #E9D8FD
|
||||
color: #44337A
|
||||
&[data-type=pb]
|
||||
background: #FEEBC8
|
||||
color: #7B341E
|
||||
|
||||
.support-form
|
||||
grid-area: support
|
||||
font-size: 85%
|
||||
|
|
@ -100,21 +75,21 @@
|
|||
font-size: 150%
|
||||
font-weight: bold
|
||||
margin-bottom: .75em
|
||||
|
||||
|
||||
.closet-hangers-ownership-groups
|
||||
+clearfix
|
||||
margin-bottom: .5em
|
||||
|
||||
|
||||
div
|
||||
float: left
|
||||
margin: 0 5%
|
||||
text-align: left
|
||||
width: 40%
|
||||
|
||||
|
||||
li
|
||||
list-style: none
|
||||
word-wrap: break-word
|
||||
|
||||
|
||||
label.unlisted
|
||||
font-style: italic
|
||||
|
||||
|
|
|
|||
1076
app/assets/stylesheets/wardrobe/show.css
Normal file
1076
app/assets/stylesheets/wardrobe/show.css
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -6,9 +6,18 @@ class OutfitsController < ApplicationController
|
|||
@outfit.user = current_user
|
||||
|
||||
if @outfit.save
|
||||
render :json => @outfit
|
||||
respond_to do |format|
|
||||
format.html { redirect_to wardrobe_v2_outfit_path(@outfit) }
|
||||
format.json { render json: @outfit }
|
||||
end
|
||||
else
|
||||
render_outfit_errors
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back fallback_location: wardrobe_v2_path,
|
||||
alert: @outfit.errors.full_messages.join(", ")
|
||||
end
|
||||
format.json { render_outfit_errors }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -87,7 +96,7 @@ class OutfitsController < ApplicationController
|
|||
end
|
||||
|
||||
@species_count = Species.count
|
||||
|
||||
|
||||
@latest_contribution = Contribution.recent.first
|
||||
Contribution.preload_contributeds_and_parents([@latest_contribution].compact)
|
||||
|
||||
|
|
@ -96,6 +105,7 @@ class OutfitsController < ApplicationController
|
|||
@campaign = Fundraising::Campaign.current rescue nil
|
||||
end
|
||||
|
||||
|
||||
def show
|
||||
@outfit = Outfit.find(params[:id])
|
||||
|
||||
|
|
@ -122,9 +132,25 @@ class OutfitsController < ApplicationController
|
|||
|
||||
def update
|
||||
if @outfit.update(outfit_params)
|
||||
render :json => @outfit
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
return_to = params[:return_to]
|
||||
if return_to.present? && return_to.start_with?("/") && !return_to.start_with?("//")
|
||||
redirect_to return_to
|
||||
else
|
||||
redirect_to wardrobe_v2_outfit_path(@outfit)
|
||||
end
|
||||
end
|
||||
format.json { render json: @outfit }
|
||||
end
|
||||
else
|
||||
render_outfit_errors
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
redirect_back fallback_location: wardrobe_v2_outfit_path(@outfit),
|
||||
alert: @outfit.errors.full_messages.join(", ")
|
||||
end
|
||||
format.json { render_outfit_errors }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -180,5 +206,6 @@ class OutfitsController < ApplicationController
|
|||
:full_error_messages => @outfit.errors.full_messages},
|
||||
:status => :bad_request
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
|
|
|
|||
189
app/controllers/wardrobe_controller.rb
Normal file
189
app/controllers/wardrobe_controller.rb
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
class WardrobeController < ApplicationController
|
||||
prepend_view_path Rails.root.join("app/views/wardrobe")
|
||||
|
||||
def show
|
||||
# Load saved outfit if an ID is provided (e.g. /outfits/:id/v2)
|
||||
@saved_outfit = Outfit.find(params[:id]) if params[:id].present?
|
||||
|
||||
# If visiting a saved outfit with no state params, redirect with the
|
||||
# outfit's state as query params. This keeps URL-as-source-of-truth simple:
|
||||
# the rest of the action always reads from params.
|
||||
if @saved_outfit && !outfit_state_params_present?
|
||||
redirect_to wardrobe_v2_outfit_path(@saved_outfit, **@saved_outfit.wardrobe_params)
|
||||
return
|
||||
end
|
||||
|
||||
# Set the form target path for all wardrobe forms
|
||||
@wardrobe_path = @saved_outfit ? wardrobe_v2_outfit_path(@saved_outfit) : wardrobe_v2_path
|
||||
|
||||
# Get selected species and color from params, or default to Blue Acara
|
||||
@selected_species = params[:species] ? Species.find_by_id(params[:species]) : Species.find_by_name("Acara")
|
||||
@selected_color = params[:color] ? Color.find_by_id(params[:color]) : Color.find_by_name("Blue")
|
||||
|
||||
# Load valid colors for the selected species (colors that have existing pet types)
|
||||
@species = Species.alphabetical
|
||||
@colors = @selected_species.compatible_colors
|
||||
|
||||
# Find the best pet type for this species+color combo
|
||||
# If the exact combo doesn't exist, this will fall back to a simple color
|
||||
@pet_type = PetType.for_species_and_color(
|
||||
species_id: @selected_species.id,
|
||||
color_id: @selected_color.id
|
||||
)
|
||||
|
||||
# Use the pet type's actual color as the selected color
|
||||
# (might differ from requested color if we fell back to a simple color)
|
||||
@selected_color = @pet_type&.color
|
||||
|
||||
# Get the selected pose from params, or default to nil (will use canonical)
|
||||
@selected_pose = params[:pose]
|
||||
|
||||
# Find the pet state for the selected pose, or use canonical
|
||||
@pet_state = if @pet_type && @selected_pose.present?
|
||||
@pet_type.pet_states.with_pose(@selected_pose).first || @pet_type.canonical_pet_state
|
||||
else
|
||||
@pet_type&.canonical_pet_state
|
||||
end
|
||||
|
||||
# If we found a pet_state, use its actual pose as the selected pose
|
||||
@selected_pose = @pet_state&.pose
|
||||
|
||||
# Load all available poses for this pet type (for the pose picker)
|
||||
@available_poses = @pet_type ? available_poses_for(@pet_type) : {}
|
||||
|
||||
# Preload the layers for all available poses so the thumbnails render efficiently
|
||||
if @pet_type
|
||||
pose_pet_states = @available_poses.values.compact
|
||||
SwfAsset.preload_manifests(pose_pet_states.flat_map(&:swf_assets))
|
||||
end
|
||||
|
||||
# Load alt style from params, scoped to the current species
|
||||
@alt_style = if params[:style].present? && @selected_species
|
||||
AltStyle.where(species_id: @selected_species.id).find_by(id: params[:style])
|
||||
end
|
||||
|
||||
# Load all available alt styles for this species (for the style picker)
|
||||
@available_alt_styles = @selected_species ?
|
||||
AltStyle.where(species_id: @selected_species.id).by_name_grouped : []
|
||||
|
||||
# Load items from the objects[] and closet[] parameters
|
||||
worn_item_ids = params[:objects] || []
|
||||
closeted_item_ids = params[:closet] || []
|
||||
worn_items = Item.where(id: worn_item_ids)
|
||||
closeted_items = Item.where(id: closeted_item_ids)
|
||||
|
||||
# Build the outfit
|
||||
@outfit = Outfit.new(
|
||||
name: @saved_outfit ? @saved_outfit.name : (params[:name].presence || "Untitled outfit"),
|
||||
pet_state: @pet_state,
|
||||
alt_style: @alt_style,
|
||||
worn_items: worn_items,
|
||||
closeted_items: closeted_items,
|
||||
)
|
||||
|
||||
# Preload the manifests for all visible layers, so they load efficiently
|
||||
# in parallel rather than sequentially when rendering
|
||||
SwfAsset.preload_manifests(@outfit.visible_layers)
|
||||
|
||||
# Also preload alt style layer manifests for the style picker thumbnails
|
||||
SwfAsset.preload_manifests(@alt_style.swf_assets.to_a) if @alt_style
|
||||
|
||||
# Compute saved outfit state for the view
|
||||
if @saved_outfit
|
||||
@has_unsaved_changes = !@outfit.same_wardrobe_state_as?(@saved_outfit)
|
||||
@is_owner = user_signed_in? && current_user.id == @saved_outfit.user_id
|
||||
else
|
||||
@has_unsaved_changes = false
|
||||
end
|
||||
|
||||
# Handle search mode
|
||||
@search_mode = params[:q].present?
|
||||
if @search_mode
|
||||
search_filters = build_search_filters(params[:q], @outfit)
|
||||
query_params = ActionController::Parameters.new(
|
||||
search_filters.each_with_index.map { |filter, i| [i.to_s, filter] }.to_h
|
||||
)
|
||||
@query = Item::Search::Query.from_params(query_params, current_user)
|
||||
@search_results = @query.results.paginate(page: params.dig(:q, :page), per_page: 30)
|
||||
end
|
||||
|
||||
render layout: false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Returns a hash of pose => pet_state for all the main poses,
|
||||
# indicating which poses are available for this pet type.
|
||||
# Uses the same logic as the Rainbow Pool to pick the "canonical" pet state
|
||||
# for each pose when multiple states exist.
|
||||
def available_poses_for(pet_type)
|
||||
poses_hash = {}
|
||||
|
||||
# Group all pet states by pose, then pick the best one for each pose
|
||||
# using emotion_order (same logic as Rainbow Pool)
|
||||
pet_type.pet_states.emotion_order.group_by(&:pose).each do |pose, states|
|
||||
# Only include the main poses (skip UNKNOWN, UNCONVERTED, etc.)
|
||||
if PetState::MAIN_POSES.include?(pose)
|
||||
poses_hash[pose] = states.first
|
||||
end
|
||||
end
|
||||
|
||||
# Ensure all main poses are in the hash, even if nil
|
||||
PetState::MAIN_POSES.each do |pose|
|
||||
poses_hash[pose] ||= nil
|
||||
end
|
||||
|
||||
poses_hash
|
||||
end
|
||||
|
||||
def outfit_state_params_present?
|
||||
params[:species].present? || params[:color].present? || params[:objects].present? || params[:closet].present?
|
||||
end
|
||||
|
||||
def build_search_filters(query_params, outfit)
|
||||
filters = []
|
||||
|
||||
# Add name filter if present
|
||||
if query_params[:name].present?
|
||||
filters << { key: "name", value: query_params[:name] }
|
||||
end
|
||||
|
||||
# Add item kind filter if present
|
||||
if query_params[:item_kind].present?
|
||||
case query_params[:item_kind]
|
||||
when "nc"
|
||||
filters << { key: "is_nc", value: "true" }
|
||||
when "np"
|
||||
filters << { key: "is_np", value: "true" }
|
||||
when "pb"
|
||||
filters << { key: "is_pb", value: "true" }
|
||||
end
|
||||
end
|
||||
|
||||
# Add zone filter if present
|
||||
if query_params[:zone].present?
|
||||
filters << { key: "occupied_zone_set_name", value: query_params[:zone] }
|
||||
end
|
||||
|
||||
# Always add auto-filter for items that fit the current pet
|
||||
pet_type = outfit.pet_type
|
||||
if pet_type
|
||||
fit_filter = {
|
||||
key: "fits",
|
||||
value: {
|
||||
species_id: pet_type.species_id.to_s,
|
||||
color_id: pet_type.color_id.to_s
|
||||
}
|
||||
}
|
||||
|
||||
# Include alt_style_id if present
|
||||
if outfit.alt_style_id.present?
|
||||
fit_filter[:value][:alt_style_id] = outfit.alt_style_id.to_s
|
||||
end
|
||||
|
||||
filters << fit_filter
|
||||
end
|
||||
|
||||
filters
|
||||
end
|
||||
end
|
||||
177
app/helpers/wardrobe_helper.rb
Normal file
177
app/helpers/wardrobe_helper.rb
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
module WardrobeHelper
|
||||
# Generate hidden fields to preserve outfit state in URL params.
|
||||
# Use the `except` parameter to skip certain fields, e.g. to override
|
||||
# them with specific values, like in the species/color picker.
|
||||
def outfit_state_params(outfit = @outfit, except: [])
|
||||
fields = []
|
||||
|
||||
fields << hidden_field_tag(:name, @outfit.name) if !@saved_outfit && @outfit.name.present? && !except.include?(:name)
|
||||
fields << hidden_field_tag(:species, @outfit.species_id) unless except.include?(:species)
|
||||
fields << hidden_field_tag(:color, @outfit.color_id) unless except.include?(:color)
|
||||
fields << hidden_field_tag(:pose, @selected_pose) if @selected_pose && !except.include?(:pose)
|
||||
fields << hidden_field_tag(:style, @alt_style.id) if @alt_style && !except.include?(:style)
|
||||
|
||||
unless except.include?(:worn_items)
|
||||
outfit.worn_items.each do |item|
|
||||
fields << hidden_field_tag('objects[]', item.id)
|
||||
end
|
||||
end
|
||||
|
||||
unless except.include?(:closeted_items)
|
||||
outfit.closeted_items.each do |item|
|
||||
fields << hidden_field_tag('closet[]', item.id)
|
||||
end
|
||||
end
|
||||
|
||||
unless except.include?(:q)
|
||||
(params[:q] || {}).each do |key, value|
|
||||
fields << hidden_field_tag("q[#{key}]", value) if value.present?
|
||||
end
|
||||
end
|
||||
|
||||
safe_join fields
|
||||
end
|
||||
|
||||
# Get the emoji and label for the pose picker button.
|
||||
# Shows the alt style name when one is active, otherwise the pose name.
|
||||
def pose_emoji_and_label(pose, alt_style: nil)
|
||||
if alt_style
|
||||
{ emoji: "🕶", label: alt_style.series_name.split(":").last.strip.split(" ").first }
|
||||
else
|
||||
case pose
|
||||
when "HAPPY_MASC", "HAPPY_FEM"
|
||||
{ emoji: "😀", label: "Happy" }
|
||||
when "SAD_MASC", "SAD_FEM"
|
||||
{ emoji: "😢", label: "Sad" }
|
||||
when "SICK_MASC", "SICK_FEM"
|
||||
{ emoji: "🤢", label: "Sick" }
|
||||
else
|
||||
{ emoji: "😀", label: "Default" }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Group outfit items by zone, applying smart multi-zone simplification.
|
||||
# Returns an array of hashes: {zone_id:, zone_label:, items: [Item, ...]}
|
||||
# This matches the logic from wardrobe-2020's getZonesAndItems function.
|
||||
def outfit_items_by_zone(outfit)
|
||||
return [] if outfit.pet_type.nil?
|
||||
|
||||
all_items = outfit.worn_items + outfit.closeted_items
|
||||
|
||||
# Get item appearances for all items at once
|
||||
item_appearances = Item.appearances_for(
|
||||
all_items,
|
||||
outfit.pet_type,
|
||||
swf_asset_includes: [:zone]
|
||||
)
|
||||
|
||||
# Separate compatible and incompatible items
|
||||
compatible = {}
|
||||
incompatible_items = []
|
||||
|
||||
all_items.each do |item|
|
||||
appearance = item_appearances[item.id]
|
||||
if appearance&.present?
|
||||
compatible[item] = appearance
|
||||
else
|
||||
incompatible_items << item
|
||||
end
|
||||
end
|
||||
|
||||
# Group items by zone - multi-zone items appear in each zone
|
||||
items_by_zone = Hash.new { |h, k| h[k] = [] }
|
||||
zones_by_id = {}
|
||||
|
||||
compatible.each do |item, appearance|
|
||||
appearance.swf_assets.map(&:zone).uniq.each do |zone|
|
||||
zones_by_id[zone.id] = zone
|
||||
items_by_zone[zone.id] << item
|
||||
end
|
||||
end
|
||||
|
||||
# Create zone groups with sorted items
|
||||
zones_and_items = items_by_zone.map do |zone_id, items|
|
||||
{
|
||||
zone_id: zone_id,
|
||||
zone_label: zones_by_id[zone_id].label,
|
||||
items: items.sort_by { |item| item.name.downcase }
|
||||
}
|
||||
end
|
||||
|
||||
# Sort zone groups alphabetically by label, then by ID for tiebreaking
|
||||
zones_and_items.sort_by! do |group|
|
||||
[group[:zone_label].downcase, group[:zone_id]]
|
||||
end
|
||||
|
||||
# Apply multi-zone simplification: remove redundant single-item groups
|
||||
zones_and_items = simplify_multi_zone_groups(zones_and_items)
|
||||
|
||||
# Add zone ID disambiguation for duplicate labels
|
||||
zones_and_items = disambiguate_zone_labels(zones_and_items)
|
||||
|
||||
# Add incompatible items section if any
|
||||
if incompatible_items.any?
|
||||
zones_and_items << {
|
||||
zone_id: nil,
|
||||
zone_label: "Incompatible",
|
||||
items: incompatible_items.sort_by { |item| item.name.downcase }
|
||||
}
|
||||
end
|
||||
|
||||
zones_and_items
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Simplify zone groups by removing redundant single-item groups.
|
||||
# Keep groups with multiple items (conflicts). For single-item groups,
|
||||
# only keep them if the item doesn't appear in a multi-item group.
|
||||
def simplify_multi_zone_groups(zones_and_items)
|
||||
# Find groups with conflicts (multiple items)
|
||||
groups_with_conflicts = zones_and_items.select { |g| g[:items].length > 1 }
|
||||
|
||||
# Track which items appear in conflict groups
|
||||
items_with_conflicts = Set.new(
|
||||
groups_with_conflicts.flat_map { |g| g[:items].map(&:id) }
|
||||
)
|
||||
|
||||
# Track which items we've already shown
|
||||
items_we_have_seen = Set.new
|
||||
|
||||
# Filter groups
|
||||
zones_and_items.select do |group|
|
||||
# Always keep groups with multiple items
|
||||
if group[:items].length > 1
|
||||
group[:items].each { |item| items_we_have_seen.add(item.id) }
|
||||
true
|
||||
else
|
||||
# For single-item groups, only keep if:
|
||||
# - Item hasn't been seen yet AND
|
||||
# - Item won't appear in a conflict group
|
||||
item_id = group[:items].first.id
|
||||
|
||||
if items_we_have_seen.include?(item_id) || items_with_conflicts.include?(item_id)
|
||||
false
|
||||
else
|
||||
items_we_have_seen.add(item_id)
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Add zone IDs to labels when there are duplicates
|
||||
def disambiguate_zone_labels(zones_and_items)
|
||||
label_counts = zones_and_items.group_by { |g| g[:zone_label] }
|
||||
.transform_values(&:count)
|
||||
|
||||
zones_and_items.each do |group|
|
||||
if label_counts[group[:zone_label]] > 1
|
||||
group[:zone_label] = "#{group[:zone_label]} (##{group[:zone_id]})"
|
||||
end
|
||||
end
|
||||
|
||||
zones_and_items
|
||||
end
|
||||
end
|
||||
|
|
@ -572,6 +572,22 @@ class Item < ApplicationRecord
|
|||
return [] if empty?
|
||||
([item] + swf_assets).map(&:restricted_zone_ids).flatten.uniq.sort
|
||||
end
|
||||
|
||||
# Check if this appearance is compatible with another appearance.
|
||||
# Two appearances are incompatible if:
|
||||
# 1. They occupy the same zone (can't wear two items in same slot)
|
||||
# 2. One restricts a zone the other occupies (e.g., hat restricts hair zone)
|
||||
def compatible_with?(other)
|
||||
occupied = occupied_zone_ids
|
||||
other_occupied = other.occupied_zone_ids
|
||||
restricted = restricted_zone_ids
|
||||
other_restricted = other.restricted_zone_ids
|
||||
|
||||
# Check for zone conflicts
|
||||
(occupied & other_occupied).empty? &&
|
||||
(restricted & other_occupied).empty? &&
|
||||
(other_restricted & occupied).empty?
|
||||
end
|
||||
end
|
||||
Appearance::Body = Struct.new(:id, :species) do
|
||||
include ActiveModel::Serializers::JSON
|
||||
|
|
|
|||
|
|
@ -170,6 +170,8 @@ class Outfit < ApplicationRecord
|
|||
end
|
||||
|
||||
def visible_layers
|
||||
return [] if pet_state.nil?
|
||||
|
||||
# Step 1: Choose biology layers - use alt style if present, otherwise pet state
|
||||
if alt_style
|
||||
biology_layers = alt_style.swf_assets.includes(:zone).to_a
|
||||
|
|
@ -259,17 +261,24 @@ class Outfit < ApplicationRecord
|
|||
(biology_layers + item_layers).sort_by(&:depth)
|
||||
end
|
||||
|
||||
def same_wardrobe_state_as?(other)
|
||||
# Exclude :name because it's managed separately via atomic rename, not URL
|
||||
# state. This also works around the @outfit (new) vs @saved_outfit
|
||||
# (persisted) split in WardrobeController, where only the unpersisted
|
||||
# outfit includes :name. We should consider keeping their names in sync.
|
||||
wardrobe_params.except(:name) == other.wardrobe_params.except(:name)
|
||||
end
|
||||
|
||||
def wardrobe_params
|
||||
params = {
|
||||
name: name,
|
||||
color: color_id,
|
||||
species: species_id,
|
||||
pose: pose,
|
||||
state: pet_state_id,
|
||||
objects: worn_item_ids,
|
||||
closet: closeted_item_ids,
|
||||
objects: worn_item_ids.sort,
|
||||
closet: closeted_item_ids.sort,
|
||||
}
|
||||
params[:style] = alt_style_id if alt_style_id.present?
|
||||
params[:name] = name if !persisted? && name.present?
|
||||
params
|
||||
end
|
||||
|
||||
|
|
@ -298,4 +307,72 @@ class Outfit < ApplicationRecord
|
|||
i += 1
|
||||
end
|
||||
end
|
||||
|
||||
# When creating Outfit copies, include items. They're considered a basic
|
||||
# property of the record, in the grand scheme of things, despite being
|
||||
# associations.
|
||||
def dup
|
||||
super.tap do |outfit|
|
||||
outfit.worn_items = self.worn_items
|
||||
outfit.closeted_items = self.closeted_items
|
||||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit without the given item at all
|
||||
# (removed from both worn and closeted).
|
||||
def without_item(item)
|
||||
dup.tap do |o|
|
||||
o.worn_items.delete(item)
|
||||
o.closeted_items.delete(item)
|
||||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit with the given item moved from worn to
|
||||
# closeted. If it's not currently worn, returns the outfit unchanged.
|
||||
def hide_item(item)
|
||||
dup.tap do |o|
|
||||
next unless o.worn_item_ids.include?(item.id)
|
||||
o.worn_items.delete(item)
|
||||
o.closeted_items << item unless o.closeted_item_ids.include?(item.id)
|
||||
end
|
||||
end
|
||||
|
||||
# Create a copy of this outfit, additionally wearing the given item.
|
||||
# Automatically moves any incompatible worn items to the closet.
|
||||
def with_item(item)
|
||||
dup.tap do |o|
|
||||
# Skip if item is nil, already worn, or outfit has no pet_state
|
||||
next if item.nil? || o.worn_item_ids.include?(item.id) || o.pet_state.nil?
|
||||
|
||||
# If the item was closeted, remove it from closet (it's moving to worn)
|
||||
o.closeted_items.delete(item) if o.closeted_item_ids.include?(item.id)
|
||||
|
||||
# Load appearances for the new item and all currently worn items
|
||||
all_items = o.worn_items + [item]
|
||||
appearances = Item.appearances_for(all_items, o.pet_type,
|
||||
swf_asset_includes: [:zone])
|
||||
|
||||
new_item_appearance = appearances[item.id]
|
||||
|
||||
# If the new item has no appearance (doesn't fit this pet), skip it
|
||||
next if new_item_appearance.empty?
|
||||
|
||||
# Find items that conflict with the new item
|
||||
conflicting_items = o.worn_items.select do |worn_item|
|
||||
worn_appearance = appearances[worn_item.id]
|
||||
# Empty appearances are always compatible
|
||||
!worn_appearance.empty? &&
|
||||
!new_item_appearance.compatible_with?(worn_appearance)
|
||||
end
|
||||
|
||||
# Move conflicting items to closet
|
||||
conflicting_items.each do |conflicting_item|
|
||||
o.worn_items.delete(conflicting_item)
|
||||
o.closeted_items << conflicting_item unless o.closeted_item_ids.include?(conflicting_item.id)
|
||||
end
|
||||
|
||||
# Add the new item
|
||||
o.worn_items << item
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -48,6 +48,26 @@ class PetType < ApplicationRecord
|
|||
random_pet_types
|
||||
end
|
||||
|
||||
# Given a species ID and color ID, return the best matching PetType.
|
||||
#
|
||||
# If the exact species+color combo exists, return it.
|
||||
# Otherwise, find the best fallback for that species:
|
||||
# - Prefer the requested color if available
|
||||
# - Otherwise prefer simple colors (basic > standard > alphabetical)
|
||||
#
|
||||
# This matches the wardrobe behavior where we automatically switch to a valid
|
||||
# color when the user selects a species that doesn't support their current color.
|
||||
#
|
||||
# Returns the PetType, or nil if no pet types exist for this species.
|
||||
def self.for_species_and_color(species_id:, color_id:)
|
||||
return nil if species_id.nil?
|
||||
|
||||
where(species_id: species_id)
|
||||
.preferring_color(color_id)
|
||||
.preferring_simple
|
||||
.first
|
||||
end
|
||||
|
||||
def as_json(options={})
|
||||
super({
|
||||
only: [:id],
|
||||
|
|
|
|||
|
|
@ -34,4 +34,13 @@ class Species < ApplicationRecord
|
|||
def self.param_to_id(param)
|
||||
param.match?(/\A\d+\Z/) ? param.to_i : find_by_name!(param).id
|
||||
end
|
||||
|
||||
# Get all colors that are compatible with this species (have pet types)
|
||||
# Returns an ActiveRecord::Relation of Color records
|
||||
def compatible_colors
|
||||
Color.alphabetical
|
||||
.joins(:pet_types)
|
||||
.where(pet_types: { species_id: id })
|
||||
.distinct
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -3,16 +3,6 @@
|
|||
= content_tag "outfit-viewer", **html_options do
|
||||
.loading-indicator= render partial: "hanger_spinner"
|
||||
|
||||
%label.play-pause-button{title: "Pause/play animations"}
|
||||
%input.play-pause-toggle{
|
||||
type: "checkbox",
|
||||
checked: outfit_viewer_is_playing,
|
||||
}
|
||||
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
|
||||
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
|
||||
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
|
||||
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
|
||||
|
||||
- outfit.visible_layers.each do |swf_asset|
|
||||
%outfit-layer{
|
||||
id: "#{viewer_id}-layer-#{swf_asset.id}",
|
||||
|
|
|
|||
|
|
@ -9,26 +9,8 @@
|
|||
= image_tag item.thumbnail_url, class: 'item-thumbnail'
|
||||
%h2.item-name= item.name
|
||||
%nav.item-links
|
||||
- if item.currently_in_mall?
|
||||
= link_to "https://ncmall.neopets.com/", class: "item-kind", data: {type: "nc"},
|
||||
title: "Currently in NC Mall!", target: "_blank" do
|
||||
= cart_icon alt: "Buy"
|
||||
#{item.current_nc_price} NC
|
||||
- elsif item.nc?
|
||||
%abbr.item-kind{'data-type' => 'nc', title: t('items.show.item_kinds.nc.description')}
|
||||
= t('items.show.item_kinds.nc.label')
|
||||
- elsif item.pb?
|
||||
%abbr.item-kind{'data-type' => 'pb', title: t('items.show.item_kinds.pb.description')}
|
||||
= t('items.show.item_kinds.pb.label')
|
||||
- else
|
||||
%abbr.item-kind{'data-type' => 'np', title: t('items.show.item_kinds.np.description')}
|
||||
= t('items.show.item_kinds.np.label')
|
||||
|
||||
- if item.created_at?
|
||||
%time.first-seen-at{
|
||||
datetime: item.created_at.iso8601,
|
||||
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
|
||||
}= time_with_only_month_if_old item.created_at
|
||||
= render "items/badges/kind", item: item
|
||||
= render "items/badges/first_seen", item: item
|
||||
|
||||
= link_to t('items.show.resources.jn_items'), jn_items_url_for(item)
|
||||
= link_to t('items.show.resources.impress_2020'), impress_2020_url_for(item)
|
||||
|
|
|
|||
13
app/views/items/badges/_first_seen.html.haml
Normal file
13
app/views/items/badges/_first_seen.html.haml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-# Renders a "first seen" timestamp badge for an item
|
||||
-#
|
||||
-# Usage:
|
||||
-# = render "items/badges/first_seen", item: @item
|
||||
-#
|
||||
-# Shows when the item was first added to the database.
|
||||
-# Only renders if the item has a created_at timestamp.
|
||||
|
||||
- if item.created_at?
|
||||
%time.item-badge.first-seen-at{
|
||||
datetime: item.created_at.iso8601,
|
||||
title: "First seen on #{item.created_at.to_date.to_fs(:long)}",
|
||||
}= time_with_only_month_if_old item.created_at
|
||||
25
app/views/items/badges/_kind.html.haml
Normal file
25
app/views/items/badges/_kind.html.haml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
-# Renders an item kind badge (NC/NP/PB)
|
||||
-#
|
||||
-# Usage:
|
||||
-# = render "items/item_kind_badge", item: @item
|
||||
-#
|
||||
-# The badge shows:
|
||||
-# - For NC Mall items: clickable link with price
|
||||
-# - For NC items: purple "NC" badge
|
||||
-# - For PB items: orange "PB" badge
|
||||
-# - For NP items: gray "NP" badge
|
||||
|
||||
- if item.currently_in_mall?
|
||||
= link_to "https://ncmall.neopets.com/", class: "item-badge", data: {item_kind: "nc"},
|
||||
title: "Currently in NC Mall!", target: "_blank" do
|
||||
= cart_icon alt: "Buy"
|
||||
#{item.current_nc_price} NC
|
||||
- elsif item.nc?
|
||||
%abbr.item-badge{data: {item_kind: "nc"}, title: t('items.show.item_kinds.nc.description')}
|
||||
= t('items.show.item_kinds.nc.label')
|
||||
- elsif item.pb?
|
||||
%abbr.item-badge{data: {item_kind: "pb"}, title: t('items.show.item_kinds.pb.description')}
|
||||
= t('items.show.item_kinds.pb.label')
|
||||
- else
|
||||
%abbr.item-badge{data: {item_kind: "np"}, title: t('items.show.item_kinds.np.description')}
|
||||
= t('items.show.item_kinds.np.label')
|
||||
|
|
@ -17,6 +17,13 @@
|
|||
= turbo_frame_tag "item-preview" do
|
||||
.preview-area
|
||||
= outfit_viewer @preview_outfit, id: "item-preview-outfit-viewer"
|
||||
%outfit-viewer-play-pause-toggle{for: "item-preview-outfit-viewer"}
|
||||
%label.play-pause-button{title: "Pause/play animations"}
|
||||
%input{type: "checkbox"}
|
||||
%svg.playing-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Pause"}
|
||||
%path{fill: "currentColor", d: "M6 19h4V5H6v14zm8-14v14h4V5h-4z"}
|
||||
%svg.paused-label{viewBox: "0 0 24 24", "aria-hidden": "true", "aria-label": "Play"}
|
||||
%path{fill: "currentColor", d: "M8 5v14l11-7z"}
|
||||
.error-indicator
|
||||
💥 We couldn't load all of this outfit. Try again?
|
||||
= link_to wardrobe_path(params: @preview_outfit.wardrobe_params),
|
||||
|
|
@ -26,20 +33,21 @@
|
|||
Customize more
|
||||
= edit_icon
|
||||
|
||||
%species-color-picker
|
||||
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
||||
- if @preview_error == :pet_type_does_not_exist
|
||||
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
||||
- elsif @preview_error == :no_item_data
|
||||
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
|
||||
.species-color-picker
|
||||
%auto-submit-form
|
||||
= form_for item_path(@item), method: :get, data: {"is-valid": @preview_error.nil?} do |f|
|
||||
- if @preview_error == :pet_type_does_not_exist
|
||||
%span.error-icon{title: "We haven't seen this kind of pet before."} ⚠️
|
||||
- elsif @preview_error == :no_item_data
|
||||
%span.error-icon{title: "We haven't seen this item on this pet before."} ⚠️
|
||||
|
||||
= select_tag "preview[color_id]",
|
||||
options_from_collection_for_select(Color.alphabetical,
|
||||
"id", "human_name", @selected_preview_pet_type.color_id)
|
||||
= select_tag "preview[species_id]",
|
||||
options_from_collection_for_select(Species.alphabetical,
|
||||
"id", "human_name", @selected_preview_pet_type.species_id)
|
||||
= submit_tag "Go", name: nil
|
||||
= select_tag "preview[color_id]",
|
||||
options_from_collection_for_select(Color.alphabetical,
|
||||
"id", "human_name", @selected_preview_pet_type.color_id)
|
||||
= select_tag "preview[species_id]",
|
||||
options_from_collection_for_select(Species.alphabetical,
|
||||
"id", "human_name", @selected_preview_pet_type.species_id)
|
||||
= submit_tag "Go", name: nil
|
||||
|
||||
%species-face-picker
|
||||
%noscript
|
||||
|
|
@ -131,4 +139,5 @@
|
|||
- content_for :javascripts do
|
||||
= javascript_include_tag "idiomorph", async: true
|
||||
= javascript_include_tag "outfit-viewer", async: true
|
||||
= javascript_include_tag "auto-submit-form", async: true
|
||||
= javascript_include_tag "items/show", async: true
|
||||
|
|
|
|||
22
app/views/wardrobe/appearance/_pose_option.html.haml
Normal file
22
app/views/wardrobe/appearance/_pose_option.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
-# Renders a single pose option in the pose picker grid
|
||||
-# @param pose [String] The pose name (e.g., "HAPPY_MASC")
|
||||
-# @param pet_state [PetState, nil] The pet state for this pose, or nil if unavailable
|
||||
-# @param selected [Boolean] Whether this pose is currently selected
|
||||
|
||||
- is_available = pet_state.present?
|
||||
- pose_label = pose.split('_').map(&:capitalize).join(' ')
|
||||
|
||||
%label.pose-option{class: [is_available ? 'available' : 'unavailable', selected ? 'selected' : nil]}
|
||||
= radio_button_tag :pose, pose, selected,
|
||||
disabled: !is_available,
|
||||
"aria-label": pose_label + (is_available ? "" : " (not available)")
|
||||
|
||||
.pose-thumbnail
|
||||
- if is_available
|
||||
-# Create a minimal outfit with just this pet state for the thumbnail
|
||||
- thumbnail_outfit = Outfit.new(pet_state: pet_state, worn_items: [])
|
||||
= outfit_viewer thumbnail_outfit, class: "pose-thumbnail-viewer",
|
||||
id: "pose-thumbnail-viewer-#{pet_state.id}"
|
||||
- else
|
||||
.pose-unavailable
|
||||
%span.question-mark{title: "Not available"} ❓
|
||||
22
app/views/wardrobe/appearance/_pose_picker.html.haml
Normal file
22
app/views/wardrobe/appearance/_pose_picker.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
- pose_info = pose_emoji_and_label(@selected_pose, alt_style: @alt_style)
|
||||
%button#pose-picker-button.pose-picker-button{type: "button", popovertarget: "pose-picker-popover"}
|
||||
%span.pose-emoji= pose_info[:emoji]
|
||||
%span.pose-label= pose_info[:label]
|
||||
%span.chevron ▾
|
||||
|
||||
%pose-picker-popover#pose-picker-popover{popover: "auto"}
|
||||
- active_tab = @alt_style ? "styles" : "expressions"
|
||||
%tab-panel{active: active_tab}
|
||||
.tab-content{"data-tab": "expressions", hidden: active_tab != "expressions" ? true : nil}
|
||||
%auto-submit-form
|
||||
= render "appearance/pose_picker_form"
|
||||
.tab-content{"data-tab": "styles", hidden: active_tab != "styles" ? true : nil}
|
||||
%auto-submit-form
|
||||
= render "appearance/style_picker_form"
|
||||
.tab-list
|
||||
%button.tab-button{"data-tab": "expressions", type: "button",
|
||||
class: ("active" if active_tab == "expressions")}
|
||||
Expressions
|
||||
%button.tab-button{"data-tab": "styles", type: "button",
|
||||
class: ("active" if active_tab == "styles")}
|
||||
Styles
|
||||
32
app/views/wardrobe/appearance/_pose_picker_form.html.haml
Normal file
32
app/views/wardrobe/appearance/_pose_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
= form_with url: @wardrobe_path, method: :get, class: "pose-picker-form" do |f|
|
||||
= outfit_state_params except: [:pose, :style]
|
||||
%table.pose-picker-table
|
||||
%thead
|
||||
%tr
|
||||
%th
|
||||
%th
|
||||
%span.emoji-icon{title: "Happy"} 😀
|
||||
%th
|
||||
%span.emoji-icon{title: "Sad"} 😢
|
||||
%th
|
||||
%span.emoji-icon{title: "Sick"} 🤢
|
||||
%tbody
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Masculine"} 💁♂️
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "HAPPY_MASC", pet_state: @available_poses["HAPPY_MASC"], selected: !@alt_style && @selected_pose == "HAPPY_MASC"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SAD_MASC", pet_state: @available_poses["SAD_MASC"], selected: !@alt_style && @selected_pose == "SAD_MASC"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SICK_MASC", pet_state: @available_poses["SICK_MASC"], selected: !@alt_style && @selected_pose == "SICK_MASC"
|
||||
%tr
|
||||
%th
|
||||
%span.emoji-icon{title: "Feminine"} 💁♀️
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "HAPPY_FEM", pet_state: @available_poses["HAPPY_FEM"], selected: !@alt_style && @selected_pose == "HAPPY_FEM"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SAD_FEM", pet_state: @available_poses["SAD_FEM"], selected: !@alt_style && @selected_pose == "SAD_FEM"
|
||||
%td
|
||||
= render "appearance/pose_option", pose: "SICK_FEM", pet_state: @available_poses["SICK_FEM"], selected: !@alt_style && @selected_pose == "SICK_FEM"
|
||||
= submit_tag "Change pose", name: nil, class: "pose-submit-button progressive-submit"
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
.species-color-picker
|
||||
%auto-submit-form
|
||||
= form_with url: @wardrobe_path, method: :get do |f|
|
||||
= outfit_state_params except: [:color, :species]
|
||||
= select_tag :color,
|
||||
options_from_collection_for_select(@colors, "id", "human_name",
|
||||
@selected_color&.id),
|
||||
"aria-label": "Pet color"
|
||||
= select_tag :species,
|
||||
options_from_collection_for_select(@species, "id", "human_name",
|
||||
@selected_species&.id),
|
||||
"aria-label": "Pet species"
|
||||
= submit_tag "Go", name: nil, class: "progressive-submit"
|
||||
11
app/views/wardrobe/appearance/_style_picker_form.html.haml
Normal file
11
app/views/wardrobe/appearance/_style_picker_form.html.haml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
= form_with url: @wardrobe_path, method: :get, class: "style-picker-form" do |f|
|
||||
= outfit_state_params except: [:style]
|
||||
.style-picker-list
|
||||
- @available_alt_styles.each do |alt_style|
|
||||
%label.style-option
|
||||
= radio_button_tag :style, alt_style.id, @alt_style&.id == alt_style.id
|
||||
.style-option-content
|
||||
.style-option-thumbnail
|
||||
%img{src: alt_style.thumbnail_url, alt: "", width: 40, height: 40, loading: "lazy"}
|
||||
%span.style-option-name= alt_style.adjective_name
|
||||
= submit_tag "Change style", name: nil, class: "style-submit-button progressive-submit"
|
||||
22
app/views/wardrobe/header/_outfit_rename_field.html.haml
Normal file
22
app/views/wardrobe/header/_outfit_rename_field.html.haml
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
- if @saved_outfit
|
||||
- form_url = outfit_path(@saved_outfit)
|
||||
- form_method = :patch
|
||||
- field_name = "outfit[name]"
|
||||
- else
|
||||
- form_url = @wardrobe_path
|
||||
- form_method = :get
|
||||
- field_name = :name
|
||||
|
||||
%outfit-rename-field
|
||||
.outfit-rename-static-display
|
||||
%span.outfit-rename-name= @outfit.name.presence || "Untitled outfit"
|
||||
%button.outfit-rename-pencil{type: "button", "aria-label": "Rename outfit"} ✏️
|
||||
= form_with url: form_url, method: form_method, class: "outfit-name-form" do |f|
|
||||
= hidden_field_tag :return_to, request.fullpath
|
||||
- unless @saved_outfit
|
||||
= outfit_state_params except: [:name]
|
||||
= f.text_field field_name, value: @outfit.name,
|
||||
class: "outfit-name-input", placeholder: "Untitled outfit",
|
||||
"aria-label": "Outfit name"
|
||||
= f.submit "Rename", name: nil, class: "outfit-name-submit"
|
||||
|
||||
20
app/views/wardrobe/header/_save_button.html.haml
Normal file
20
app/views/wardrobe/header/_save_button.html.haml
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
- if @saved_outfit
|
||||
- if @is_owner
|
||||
- if @has_unsaved_changes
|
||||
= form_with url: outfit_path(@saved_outfit), method: :patch, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save", class: "outfit-save-button"
|
||||
- else
|
||||
%button.outfit-save-button{disabled: true} Saved!
|
||||
- elsif user_signed_in?
|
||||
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save a copy", class: "outfit-save-button"
|
||||
- else
|
||||
= link_to "Log in to save a copy",
|
||||
new_auth_user_session_path(return_to: request.fullpath),
|
||||
class: "outfit-save-button"
|
||||
- elsif user_signed_in?
|
||||
= form_with url: outfits_path, method: :post, class: "outfit-save-form" do |f|
|
||||
= render "header/save_outfit_fields"
|
||||
= f.submit "Save", class: "outfit-save-button"
|
||||
10
app/views/wardrobe/header/_save_outfit_fields.html.haml
Normal file
10
app/views/wardrobe/header/_save_outfit_fields.html.haml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
= hidden_field_tag "outfit[name]", @outfit.name
|
||||
= hidden_field_tag "outfit[biology][species_id]", @outfit.species_id
|
||||
= hidden_field_tag "outfit[biology][color_id]", @outfit.color_id
|
||||
= hidden_field_tag "outfit[biology][pose]", @outfit.pet_state.pose
|
||||
- if @alt_style
|
||||
= hidden_field_tag "outfit[alt_style_id]", @alt_style.id
|
||||
- @outfit.worn_items.each do |item|
|
||||
= hidden_field_tag "outfit[item_ids][worn][]", item.id
|
||||
- @outfit.closeted_items.each do |item|
|
||||
= hidden_field_tag "outfit[item_ids][closeted][]", item.id
|
||||
29
app/views/wardrobe/items/_item_card.html.haml
Normal file
29
app/views/wardrobe/items/_item_card.html.haml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
- is_worn = @outfit.worn_items.include?(item)
|
||||
- is_closeted = @outfit.closeted_items.include?(item)
|
||||
%item-card{data: {is_worn: is_worn || nil, is_closeted: is_closeted || nil}}
|
||||
- if defined?(zone_id) && zone_id
|
||||
%label.item-card-label
|
||||
%input.visually-hidden{type: "radio", name: "zone_#{zone_id}", checked: is_worn || nil, "aria-label": item.name}
|
||||
= render "wardrobe/items/item_card_content", item: item
|
||||
- else
|
||||
%label.item-card-label
|
||||
%input.visually-hidden{type: "checkbox", checked: is_worn || nil, "aria-label": item.name}
|
||||
= render "wardrobe/items/item_card_content", item: item
|
||||
- if is_worn
|
||||
= button_to @wardrobe_path, method: :get, class: "item-hide-button", title: "Hide #{item.name}", "aria-label": "Hide #{item.name}" do
|
||||
👁️🗨️
|
||||
= outfit_state_params @outfit.hide_item(item)
|
||||
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||
❌
|
||||
= outfit_state_params @outfit.without_item(item)
|
||||
- elsif is_closeted
|
||||
= button_to @wardrobe_path, method: :get, class: "item-show-button", title: "Show #{item.name}", "aria-label": "Show #{item.name}" do
|
||||
👁️
|
||||
= outfit_state_params @outfit.with_item(item)
|
||||
= button_to @wardrobe_path, method: :get, class: "item-remove-button", title: "Remove #{item.name}", "aria-label": "Remove #{item.name}" do
|
||||
❌
|
||||
= outfit_state_params @outfit.without_item(item)
|
||||
- else
|
||||
= button_to @wardrobe_path, method: :get, class: "item-add-button", title: "Add #{item.name}", "aria-label": "Add #{item.name}" do
|
||||
➕
|
||||
= outfit_state_params @outfit.with_item(item)
|
||||
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
7
app/views/wardrobe/items/_item_card_content.html.haml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
.item-thumbnail
|
||||
= image_tag item.thumbnail_url, alt: "", loading: "lazy"
|
||||
.item-info
|
||||
.item-name= item.name
|
||||
.item-badges
|
||||
= render "items/badges/kind", item: item
|
||||
= render "items/badges/first_seen", item: item
|
||||
13
app/views/wardrobe/items/_search_results.html.haml
Normal file
13
app/views/wardrobe/items/_search_results.html.haml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
.search-results
|
||||
- if @search_results.any?
|
||||
= will_paginate @search_results, page_links: false, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||
|
||||
%ul.search-results-list
|
||||
- @search_results.each do |item|
|
||||
= render "items/item_card", item: item
|
||||
|
||||
= will_paginate @search_results, param_name: "q[page]", params: @outfit.wardrobe_params.merge(q: params[:q])
|
||||
|
||||
- else
|
||||
.empty-state
|
||||
%p No matching items found. Try a different search term, or browse items on the main site.
|
||||
81
app/views/wardrobe/show.html.haml
Normal file
81
app/views/wardrobe/show.html.haml
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
- title @outfit.name
|
||||
|
||||
!!! 5
|
||||
%html
|
||||
%head
|
||||
%meta{charset: 'utf-8'}
|
||||
%meta{name: 'viewport', content: 'width=device-width, initial-scale=1'}
|
||||
%title #{yield :title} | #{t "app_name"}
|
||||
%link{href: image_path('favicon.png'), rel: 'icon'}
|
||||
= stylesheet_link_tag "application/hanger-spinner"
|
||||
= stylesheet_link_tag "application/outfit-viewer"
|
||||
= page_stylesheet_link_tag "wardrobe/show"
|
||||
= javascript_include_tag "application", async: true
|
||||
= javascript_include_tag "idiomorph", async: true
|
||||
= javascript_include_tag "outfit-viewer", async: true
|
||||
= javascript_include_tag "auto-submit-form", async: true
|
||||
= javascript_include_tag "pose-picker", async: true
|
||||
= javascript_include_tag "tab-panel", async: true
|
||||
= javascript_include_tag "outfit-rename-field", async: true
|
||||
= javascript_include_tag "wardrobe/item-card", async: true
|
||||
= javascript_include_tag "wardrobe/item-search-keys", async: true
|
||||
= javascript_include_tag "wardrobe/show", async: true
|
||||
= csrf_meta_tags
|
||||
%meta{name: 'outfit-viewer-morph-mode', value: 'full-page'}
|
||||
%body.wardrobe-v2
|
||||
- if flash[:alert]
|
||||
.flash-messages
|
||||
.flash-alert= flash[:alert]
|
||||
.wardrobe-container{data: @saved_outfit ? {"has-unsaved-changes": @has_unsaved_changes.to_s} : {}}
|
||||
.outfit-preview-section
|
||||
- if @pet_type.nil?
|
||||
.no-preview-message
|
||||
%p
|
||||
We haven't seen this kind of pet before! Try a different species/color
|
||||
combination.
|
||||
- else
|
||||
= outfit_viewer @outfit, id: "wardrobe-outfit-viewer",
|
||||
preferred_image_format: :svg # TODO: Make this a selectable option
|
||||
|
||||
.preview-controls
|
||||
.preview-controls-top
|
||||
%outfit-viewer-play-pause-toggle{for: "wardrobe-outfit-viewer"}
|
||||
%label.play-pause-control-button.button
|
||||
%input{type: "checkbox", checked: cookies[:DTIOutfitViewerIsPlaying] != "false"}
|
||||
%span.paused-label Paused
|
||||
%span.playing-label Playing
|
||||
|
||||
.preview-controls-bottom
|
||||
= render "appearance/species_color_picker"
|
||||
|
||||
- if @pet_type
|
||||
= render "appearance/pose_picker"
|
||||
|
||||
.outfit-controls-section
|
||||
.item-search-form
|
||||
- if @search_mode
|
||||
= button_to @wardrobe_path, method: :get, class: "back-button" do
|
||||
←
|
||||
= outfit_state_params except: [:q]
|
||||
= form_with url: @wardrobe_path, method: :get, class: "search-form" do |f|
|
||||
= outfit_state_params
|
||||
= f.text_field "q[name]", placeholder: "Search for items...", value: params.dig(:q, :name), "aria-label": "Search for items"
|
||||
= f.submit "Search"
|
||||
|
||||
- if @search_mode
|
||||
= render "items/search_results"
|
||||
- else
|
||||
.outfit-header
|
||||
- if @saved_outfit && !@is_owner
|
||||
.outfit-name-static= @outfit.name
|
||||
- else
|
||||
= render "header/outfit_rename_field"
|
||||
= render "header/save_button"
|
||||
- if @outfit.worn_items.any? || @outfit.closeted_items.any?
|
||||
.outfit-items
|
||||
- outfit_items_by_zone(@outfit).each do |zone_group|
|
||||
.zone-group
|
||||
%h3.zone-label= zone_group[:zone_label]
|
||||
%ul.items-list
|
||||
- zone_group[:items].each do |item|
|
||||
= render "items/item_card", item: item, zone_id: zone_group[:zone_id]
|
||||
|
|
@ -11,6 +11,8 @@ OpenneoImpressItems::Application.routes.draw do
|
|||
# Should we refactor the controller/view structure here?
|
||||
get '/outfits/new', to: 'outfits#edit', as: :wardrobe
|
||||
get '/wardrobe' => redirect('/outfits/new')
|
||||
get '/wardrobe/v2', to: 'wardrobe#show', as: :wardrobe_v2
|
||||
get '/outfits/:id/v2', to: 'wardrobe#show', as: :wardrobe_v2_outfit
|
||||
get '/start/:color_name/:species_name' => 'outfits#start'
|
||||
|
||||
# The outfits users have created!
|
||||
|
|
|
|||
126
docs/wardrobe-v2-migration.md
Normal file
126
docs/wardrobe-v2-migration.md
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
# Wardrobe V2 Migration Status
|
||||
|
||||
This document tracks the status of Wardrobe V2, a ground-up rewrite of the outfit editor using Rails + Turbo, replacing the React + GraphQL system embedded from Impress 2020.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the complex React outfit editor (`app/javascript/wardrobe-2020/`) with a simpler Rails/Turbo implementation that:
|
||||
- Eliminates dependency on Impress 2020's GraphQL API
|
||||
- Uses progressive enhancement (works without JavaScript)
|
||||
- Leverages Web Components for interactive features
|
||||
- Reduces frontend complexity and maintenance burden
|
||||
- Eventually enables full deprecation of the Impress 2020 service
|
||||
|
||||
## Current Status
|
||||
|
||||
**Wardrobe V2 is in active development** on the `feature/wardrobe-v2` branch. It's accessible at `/wardrobe/v2` but is not yet linked from the main UI.
|
||||
|
||||
### What Works
|
||||
|
||||
- **Species/color/pose/style selection**: Full picker UI with visual pose thumbnails, availability indicators, canonical fallback, and alt style picker with tabbed UI
|
||||
- **Item search**: Text search with auto-filtering by pet compatibility, pagination, add/remove items
|
||||
- **Item display**: Grouped by zone with multi-zone simplification, incompatible items section, NC/NP badges
|
||||
- **Outfit rendering**: Uses the shared `<outfit-viewer>` web component
|
||||
- **Progressive enhancement**: Everything works without JS; web components add auto-submit and smoother interactions
|
||||
- **Smooth navigation**: Idiomorph DOM morphing reuses `<outfit-viewer>` layers across full-page navigations
|
||||
- **Outfit saving/loading**: Load saved outfits at `/outfits/:id/v2`, save changes (owner) or save copies (non-owner), editable outfit name, unsaved changes warning
|
||||
|
||||
### Key implementation files
|
||||
|
||||
Code lives in `app/controllers/wardrobe_controller.rb`, `app/views/wardrobe/`, `app/helpers/wardrobe_helper.rb`, and `app/assets/{stylesheets,javascripts}/wardrobe/`.
|
||||
|
||||
## Technical Approach
|
||||
|
||||
**Simplicity over polish**: Unlike many webapps, we value the simplicity of the codebase very highly, even at the expense of an ideal user experience. Start with simple solutions, even if the UX is clunky; then we'll iterate up as needed.
|
||||
|
||||
**URL as single source of truth**: All outfit state lives in URL params (`species`, `color`, `pose`, `style`, `objects[]`, `q[...]`). Every interaction is a GET request that generates a new URL. No client-side state management. Browser back/forward work naturally.
|
||||
|
||||
**Server-side rendering + Web Components**: All HTML is generated server-side. Lightweight web components (`<auto-submit-form>`, `<pose-picker-popover>`, `<tab-panel>`, `<outfit-viewer>`) add interactivity without framework overhead.
|
||||
|
||||
**Progressive enhancement**: Submit buttons appear when JS is slow/disabled. Web components enhance forms with auto-submit on change.
|
||||
|
||||
## Roadmap
|
||||
|
||||
### Phase 1: Core Functionality (MVP)
|
||||
|
||||
The goal is a basic usable wardrobe. Species/color/pose selection, item search, and add/remove are already done.
|
||||
|
||||
**Outfit Saving/Loading** (basic implementation done)
|
||||
- Save button near editable outfit name, disabled/"Saved!" when state matches saved
|
||||
- Route to load saved outfits (`GET /outfits/:id/v2`) with redirect-based state initialization
|
||||
- "Save a copy" for non-owners, login prompt for unauthenticated users
|
||||
- `beforeunload` warning for unsaved changes via MutationObserver
|
||||
- Outfit name: For saved outfits, rename is a standalone PATCH operation (not a URL param). For unsaved outfits, name is tracked as a URL param. Progressive enhancement shows static text + pencil icon for renaming.
|
||||
|
||||
**Alt Styles Support** (done)
|
||||
- `Outfit#visible_layers` handles alt styles
|
||||
- Picker UI with tabbed Expressions/Styles panels, `style` URL param, visual thumbnails, "Default" option
|
||||
- Stale style params dropped gracefully when switching species
|
||||
- Search results auto-filtered by alt style compatibility
|
||||
|
||||
**Closeted Items** (done)
|
||||
- Instead of just wearing/unwearing items, also support a "closeted" state: the user is *considering* this item,
|
||||
but it is not displayed on the pet itself right now.
|
||||
- Wearing an item will stop wearing, but keep in closet, items that are mutually incompatible with it.
|
||||
- Baseline behavior: separate toggle buttons for worn state and closeted state.
|
||||
- Unworn items have "Add" (wear).
|
||||
- Worn items have "Hide" (stop wearing, keep in closet) and "Remove" (remove from worn and closet).
|
||||
- Closeted items have "Show" (wear) and "Remove" (remove from closet).
|
||||
- Visual distinction: worn items have green emphasis, closeted items have dashed border and reduced opacity.
|
||||
- Closeted items appear in zone groups alongside worn items.
|
||||
- `closet[]` URL params, saving/loading, and search pagination all work.
|
||||
- Progressive enhancement (done):
|
||||
- Each item card is an `<item-card>` custom element with a visually-hidden input inside a `<label>`.
|
||||
- In the outfit view, items use radio inputs (mutual exclusivity within zone via `name` attribute). Arrow keys
|
||||
navigate between items via native radio behavior.
|
||||
- In the search view, items use checkbox inputs (independent toggle).
|
||||
- In both views, the `<item-card>` web component delegates clicks to the baseline forms: clicking a closeted or
|
||||
absent item submits its Show/Add form (wears it), clicking a worn item submits its Hide form (un-wears it).
|
||||
- When the item is closeted or worn, there is a "Remove" button.
|
||||
- The radio button and checkbox are visually hidden, and are reflected in the item card worn/closeted styles instead.
|
||||
|
||||
### Phase 2: Polish & Parity
|
||||
|
||||
Match the quality and usability of Wardrobe 2020 where it matters.
|
||||
|
||||
- **Item UX**: Wear/unwear toggle, item info links, zone badges, removal animations, incompatibility tooltips
|
||||
- **Preview controls**: Download as PNG, copy link, settings (hi-res mode, archive toggle), HTML5 conversion badge
|
||||
- **Loading & errors**: Spinners, skeleton screens, smooth transitions, error recovery
|
||||
- **Mobile**: Touch-friendly targets, fix hover-dependent interactions, always-visible controls on touch
|
||||
- **Accessibility**: ARIA labels, keyboard navigation, semantic headings, skip links, color contrast
|
||||
|
||||
### Phase 3: Advanced Features
|
||||
|
||||
Feature parity with Wardrobe 2020 where valuable.
|
||||
|
||||
- **User features**: Auth integration, closet/ownership/wishlist badges, filter search by owned/wanted
|
||||
- **Conflict management**: Auto zone conflict resolution, smart item restoration on unwear
|
||||
- **Pet loading**: "Load my pet" by name, modeling integration
|
||||
- **Search enhancements**: Inline syntax (`is:nc`, `fits:blue-acara`), advanced filter UI, autocomplete
|
||||
- **Outfit auto-saving**: Save outfit changes automatically over time, rather than requiring clicking Save
|
||||
|
||||
### Phase 4: Migration & Rollout
|
||||
|
||||
- Gradual rollout (staff → beta → default)
|
||||
- Performance measurement and optimization (Turbo Frames for partial updates?)
|
||||
- Visual polish and design refinement
|
||||
- Remove wardrobe-2020 code and update Impress 2020 dependencies doc
|
||||
|
||||
### Deferred / Maybe Never
|
||||
|
||||
- Support mode features (can keep using old wardrobe)
|
||||
- Known glitches system (complex, low value)
|
||||
- Appearance version pinning (niche)
|
||||
- UC pet support (being phased out)
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Is the URL-as-state approach sustainable as complexity grows (saving, closet, conflicts)?
|
||||
- Which Wardrobe 2020 features are actually essential vs. nice-to-have? (Need user feedback)
|
||||
- How to handle the transition period (maintain both? redirect? feature flag?)
|
||||
|
||||
## References
|
||||
|
||||
- [Impress 2020 Dependencies](./impress-2020-dependencies.md) - What still depends on the Impress 2020 service
|
||||
- [Customization Architecture](./customization-architecture.md) - Data model deep dive
|
||||
- Wardrobe 2020 source: `app/javascript/wardrobe-2020/` (the authoritative reference for feature parity comparisons)
|
||||
310
spec/helpers/wardrobe_helper_spec.rb
Normal file
310
spec/helpers/wardrobe_helper_spec.rb
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe WardrobeHelper, type: :helper do
|
||||
fixtures :zones, :colors, :species, :pet_types
|
||||
|
||||
# Use the Blue Acara's body_id throughout tests
|
||||
let(:body_id) { pet_types(:blue_acara).body_id }
|
||||
|
||||
# Helper to create a test outfit with a pet type
|
||||
# Biology assets are just setup noise - we only care about pet_type.body_id
|
||||
def create_test_outfit
|
||||
pet_type = pet_types(:blue_acara)
|
||||
|
||||
# PetState requires at least one biology asset (validation requirement)
|
||||
bio_asset = SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: (@bio_remote_id = (@bio_remote_id || 1000) + 1),
|
||||
url: "https://images.neopets.example/bio_#{@bio_remote_id}.swf",
|
||||
zone: zones(:body),
|
||||
zones_restrict: "",
|
||||
body_id: 0
|
||||
)
|
||||
|
||||
pet_state = PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: "HAPPY_MASC",
|
||||
swf_assets: [bio_asset],
|
||||
swf_asset_ids: [bio_asset.id]
|
||||
)
|
||||
|
||||
Outfit.create!(pet_state: pet_state)
|
||||
end
|
||||
|
||||
# Helper to create SwfAssets for items (matches pattern from item_spec.rb)
|
||||
def build_item_asset(zone, body_id:)
|
||||
@item_remote_id = (@item_remote_id || 0) + 1
|
||||
SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: @item_remote_id,
|
||||
url: "https://images.neopets.example/item_#{@item_remote_id}.swf",
|
||||
zone: zone,
|
||||
zones_restrict: "",
|
||||
body_id: body_id
|
||||
)
|
||||
end
|
||||
|
||||
# Helper to create an item with zones
|
||||
def create_item(name, zones_and_bodies)
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "",
|
||||
thumbnail_url: "https://images.neopets.example/#{name.parameterize}.gif",
|
||||
rarity: "Common",
|
||||
price: 100,
|
||||
zones_restrict: "0" * 52
|
||||
)
|
||||
|
||||
zones_and_bodies.each do |zone, body_id|
|
||||
item.swf_assets << build_item_asset(zone, body_id: body_id)
|
||||
end
|
||||
|
||||
item
|
||||
end
|
||||
|
||||
describe '#outfit_items_by_zone' do
|
||||
context 'with nil pet_type' do
|
||||
it 'returns empty array' do
|
||||
# Create an outfit without a pet_state (pet_type will be nil)
|
||||
outfit = Outfit.new
|
||||
# Allow the delegation to fail gracefully
|
||||
allow(outfit).to receive(:pet_type).and_return(nil)
|
||||
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty outfit' do
|
||||
it 'returns empty array' do
|
||||
outfit = create_test_outfit
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
expect(result).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with single-zone items' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:hat_item) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
|
||||
let!(:jacket_item) { create_item("Red Jacket", [[zones(:jacket), body_id]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << hat_item
|
||||
outfit.worn_items << jacket_item
|
||||
end
|
||||
|
||||
it 'groups items by zone' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
expect(result.length).to eq(2)
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
expect(zone_labels).to contain_exactly("Hat", "Jacket")
|
||||
end
|
||||
|
||||
it 'sorts zones alphabetically' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
expect(zone_labels).to eq(["Hat", "Jacket"])
|
||||
end
|
||||
|
||||
it 'includes items in their respective zones' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||
jacket_group = result.find { |g| g[:zone_label] == "Jacket" }
|
||||
|
||||
expect(hat_group[:items]).to eq([hat_item])
|
||||
expect(jacket_group[:items]).to eq([jacket_item])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multiple items in same zone' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:hat1) { create_item("Awesome Hat", [[zones(:hat), body_id]]) }
|
||||
let!(:hat2) { create_item("Cool Hat", [[zones(:hat), body_id]]) }
|
||||
let!(:hat3) { create_item("Blue Hat", [[zones(:hat), body_id]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << hat1
|
||||
outfit.worn_items << hat2
|
||||
outfit.worn_items << hat3
|
||||
end
|
||||
|
||||
it 'sorts items alphabetically within zone' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||
item_names = hat_group[:items].map(&:name)
|
||||
|
||||
expect(item_names).to eq(["Awesome Hat", "Blue Hat", "Cool Hat"])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multi-zone item (no conflicts)' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:bow_tie) do
|
||||
create_item("Bow Tie", [
|
||||
[zones(:collar), body_id],
|
||||
[zones(:necklace), body_id],
|
||||
[zones(:earrings), body_id]
|
||||
])
|
||||
end
|
||||
|
||||
before do
|
||||
outfit.worn_items << bow_tie
|
||||
end
|
||||
|
||||
it 'shows item in only one zone (simplification)' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
# Should show in Collar zone only (first alphabetically)
|
||||
expect(result.length).to eq(1)
|
||||
expect(result[0][:zone_label]).to eq("Collar")
|
||||
expect(result[0][:items]).to eq([bow_tie])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with multi-zone simplification (item appears in conflict zone)' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:multi_zone_item) do
|
||||
create_item("Fancy Outfit", [
|
||||
[zones(:jacket), body_id],
|
||||
[zones(:collar), body_id]
|
||||
])
|
||||
end
|
||||
let!(:collar_item) { create_item("Simple Collar", [[zones(:collar), body_id]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << multi_zone_item
|
||||
outfit.worn_items << collar_item
|
||||
end
|
||||
|
||||
it 'keeps conflict zone and hides redundant single-item zone' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
|
||||
# Should show Collar (has conflict with 2 items)
|
||||
# Should NOT show Jacket (redundant - item already in Collar)
|
||||
expect(zone_labels).to eq(["Collar"])
|
||||
|
||||
collar_group = result.find { |g| g[:zone_label] == "Collar" }
|
||||
item_names = collar_group[:items].map(&:name).sort
|
||||
expect(item_names).to eq(["Fancy Outfit", "Simple Collar"])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with zone label disambiguation' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
|
||||
# Create additional zones with duplicate labels for this test
|
||||
let!(:markings_zone_a) do
|
||||
Zone.create!(label: "Markings", depth: 100, plain_label: "markings_a", type_id: 2)
|
||||
end
|
||||
let!(:markings_zone_b) do
|
||||
Zone.create!(label: "Markings", depth: 101, plain_label: "markings_b", type_id: 2)
|
||||
end
|
||||
|
||||
let!(:item_zone_a) { create_item("Tattoo A", [[markings_zone_a, body_id]]) }
|
||||
let!(:item_zone_b) { create_item("Tattoo B", [[markings_zone_b, body_id]]) }
|
||||
let!(:item_zone_a_b) { create_item("Tattoo C", [[markings_zone_a, body_id]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << item_zone_a
|
||||
outfit.worn_items << item_zone_b
|
||||
outfit.worn_items << item_zone_a_b
|
||||
end
|
||||
|
||||
it 'adds zone IDs to duplicate labels' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
|
||||
# Both should have IDs appended since they share the label "Markings"
|
||||
expect(zone_labels).to contain_exactly(
|
||||
"Markings (##{markings_zone_a.id})",
|
||||
"Markings (##{markings_zone_b.id})"
|
||||
)
|
||||
end
|
||||
|
||||
it 'groups items correctly by zone despite same label' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
zone_a_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_a.id})" }
|
||||
zone_b_group = result.find { |g| g[:zone_label] == "Markings (##{markings_zone_b.id})" }
|
||||
|
||||
expect(zone_a_group[:items].map(&:name).sort).to eq(["Tattoo A", "Tattoo C"])
|
||||
expect(zone_b_group[:items].map(&:name)).to eq(["Tattoo B"])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with incompatible items' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:compatible_item) { create_item("Fits Pet", [[zones(:hat), body_id]]) }
|
||||
let!(:incompatible_item) { create_item("Wrong Body", [[zones(:jacket), 999]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << compatible_item
|
||||
outfit.worn_items << incompatible_item
|
||||
end
|
||||
|
||||
it 'separates incompatible items into their own section' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
expect(zone_labels).to contain_exactly("Hat", "Incompatible")
|
||||
|
||||
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
|
||||
expect(incompatible_group[:items]).to eq([incompatible_item])
|
||||
end
|
||||
|
||||
it 'sorts incompatible items alphabetically' do
|
||||
outfit.worn_items << create_item("Alpha Item", [[zones(:jacket), 999]])
|
||||
outfit.worn_items << create_item("Zulu Item", [[zones(:jacket), 999]])
|
||||
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
incompatible_group = result.find { |g| g[:zone_label] == "Incompatible" }
|
||||
|
||||
item_names = incompatible_group[:items].map(&:name)
|
||||
expect(item_names).to eq(["Alpha Item", "Wrong Body", "Zulu Item"])
|
||||
end
|
||||
end
|
||||
|
||||
context 'with complex multi-zone scenario' do
|
||||
let(:outfit) { create_test_outfit }
|
||||
let!(:bg1) { create_item("Forest Background", [[zones(:background), 0]]) }
|
||||
let!(:bg2) { create_item("Beach Background", [[zones(:background), 0]]) }
|
||||
let!(:multi_item) do
|
||||
create_item("Wings and Hat", [
|
||||
[zones(:wings), 0],
|
||||
[zones(:hat), 0]
|
||||
])
|
||||
end
|
||||
let!(:hat_item) { create_item("Simple Hat", [[zones(:hat), 0]]) }
|
||||
|
||||
before do
|
||||
outfit.worn_items << bg1
|
||||
outfit.worn_items << bg2
|
||||
outfit.worn_items << multi_item
|
||||
outfit.worn_items << hat_item
|
||||
end
|
||||
|
||||
it 'correctly applies all sorting and grouping rules' do
|
||||
result = helper.outfit_items_by_zone(outfit)
|
||||
|
||||
# Background: has conflict (2 items)
|
||||
# Hat: has conflict (2 items, including multi-zone item)
|
||||
# Wings: should be hidden (multi-zone item already in Hat conflict)
|
||||
zone_labels = result.map { |g| g[:zone_label] }
|
||||
expect(zone_labels).to eq(["Background", "Hat"])
|
||||
|
||||
bg_group = result.find { |g| g[:zone_label] == "Background" }
|
||||
expect(bg_group[:items].map(&:name)).to eq(["Beach Background", "Forest Background"])
|
||||
|
||||
hat_group = result.find { |g| g[:zone_label] == "Hat" }
|
||||
expect(hat_group[:items].map(&:name)).to eq(["Simple Hat", "Wings and Hat"])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -3,6 +3,289 @@ require_relative '../rails_helper'
|
|||
RSpec.describe Outfit do
|
||||
fixtures :zones, :colors, :species
|
||||
|
||||
let(:blue) { colors(:blue) }
|
||||
let(:acara) { species(:acara) }
|
||||
|
||||
before do
|
||||
PetType.destroy_all
|
||||
@pet_type = PetType.create!(color: blue, species: acara, body_id: 1)
|
||||
@pet_state = create_pet_state(@pet_type, "HAPPY_MASC")
|
||||
@outfit = Outfit.new(pet_state: @pet_state)
|
||||
end
|
||||
|
||||
def create_pet_state(pet_type, pose)
|
||||
# Create a basic biology asset so pet state saves correctly
|
||||
swf_asset = SwfAsset.create!(
|
||||
type: "biology",
|
||||
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
|
||||
url: "https://images.neopets.example/biology.swf",
|
||||
zone: zones(:body),
|
||||
zones_restrict: "",
|
||||
body_id: pet_type.body_id
|
||||
)
|
||||
PetState.create!(
|
||||
pet_type: pet_type,
|
||||
pose: pose,
|
||||
swf_assets: [swf_asset],
|
||||
swf_asset_ids: [swf_asset.id]
|
||||
)
|
||||
end
|
||||
|
||||
def create_item(name, zone, body_id: 1, zones_restrict: "")
|
||||
item = Item.create!(
|
||||
name: name,
|
||||
description: "Test item",
|
||||
thumbnail_url: "https://images.neopets.example/item.png",
|
||||
zones_restrict: zones_restrict,
|
||||
rarity: "Common",
|
||||
price: 100
|
||||
)
|
||||
swf_asset = SwfAsset.create!(
|
||||
type: "object",
|
||||
remote_id: (SwfAsset.maximum(:remote_id) || 0) + 1,
|
||||
url: "https://images.neopets.example/#{name}.swf",
|
||||
zone: zone,
|
||||
zones_restrict: zones_restrict,
|
||||
body_id: body_id
|
||||
)
|
||||
item.swf_assets << swf_asset
|
||||
item
|
||||
end
|
||||
|
||||
describe "Item::Appearance#compatible_with?" do
|
||||
it "returns true for items in different zones with no restrictions" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
|
||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||
hat_appearance = appearances[hat.id]
|
||||
shirt_appearance = appearances[shirt.id]
|
||||
|
||||
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
|
||||
expect(shirt_appearance.compatible_with?(hat_appearance)).to be true
|
||||
end
|
||||
|
||||
it "returns false for items in the same zone" do
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
appearances = Item.appearances_for([hat1, hat2], @pet_type)
|
||||
hat1_appearance = appearances[hat1.id]
|
||||
hat2_appearance = appearances[hat2.id]
|
||||
|
||||
expect(hat1_appearance.compatible_with?(hat2_appearance)).to be false
|
||||
expect(hat2_appearance.compatible_with?(hat1_appearance)).to be false
|
||||
end
|
||||
|
||||
it "returns false when one item restricts a zone the other occupies" do
|
||||
# Create a hat that restricts the ruff zone (zone 29)
|
||||
# The zones_restrict format is a 52-character bitstring where bit N corresponds to zone N+1
|
||||
# Zones are 1-indexed, so zone 29 needs the bit at position 28 (0-indexed from right)
|
||||
# Build string from right to left: 28 zeros, then "1", then 23 zeros
|
||||
zones_restrict = ("0" * 23 + "1" + "0" * 28).reverse.chars.reverse.join
|
||||
|
||||
# Simpler approach: create a 52-char string with bit 28 set to "1"
|
||||
zones_restrict_array = Array.new(52, "0")
|
||||
zones_restrict_array[28] = "1" # Set bit for zone 29
|
||||
zones_restrict = zones_restrict_array.join
|
||||
|
||||
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
|
||||
|
||||
# Create an item in the ruff zone
|
||||
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||
|
||||
appearances = Item.appearances_for([restricting_hat, ruff_item], @pet_type)
|
||||
hat_appearance = appearances[restricting_hat.id]
|
||||
ruff_appearance = appearances[ruff_item.id]
|
||||
|
||||
expect(hat_appearance.compatible_with?(ruff_appearance)).to be false
|
||||
expect(ruff_appearance.compatible_with?(hat_appearance)).to be false
|
||||
end
|
||||
|
||||
it "returns true for empty appearances" do
|
||||
# Create items that don't fit the current pet (wrong body_id)
|
||||
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||
shirt = create_item("Shirt", zones(:shirtdress), body_id: 999)
|
||||
|
||||
appearances = Item.appearances_for([hat, shirt], @pet_type)
|
||||
hat_appearance = appearances[hat.id]
|
||||
shirt_appearance = appearances[shirt.id]
|
||||
|
||||
# Both should be empty (no swf_assets for this pet)
|
||||
expect(hat_appearance).to be_empty
|
||||
expect(shirt_appearance).to be_empty
|
||||
|
||||
# Empty appearances should be compatible
|
||||
expect(hat_appearance.compatible_with?(shirt_appearance)).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe "#without_item" do
|
||||
it "returns a new outfit without the given item" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit_with_hat = @outfit.with_item(hat)
|
||||
|
||||
new_outfit = outfit_with_hat.without_item(hat)
|
||||
|
||||
expect(new_outfit.worn_items).not_to include(hat)
|
||||
expect(outfit_with_hat.worn_items).to include(hat) # Original unchanged
|
||||
end
|
||||
|
||||
it "returns a new outfit instance (immutable)" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit_with_hat = @outfit.with_item(hat)
|
||||
|
||||
new_outfit = outfit_with_hat.without_item(hat)
|
||||
|
||||
expect(new_outfit).not_to eq(outfit_with_hat)
|
||||
expect(new_outfit.object_id).not_to eq(outfit_with_hat.object_id)
|
||||
end
|
||||
|
||||
it "does nothing if the item is not worn" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.without_item(hat)
|
||||
|
||||
expect(new_outfit.worn_items).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
describe "#with_item" do
|
||||
it "adds an item when there are no conflicts" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
expect(new_outfit.worn_items).to include(hat)
|
||||
end
|
||||
|
||||
it "returns a new outfit instance (immutable)" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
expect(new_outfit).not_to eq(@outfit)
|
||||
expect(new_outfit.object_id).not_to eq(@outfit.object_id)
|
||||
expect(@outfit.worn_items).to be_empty # Original unchanged
|
||||
end
|
||||
|
||||
it "is idempotent (adding same item twice has no effect)" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
outfit1 = @outfit.with_item(hat)
|
||||
outfit2 = outfit1.with_item(hat)
|
||||
|
||||
expect(outfit1.worn_items.size).to eq(1)
|
||||
expect(outfit2.worn_items.size).to eq(1)
|
||||
expect(outfit2.worn_items).to include(hat)
|
||||
end
|
||||
|
||||
it "does not add items that don't fit this pet" do
|
||||
# Create item with wrong body_id
|
||||
hat = create_item("Hat", zones(:hat1), body_id: 999)
|
||||
|
||||
new_outfit = @outfit.with_item(hat)
|
||||
|
||||
expect(new_outfit.worn_items).to be_empty
|
||||
end
|
||||
|
||||
context "with conflicting items" do
|
||||
it "moves conflicting item to closet when items occupy the same zone" do
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
outfit_with_hat1 = @outfit.with_item(hat1)
|
||||
outfit_with_hat2 = outfit_with_hat1.with_item(hat2)
|
||||
|
||||
expect(outfit_with_hat2.worn_items).to include(hat2)
|
||||
expect(outfit_with_hat2.worn_items).not_to include(hat1)
|
||||
expect(outfit_with_hat2.closeted_items).to include(hat1)
|
||||
end
|
||||
|
||||
it "moves conflicting item to closet when new item restricts zone" do
|
||||
# Create item in ruff zone
|
||||
ruff_item = create_item("Ruff Item", zones(:ruff))
|
||||
|
||||
# Create hat that restricts ruff zone (zone 29)
|
||||
# zones_restrict is 0-indexed, so zone 29 needs bit 28 to be "1"
|
||||
zones_restrict_array = Array.new(52, "0")
|
||||
zones_restrict_array[28] = "1"
|
||||
zones_restrict = zones_restrict_array.join
|
||||
restricting_hat = create_item("Restricting Hat", zones(:hat1), zones_restrict: zones_restrict)
|
||||
|
||||
# First wear ruff item, then wear restricting hat
|
||||
outfit_with_ruff = @outfit.with_item(ruff_item)
|
||||
outfit_with_hat = outfit_with_ruff.with_item(restricting_hat)
|
||||
|
||||
expect(outfit_with_hat.worn_items).to include(restricting_hat)
|
||||
expect(outfit_with_hat.worn_items).not_to include(ruff_item)
|
||||
expect(outfit_with_hat.closeted_items).to include(ruff_item)
|
||||
end
|
||||
|
||||
it "keeps compatible items when adding new item" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
pants = create_item("Pants", zones(:trousers))
|
||||
|
||||
outfit1 = @outfit.with_item(hat).with_item(shirt)
|
||||
outfit2 = outfit1.with_item(pants)
|
||||
|
||||
expect(outfit2.worn_items).to include(hat, shirt, pants)
|
||||
expect(outfit2.closeted_items).to be_empty
|
||||
end
|
||||
|
||||
it "can move multiple conflicting items to closet" do
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
hat3 = create_item("Hat 3", zones(:hat1))
|
||||
|
||||
# Wear hat1 and hat2 by manually building the outfit
|
||||
# (normally you can't, but we're testing the conflict resolution)
|
||||
outfit = @outfit.dup
|
||||
outfit.worn_items << hat1
|
||||
outfit.worn_items << hat2
|
||||
|
||||
# Now add hat3, which should move both hat1 and hat2 to closet
|
||||
outfit_with_hat3 = outfit.with_item(hat3)
|
||||
|
||||
expect(outfit_with_hat3.worn_items).to contain_exactly(hat3)
|
||||
expect(outfit_with_hat3.closeted_items).to contain_exactly(hat1, hat2)
|
||||
end
|
||||
|
||||
it "does not duplicate items in closet if already closeted" do
|
||||
hat1 = create_item("Hat 1", zones(:hat1))
|
||||
hat2 = create_item("Hat 2", zones(:hat1))
|
||||
|
||||
# Wear hat1
|
||||
outfit1 = @outfit.with_item(hat1)
|
||||
|
||||
# Add hat2 (moves hat1 to closet)
|
||||
outfit2 = outfit1.with_item(hat2)
|
||||
|
||||
# Add hat2 again (should be idempotent, not duplicate hat1 in closet)
|
||||
outfit3 = outfit2.with_item(hat2)
|
||||
|
||||
expect(outfit3.closeted_items.size).to eq(1)
|
||||
expect(outfit3.closeted_items).to include(hat1)
|
||||
end
|
||||
end
|
||||
|
||||
context "edge cases" do
|
||||
it "handles nil item gracefully" do
|
||||
expect { @outfit.with_item(nil) }.not_to raise_error
|
||||
end
|
||||
|
||||
it "works with outfit that has no pet_state" do
|
||||
# This shouldn't happen in practice, but let's be defensive
|
||||
outfit_no_pet = Outfit.new
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
|
||||
# Should not crash, but also won't add the item
|
||||
expect { outfit_no_pet.with_item(hat) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Helper to create a pet state with specific swf_assets
|
||||
def build_pet_state(pet_type, pose: "HAPPY_FEM", swf_assets: [])
|
||||
pet_state = PetState.create!(
|
||||
|
|
@ -60,6 +343,72 @@ RSpec.describe Outfit do
|
|||
item
|
||||
end
|
||||
|
||||
describe "#same_wardrobe_state_as?" do
|
||||
it "returns true for outfits with identical state" do
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns true even when names differ (name is not part of wardrobe state)" do
|
||||
outfit1 = Outfit.new(name: "Outfit A", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Outfit B", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when poses differ" do
|
||||
other_pet_state = create_pet_state(@pet_type, "SAD_MASC")
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns false when worn items differ" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat])
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns true regardless of worn item order" do
|
||||
hat = create_item("Hat", zones(:hat1))
|
||||
shirt = create_item("Shirt", zones(:shirtdress))
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [hat, shirt])
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state, worn_items: [shirt, hat])
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be true
|
||||
end
|
||||
|
||||
it "returns false when species differ" do
|
||||
other_pet_type = PetType.create!(color: blue, species: species(:blumaroo), body_id: 2)
|
||||
other_pet_state = create_pet_state(other_pet_type, "HAPPY_MASC")
|
||||
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: other_pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
it "returns false when alt styles differ" do
|
||||
alt_style = AltStyle.create!(
|
||||
species: acara,
|
||||
color: blue,
|
||||
body_id: 999,
|
||||
series_name: "Nostalgic",
|
||||
thumbnail_url: "https://images.neopets.example/alt.png"
|
||||
)
|
||||
outfit1 = Outfit.new(name: "Test", pet_state: @pet_state, alt_style: alt_style)
|
||||
outfit2 = Outfit.new(name: "Test", pet_state: @pet_state)
|
||||
|
||||
expect(outfit1.same_wardrobe_state_as?(outfit2)).to be false
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
describe "#visible_layers" do
|
||||
before do
|
||||
# Clean up any existing pet types to avoid conflicts
|
||||
|
|
|
|||
|
|
@ -38,4 +38,23 @@ RSpec.describe PetType do
|
|||
expect(PetType.find_by_param!("123-456")).to eq pet_types(:newcolor_newspecies)
|
||||
end
|
||||
end
|
||||
|
||||
describe ".for_species_and_color" do
|
||||
it('returns the exact match when it exists') do
|
||||
result = PetType.for_species_and_color(species_id: species(:acara), color_id: colors(:blue))
|
||||
expect(result).to eq pet_types(:blue_acara)
|
||||
end
|
||||
|
||||
it('returns nil when species is nil') do
|
||||
result = PetType.for_species_and_color(species_id: nil, color_id: colors(:blue))
|
||||
expect(result).to be_nil
|
||||
end
|
||||
|
||||
it('falls back to a simple color when exact match does not exist') do
|
||||
# Request a species that exists but with a color that might not
|
||||
# It should fall back to a basic/standard color for that species
|
||||
result = PetType.for_species_and_color(species_id: species(:acara), color_id: 999)
|
||||
expect(result).to eq pet_types(:blue_acara)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,7 +1,15 @@
|
|||
require_relative '../rails_helper'
|
||||
|
||||
RSpec.describe Species do
|
||||
fixtures :species
|
||||
fixtures :species, :colors
|
||||
|
||||
describe "#valid_colors_for_species" do
|
||||
it('returns colors that have pet types for the species') do
|
||||
# The Blue Acara exists in fixtures, as does a "Color #123 Acara", which we'll ignore.
|
||||
compatible_colors = species(:acara).compatible_colors
|
||||
expect(compatible_colors.map(&:id)).to eq [8]
|
||||
end
|
||||
end
|
||||
|
||||
describe '#to_param' do
|
||||
it("uses name when possible") do
|
||||
|
|
|
|||
Loading…
Reference in a new issue