Compare commits

..

No commits in common. "207c65f209aa107e786f210804a038846212f51b" and "4fff8d88f27405d3e6f04bacaa9b76474df8c659" have entirely different histories.

5 changed files with 71 additions and 303 deletions

View file

@ -10,11 +10,7 @@ class AltStylesController < ApplicationController
respond_to do |format| respond_to do |format|
format.html { render } format.html { render }
format.json { format.json { render json: @alt_styles }
render json: @alt_styles.as_json(
methods: [:adjective_name, :thumbnail_url],
)
}
end end
end end
end end

View file

@ -11,16 +11,10 @@ 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";
@ -28,7 +22,6 @@ 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";
@ -70,9 +63,9 @@ function PosePicker({
onUnlockFocus, onUnlockFocus,
...props ...props
}) { }) {
const theme = useTheme();
const initialFocusRef = React.useRef(); const initialFocusRef = React.useRef();
const posesQuery = usePoses(speciesId, colorId, pose); const { loading, error, poseInfos } = usePoses(speciesId, colorId, pose);
const altStylesQuery = useAltStylesForSpecies(speciesId);
const [isInSupportMode, setIsInSupportMode] = useLocalStorage( const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
"DTIPosePickerIsInSupportMode", "DTIPosePickerIsInSupportMode",
false, false,
@ -80,11 +73,6 @@ 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(() => {
@ -151,9 +139,15 @@ function PosePicker({
return null; return null;
} }
const numStandardPoses = Object.values(poseInfos).filter( // If there's only one pose anyway, don't bother showing a picker!
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose), // (Unless we're Support, in which case we want the ability to pop it open to
// 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 });
@ -170,15 +164,44 @@ function PosePicker({
lazyBehavior="keepMounted" lazyBehavior="keepMounted"
> >
{({ isOpen }) => ( {({ isOpen }) => (
<ClassNames>
{({ css, cx }) => (
<> <>
<PopoverTrigger> <PopoverTrigger>
<PosePickerButton pose={pose} isOpen={isOpen} {...props} /> <Button
variant="unstyled"
boxShadow="md"
d="flex"
alignItems="center"
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> </PopoverTrigger>
<Portal> <Portal>
<PopoverContent> <PopoverContent>
<Tabs size="sm" variant="soft-rounded"> <Box p="4" position="relative">
<TabPanels position="relative">
<TabPanel>
{isInSupportMode ? ( {isInSupportMode ? (
<PosePickerSupport <PosePickerSupport
speciesId={speciesId} speciesId={speciesId}
@ -195,8 +218,20 @@ function PosePicker({
onChange={onChange} onChange={onChange}
initialFocusRef={initialFocusRef} initialFocusRef={initialFocusRef}
/> />
{numStandardPoses == 0 && ( {numAvailablePoses <= 1 && (
<PosePickerEmptyExplanation /> <SupportOnly>
<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>
)} )}
</> </>
)} )}
@ -208,76 +243,18 @@ function PosePicker({
/> />
</Box> </Box>
</SupportOnly> </SupportOnly>
</TabPanel> </Box>
<TabPanel>
<StyleSelect altStyles={altStyles} />
<StyleExplanation />
</TabPanel>
</TabPanels>
<SupportOnly>
<TabList paddingX="2" paddingY="1">
<Tab width="50%">Expressions</Tab>
<Tab width="50%">Styles</Tab>
</TabList>
</SupportOnly>
</Tabs>
<PopoverArrow /> <PopoverArrow />
</PopoverContent> </PopoverContent>
</Portal> </Portal>
</> </>
)} )}
</ClassNames>
)}
</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">
@ -394,8 +371,6 @@ 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,
@ -537,152 +512,6 @@ 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} />;
} }
@ -818,21 +647,7 @@ function getIcon(pose) {
} else if (pose === "UNCONVERTED") { } else if (pose === "UNCONVERTED") {
return twemojiSunglasses; return twemojiSunglasses;
} else { } else {
return twemojiPaintbrush; return twemojiQuestion;
}
}
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";
} }
} }

View file

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

Before

Width:  |  Height:  |  Size: 950 B

View file

@ -1,38 +0,0 @@
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,
};
}

View file

@ -11,10 +11,6 @@ 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!