Compare commits
6 commits
4fff8d88f2
...
207c65f209
Author | SHA1 | Date | |
---|---|---|---|
207c65f209 | |||
433a14104f | |||
514c99fb42 | |||
e2ab8bbc9c | |||
57beca1b3c | |||
32f5d6d4a0 |
5 changed files with 303 additions and 71 deletions
|
@ -10,7 +10,11 @@ class AltStylesController < ApplicationController
|
||||||
|
|
||||||
respond_to do |format|
|
respond_to do |format|
|
||||||
format.html { render }
|
format.html { render }
|
||||||
format.json { render json: @alt_styles }
|
format.json {
|
||||||
|
render json: @alt_styles.as_json(
|
||||||
|
methods: [:adjective_name, :thumbnail_url],
|
||||||
|
)
|
||||||
|
}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,10 +11,16 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Portal,
|
Portal,
|
||||||
|
Tab,
|
||||||
|
Tabs,
|
||||||
|
TabList,
|
||||||
|
TabPanel,
|
||||||
|
TabPanels,
|
||||||
VisuallyHidden,
|
VisuallyHidden,
|
||||||
useColorModeValue,
|
useColorModeValue,
|
||||||
useTheme,
|
useTheme,
|
||||||
useToast,
|
useToast,
|
||||||
|
useToken,
|
||||||
} from "@chakra-ui/react";
|
} from "@chakra-ui/react";
|
||||||
import { loadable } from "../util";
|
import { loadable } from "../util";
|
||||||
|
|
||||||
|
@ -22,6 +28,7 @@ import { petAppearanceFragment } from "../components/useOutfitAppearance";
|
||||||
import getVisibleLayers from "../components/getVisibleLayers";
|
import getVisibleLayers from "../components/getVisibleLayers";
|
||||||
import { OutfitLayers } from "../components/OutfitPreview";
|
import { OutfitLayers } from "../components/OutfitPreview";
|
||||||
import SupportOnly from "./support/SupportOnly";
|
import SupportOnly from "./support/SupportOnly";
|
||||||
|
import { useAltStylesForSpecies } from "../loaders/alt-styles";
|
||||||
import useSupport from "./support/useSupport";
|
import useSupport from "./support/useSupport";
|
||||||
import { useLocalStorage } from "../util";
|
import { useLocalStorage } from "../util";
|
||||||
|
|
||||||
|
@ -63,9 +70,9 @@ function PosePicker({
|
||||||
onUnlockFocus,
|
onUnlockFocus,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
const theme = useTheme();
|
|
||||||
const initialFocusRef = React.useRef();
|
const initialFocusRef = React.useRef();
|
||||||
const { loading, error, poseInfos } = usePoses(speciesId, colorId, pose);
|
const posesQuery = usePoses(speciesId, colorId, pose);
|
||||||
|
const altStylesQuery = useAltStylesForSpecies(speciesId);
|
||||||
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
|
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
|
||||||
"DTIPosePickerIsInSupportMode",
|
"DTIPosePickerIsInSupportMode",
|
||||||
false,
|
false,
|
||||||
|
@ -73,6 +80,11 @@ function PosePicker({
|
||||||
const { isSupportUser } = useSupport();
|
const { isSupportUser } = useSupport();
|
||||||
const toast = useToast();
|
const toast = useToast();
|
||||||
|
|
||||||
|
const loading = posesQuery.loading || altStylesQuery.loading;
|
||||||
|
const error = posesQuery.error ?? altStylesQuery.error;
|
||||||
|
const poseInfos = posesQuery.poseInfos;
|
||||||
|
const altStyles = altStylesQuery.data ?? [];
|
||||||
|
|
||||||
// Resize the Popover when we toggle support mode, because it probably will
|
// Resize the Popover when we toggle support mode, because it probably will
|
||||||
// affect the content size.
|
// affect the content size.
|
||||||
React.useLayoutEffect(() => {
|
React.useLayoutEffect(() => {
|
||||||
|
@ -139,15 +151,9 @@ function PosePicker({
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If there's only one pose anyway, don't bother showing a picker!
|
const numStandardPoses = Object.values(poseInfos).filter(
|
||||||
// (Unless we're Support, in which case we want the ability to pop it open to
|
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
|
||||||
// inspect and label the Unknown poses!)
|
|
||||||
const numAvailablePoses = Object.values(poseInfos).filter(
|
|
||||||
(p) => p.isAvailable,
|
|
||||||
).length;
|
).length;
|
||||||
if (numAvailablePoses <= 1 && !isSupportUser) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChange = (e) => {
|
const onChange = (e) => {
|
||||||
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
dispatchToOutfit({ type: "setPose", pose: e.target.value });
|
||||||
|
@ -164,44 +170,15 @@ function PosePicker({
|
||||||
lazyBehavior="keepMounted"
|
lazyBehavior="keepMounted"
|
||||||
>
|
>
|
||||||
{({ isOpen }) => (
|
{({ isOpen }) => (
|
||||||
<ClassNames>
|
<>
|
||||||
{({ css, cx }) => (
|
<PopoverTrigger>
|
||||||
<>
|
<PosePickerButton pose={pose} isOpen={isOpen} {...props} />
|
||||||
<PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<Button
|
<Portal>
|
||||||
variant="unstyled"
|
<PopoverContent>
|
||||||
boxShadow="md"
|
<Tabs size="sm" variant="soft-rounded">
|
||||||
d="flex"
|
<TabPanels position="relative">
|
||||||
alignItems="center"
|
<TabPanel>
|
||||||
justifyContent="center"
|
|
||||||
_focus={{ borderColor: "gray.50" }}
|
|
||||||
_hover={{ borderColor: "gray.50" }}
|
|
||||||
outline="initial"
|
|
||||||
className={cx(
|
|
||||||
css`
|
|
||||||
border: 1px solid transparent !important;
|
|
||||||
transition: border-color 0.2s !important;
|
|
||||||
|
|
||||||
&:focus,
|
|
||||||
&:hover,
|
|
||||||
&.is-open {
|
|
||||||
border-color: ${theme.colors.gray["50"]} !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-open {
|
|
||||||
border-width: 2px !important;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
isOpen && "is-open",
|
|
||||||
)}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
<EmojiImage src={getIcon(pose)} alt="Choose a pose" />
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<Portal>
|
|
||||||
<PopoverContent>
|
|
||||||
<Box p="4" position="relative">
|
|
||||||
{isInSupportMode ? (
|
{isInSupportMode ? (
|
||||||
<PosePickerSupport
|
<PosePickerSupport
|
||||||
speciesId={speciesId}
|
speciesId={speciesId}
|
||||||
|
@ -218,20 +195,8 @@ function PosePicker({
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
initialFocusRef={initialFocusRef}
|
initialFocusRef={initialFocusRef}
|
||||||
/>
|
/>
|
||||||
{numAvailablePoses <= 1 && (
|
{numStandardPoses == 0 && (
|
||||||
<SupportOnly>
|
<PosePickerEmptyExplanation />
|
||||||
<Box
|
|
||||||
fontSize="xs"
|
|
||||||
fontStyle="italic"
|
|
||||||
textAlign="center"
|
|
||||||
opacity="0.7"
|
|
||||||
marginTop="2"
|
|
||||||
>
|
|
||||||
The empty picker is hidden for most users!
|
|
||||||
<br />
|
|
||||||
You can see it because you're a Support user.
|
|
||||||
</Box>
|
|
||||||
</SupportOnly>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
@ -243,18 +208,76 @@ function PosePicker({
|
||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
</SupportOnly>
|
</SupportOnly>
|
||||||
</Box>
|
</TabPanel>
|
||||||
<PopoverArrow />
|
<TabPanel>
|
||||||
</PopoverContent>
|
<StyleSelect altStyles={altStyles} />
|
||||||
</Portal>
|
<StyleExplanation />
|
||||||
</>
|
</TabPanel>
|
||||||
)}
|
</TabPanels>
|
||||||
</ClassNames>
|
<SupportOnly>
|
||||||
|
<TabList paddingX="2" paddingY="1">
|
||||||
|
<Tab width="50%">Expressions</Tab>
|
||||||
|
<Tab width="50%">Styles</Tab>
|
||||||
|
</TabList>
|
||||||
|
</SupportOnly>
|
||||||
|
</Tabs>
|
||||||
|
<PopoverArrow />
|
||||||
|
</PopoverContent>
|
||||||
|
</Portal>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PosePickerButton({ pose, isOpen, ...props }, ref) {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css, cx }) => (
|
||||||
|
<Button
|
||||||
|
variant="unstyled"
|
||||||
|
boxShadow="md"
|
||||||
|
d="flex"
|
||||||
|
alignItems="center"
|
||||||
|
justifyContent="center"
|
||||||
|
_focus={{ borderColor: "gray.50" }}
|
||||||
|
_hover={{ borderColor: "gray.50" }}
|
||||||
|
outline="initial"
|
||||||
|
fontSize="sm"
|
||||||
|
fontWeight="normal"
|
||||||
|
className={cx(
|
||||||
|
css`
|
||||||
|
border: 1px solid transparent !important;
|
||||||
|
transition: border-color 0.2s !important;
|
||||||
|
padding-inline: 0.75em;
|
||||||
|
|
||||||
|
&:focus,
|
||||||
|
&:hover,
|
||||||
|
&.is-open {
|
||||||
|
border-color: ${theme.colors.gray["50"]} !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-open {
|
||||||
|
border-width: 2px !important;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
isOpen && "is-open",
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
>
|
||||||
|
<EmojiImage src={getIcon(pose)} alt="" />
|
||||||
|
<Box width=".5em" />
|
||||||
|
{getLabel(pose)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
PosePickerButton = React.forwardRef(PosePickerButton);
|
||||||
|
|
||||||
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
|
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
|
||||||
return (
|
return (
|
||||||
<Box display="flex" flexDirection="column" alignItems="center">
|
<Box display="flex" flexDirection="column" alignItems="center">
|
||||||
|
@ -371,6 +394,8 @@ const GENDER_PRESENTATION_STRINGS = {
|
||||||
SICK_FEM: "Feminine",
|
SICK_FEM: "Feminine",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
|
||||||
|
|
||||||
function PoseOption({
|
function PoseOption({
|
||||||
poseInfo,
|
poseInfo,
|
||||||
onChange,
|
onChange,
|
||||||
|
@ -512,6 +537,152 @@ function PoseOption({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PosePickerEmptyExplanation() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
fontSize="xs"
|
||||||
|
fontStyle="italic"
|
||||||
|
textAlign="center"
|
||||||
|
opacity="0.7"
|
||||||
|
marginTop="2"
|
||||||
|
>
|
||||||
|
We're still working on labeling these! For now, we're just giving you one
|
||||||
|
of the expressions we have.
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyleSelect({ altStyles }) {
|
||||||
|
const [selectedStyleId, setSelectedStyleId] = React.useState(null);
|
||||||
|
|
||||||
|
const defaultStyle = { id: null, adjectiveName: "Default" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
as="form"
|
||||||
|
direction="column"
|
||||||
|
gap="2"
|
||||||
|
maxHeight="40vh"
|
||||||
|
padding="4px"
|
||||||
|
overflow="auto"
|
||||||
|
>
|
||||||
|
<StyleOption
|
||||||
|
altStyle={defaultStyle}
|
||||||
|
checked={selectedStyleId == null}
|
||||||
|
onChange={setSelectedStyleId}
|
||||||
|
/>
|
||||||
|
{altStyles.map((altStyle) => (
|
||||||
|
<StyleOption
|
||||||
|
key={altStyle.id}
|
||||||
|
altStyle={altStyle}
|
||||||
|
checked={selectedStyleId === altStyle.id}
|
||||||
|
onChange={setSelectedStyleId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyleOption({ altStyle, checked, onChange }) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const selectedBorderColor = useColorModeValue(
|
||||||
|
theme.colors.green["600"],
|
||||||
|
theme.colors.green["300"],
|
||||||
|
);
|
||||||
|
const outlineShadow = useToken("shadows", "outline");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClassNames>
|
||||||
|
{({ css, cx }) => (
|
||||||
|
<Box
|
||||||
|
as="label"
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(e) => {
|
||||||
|
// HACK: We need the timeout to beat the popover's focus stealing!
|
||||||
|
const input = e.currentTarget.querySelector("input");
|
||||||
|
setTimeout(() => input.focus(), 0);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<VisuallyHidden
|
||||||
|
as="input"
|
||||||
|
type="radio"
|
||||||
|
name="style"
|
||||||
|
value={altStyle.id}
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => onChange(altStyle.id)}
|
||||||
|
/>
|
||||||
|
<Flex
|
||||||
|
alignItems="center"
|
||||||
|
gap="2"
|
||||||
|
borderRadius="md"
|
||||||
|
// HACK: Don't let the thumbnail image overlap the border
|
||||||
|
paddingLeft="2px"
|
||||||
|
paddingY="2px"
|
||||||
|
className={cx(css`
|
||||||
|
border: 2px solid transparent;
|
||||||
|
opacity: 0.8;
|
||||||
|
transition: all 0.2s;
|
||||||
|
|
||||||
|
input:focus + & {
|
||||||
|
box-shadow: ${outlineShadow};
|
||||||
|
}
|
||||||
|
|
||||||
|
input:checked + & {
|
||||||
|
border-color: ${selectedBorderColor};
|
||||||
|
opacity: 1;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
`)}
|
||||||
|
>
|
||||||
|
{altStyle.thumbnailUrl ? (
|
||||||
|
<Box
|
||||||
|
as="img"
|
||||||
|
src={altStyle.thumbnailUrl}
|
||||||
|
alt="Item thumbnail"
|
||||||
|
width="40px"
|
||||||
|
height="40px"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Box width="40px" height="40px" />
|
||||||
|
)}
|
||||||
|
<Box width="2" />
|
||||||
|
{altStyle.adjectiveName}
|
||||||
|
</Flex>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</ClassNames>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StyleExplanation() {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
fontSize="xs"
|
||||||
|
fontStyle="italic"
|
||||||
|
textAlign="center"
|
||||||
|
opacity="0.7"
|
||||||
|
marginTop="2"
|
||||||
|
>
|
||||||
|
"Alt Styles" are special NC items that override the pet's usual appearance
|
||||||
|
via the{" "}
|
||||||
|
<Box
|
||||||
|
as="a"
|
||||||
|
href="https://www.neopets.com/stylingchamber/"
|
||||||
|
target="_blank"
|
||||||
|
textDecoration="underline"
|
||||||
|
>
|
||||||
|
Styling Chamber
|
||||||
|
</Box>
|
||||||
|
. The pet's color doesn't have to match.
|
||||||
|
<SupportOnly>
|
||||||
|
<br />
|
||||||
|
WIP: Only Support staff see this tab for now! 💖
|
||||||
|
</SupportOnly>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function EmojiImage({ src, alt, boxSize = 16 }) {
|
function EmojiImage({ src, alt, boxSize = 16 }) {
|
||||||
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
|
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
|
||||||
}
|
}
|
||||||
|
@ -647,7 +818,21 @@ function getIcon(pose) {
|
||||||
} else if (pose === "UNCONVERTED") {
|
} else if (pose === "UNCONVERTED") {
|
||||||
return twemojiSunglasses;
|
return twemojiSunglasses;
|
||||||
} else {
|
} else {
|
||||||
return twemojiQuestion;
|
return twemojiPaintbrush;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLabel(pose) {
|
||||||
|
if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
|
||||||
|
return "Happy";
|
||||||
|
} else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
|
||||||
|
return "Sad";
|
||||||
|
} else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
|
||||||
|
return "Sick";
|
||||||
|
} else if (pose === "UNCONVERTED") {
|
||||||
|
return "Classic UC";
|
||||||
|
} else {
|
||||||
|
return "Default";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 36 36"><path fill="#3B88C3" d="M14.57 27.673c2.814-1.692 6.635-3.807 9.899-7.071 7.03-7.029 12.729-16.97 11.314-18.385C34.369.803 24.428 6.502 17.398 13.531c-3.265 3.265-5.379 7.085-7.071 9.899l4.243 4.243z"/><path fill="#C1694F" d="M.428 34.744s7.071 1.414 12.021-3.536c2.121-2.121 2.121-4.949 2.121-4.949l-2.829-2.829s-3.535.708-4.95 2.122c-1.414 1.414-2.518 4.232-2.888 5.598-.676 2.502-3.475 3.594-3.475 3.594z"/><path fill="#CCD6DD" d="M17.882 25.328l-5.168-5.168c-.391-.391-.958-.326-1.27.145l-1.123 1.705c-.311.471-.271 1.142.087 1.501l4.122 4.123c.358.358 1.03.397 1.501.087l1.705-1.124c.472-.311.536-.878.146-1.269z"/><path fill="#A0041E" d="M11.229 32.26c-1.191.769-1.826.128-1.609-.609.221-.751-.12-1.648-1.237-1.414-1.117.233-1.856-.354-1.503-1.767.348-1.393-1.085-1.863-1.754-.435-.582 1.16-1.017 2.359-1.222 3.115-.677 2.503-3.476 3.595-3.476 3.595s5.988 1.184 10.801-2.485z"/></svg>
|
After Width: | Height: | Size: 950 B |
38
app/javascript/wardrobe-2020/loaders/alt-styles.js
Normal file
38
app/javascript/wardrobe-2020/loaders/alt-styles.js
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export function useAltStylesForSpecies(speciesId, options = {}) {
|
||||||
|
return useQuery({
|
||||||
|
...options,
|
||||||
|
queryKey: ["altStylesForSpecies", String(speciesId)],
|
||||||
|
queryFn: () => loadAltStylesForSpecies(speciesId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAltStylesForSpecies(speciesId) {
|
||||||
|
const res = await fetch(
|
||||||
|
`/species/${encodeURIComponent(speciesId)}/alt-styles.json`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`loading alt styles failed: ${res.status} ${res.statusText}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json().then(normalizeAltStyles);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAltStyles(altStylesData) {
|
||||||
|
return altStylesData.map(normalizeAltStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAltStyle(altStyleData) {
|
||||||
|
return {
|
||||||
|
id: altStyleData.id,
|
||||||
|
speciesId: altStyleData.species_id,
|
||||||
|
colorId: altStyleData.color_id,
|
||||||
|
bodyId: altStyleData.body_id,
|
||||||
|
adjectiveName: altStyleData.adjective_name,
|
||||||
|
thumbnailUrl: altStyleData.thumbnail_url,
|
||||||
|
};
|
||||||
|
}
|
|
@ -11,6 +11,10 @@ class AltStyle < ApplicationRecord
|
||||||
species_human_name: species.human_name)
|
species_human_name: species.human_name)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def adjective_name
|
||||||
|
"Nostalgic #{color.human_name}"
|
||||||
|
end
|
||||||
|
|
||||||
def thumbnail_url
|
def thumbnail_url
|
||||||
# HACK: Just assume this is a Nostalgic Alt Style, and that the thumbnail
|
# HACK: Just assume this is a Nostalgic Alt Style, and that the thumbnail
|
||||||
# is named reliably!
|
# is named reliably!
|
||||||
|
|
Loading…
Reference in a new issue