Compare commits

..

6 commits

Author SHA1 Message Date
207c65f209 Link to the Styling Chamber 2024-01-29 05:36:48 -08:00
433a14104f Hide the WIP Styles UI behind support staff flag
That way, I can stop being on a branch and be working on main, and
deploy stuff to preview live, without having to share it with everyone
just yet! (This was the motivation for finally adding Support tooling
to main DTI lol!)
2024-01-29 04:36:48 -08:00
514c99fb42 Add WIP styles tab to the pose picker
It shows the styles! You can select between them, but it currently does
nothing, womp womp!
2024-01-29 04:26:40 -08:00
e2ab8bbc9c Start adding Styles UI to pose picker
Add the tab UI, though the styles aren't in it yet; and add text label
to help make the whole UI more discoverable.
2024-01-29 04:26:40 -08:00
57beca1b3c Refactor PosePicker a bit
Just extracting some things to make the main function body leaner so
it's easier to add the alt styles stuff.
2024-01-29 04:26:40 -08:00
32f5d6d4a0 Show the PosePicker, even if there are no standard poses labeled
This is because I'm gonna put alt styles in here too, and I figure it's
reasonable to just explain what's going on.
2024-01-29 04:26:40 -08:00
5 changed files with 303 additions and 71 deletions

View file

@ -10,7 +10,11 @@ class AltStylesController < ApplicationController
respond_to do |format|
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

View file

@ -11,10 +11,16 @@ import {
PopoverContent,
PopoverTrigger,
Portal,
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
VisuallyHidden,
useColorModeValue,
useTheme,
useToast,
useToken,
} from "@chakra-ui/react";
import { loadable } from "../util";
@ -22,6 +28,7 @@ import { petAppearanceFragment } from "../components/useOutfitAppearance";
import getVisibleLayers from "../components/getVisibleLayers";
import { OutfitLayers } from "../components/OutfitPreview";
import SupportOnly from "./support/SupportOnly";
import { useAltStylesForSpecies } from "../loaders/alt-styles";
import useSupport from "./support/useSupport";
import { useLocalStorage } from "../util";
@ -63,9 +70,9 @@ function PosePicker({
onUnlockFocus,
...props
}) {
const theme = useTheme();
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(
"DTIPosePickerIsInSupportMode",
false,
@ -73,6 +80,11 @@ function PosePicker({
const { isSupportUser } = useSupport();
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
// affect the content size.
React.useLayoutEffect(() => {
@ -139,15 +151,9 @@ function PosePicker({
return null;
}
// If there's only one pose anyway, don't bother showing a picker!
// (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,
const numStandardPoses = Object.values(poseInfos).filter(
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
).length;
if (numAvailablePoses <= 1 && !isSupportUser) {
return null;
}
const onChange = (e) => {
dispatchToOutfit({ type: "setPose", pose: e.target.value });
@ -164,44 +170,15 @@ function PosePicker({
lazyBehavior="keepMounted"
>
{({ isOpen }) => (
<ClassNames>
{({ css, cx }) => (
<>
<PopoverTrigger>
<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>
<Portal>
<PopoverContent>
<Box p="4" position="relative">
<>
<PopoverTrigger>
<PosePickerButton pose={pose} isOpen={isOpen} {...props} />
</PopoverTrigger>
<Portal>
<PopoverContent>
<Tabs size="sm" variant="soft-rounded">
<TabPanels position="relative">
<TabPanel>
{isInSupportMode ? (
<PosePickerSupport
speciesId={speciesId}
@ -218,20 +195,8 @@ function PosePicker({
onChange={onChange}
initialFocusRef={initialFocusRef}
/>
{numAvailablePoses <= 1 && (
<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>
{numStandardPoses == 0 && (
<PosePickerEmptyExplanation />
)}
</>
)}
@ -243,18 +208,76 @@ function PosePicker({
/>
</Box>
</SupportOnly>
</Box>
<PopoverArrow />
</PopoverContent>
</Portal>
</>
)}
</ClassNames>
</TabPanel>
<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 />
</PopoverContent>
</Portal>
</>
)}
</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 }) {
return (
<Box display="flex" flexDirection="column" alignItems="center">
@ -371,6 +394,8 @@ const GENDER_PRESENTATION_STRINGS = {
SICK_FEM: "Feminine",
};
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
function PoseOption({
poseInfo,
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 }) {
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
}
@ -647,7 +818,21 @@ function getIcon(pose) {
} else if (pose === "UNCONVERTED") {
return twemojiSunglasses;
} 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";
}
}

View file

@ -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

View 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,
};
}

View file

@ -11,6 +11,10 @@ class AltStyle < ApplicationRecord
species_human_name: species.human_name)
end
def adjective_name
"Nostalgic #{color.human_name}"
end
def thumbnail_url
# HACK: Just assume this is a Nostalgic Alt Style, and that the thumbnail
# is named reliably!