Add image converter to /outfit-urls

Adding the tool that will help people convert one image at a time!
This commit is contained in:
Emi Matchu 2021-05-25 03:35:32 -07:00
parent 42b8c3833c
commit 390c21b53e
2 changed files with 237 additions and 6 deletions

View file

@ -1,8 +1,31 @@
import React from "react"; import React from "react";
import { css } from "@emotion/react"; import { css } from "@emotion/react";
import { VStack } from "@chakra-ui/react"; import {
AspectRatio,
Box,
Button,
Center,
FormControl,
FormErrorMessage,
FormLabel,
Grid,
Input,
InputGroup,
InputRightElement,
Tab,
TabList,
TabPanel,
TabPanels,
Tabs,
useBreakpointValue,
useClipboard,
useColorModeValue,
VStack,
} from "@chakra-ui/react";
import { Heading1, Heading2 } from "./util"; import { Delay, Heading1, Heading2 } from "./util";
import HangerSpinner from "./components/HangerSpinner";
import { gql, useQuery } from "@apollo/client";
function OutfitUrlsPage() { function OutfitUrlsPage() {
return ( return (
@ -27,13 +50,14 @@ function OutfitUrlsPage() {
`} `}
> >
<section> <section>
<p>Hi, friends! Sorry for the trouble 😓</p>
<p> <p>
In short: Old outfit image URLs are expiring, but you can get the Hi, friends! Sorry for the trouble 😓 In short, by switching to the
updated URL right here! new outfit URLs below, we'll decrease our hosting costs by
$20/month! 🙏
</p> </p>
<p>TODO: Outfit image URL converter goes here</p> <OutfitUrlConverter />
</section> </section>
<Box height="2" />
<section> <section>
<Heading2>The history</Heading2> <Heading2>The history</Heading2>
<p> <p>
@ -71,4 +95,188 @@ function OutfitUrlsPage() {
); );
} }
function OutfitUrlConverter() {
return (
<Tabs>
<TabList>
<Tab>Convert an image</Tab>
<Tab>Convert a lookup/petpage</Tab>
</TabList>
<TabPanels>
<TabPanel>
<SingleImageConverter />
</TabPanel>
<TabPanel>
<BulkImageConverter />
</TabPanel>
</TabPanels>
</Tabs>
);
}
function SingleImageConverter() {
const [inputUrl, setInputUrl] = React.useState("");
let parsedUrl;
let parseError;
try {
parsedUrl = parseS3OutfitUrl(inputUrl);
} catch (e) {
parseError = e;
}
const outfitId = parsedUrl?.outfitId;
const size = parsedUrl?.size;
const { loading, error: gqlError, data } = useQuery(
gql`
query OutfitUrlsSingleImageConverter(
$outfitId: ID!
$size: OutfitImageSize
) {
outfit(id: $outfitId) {
id
imageUrl(size: $size)
}
}
`,
{
variables: { outfitId, size: `SIZE_${size}` },
skip: outfitId == null || size == null,
onError: (e) => console.error(e),
}
);
const imageUrl = data?.outfit?.imageUrl;
const previewBackground = useColorModeValue("gray.200", "whiteAlpha.300");
const spinnerSize = useBreakpointValue({ base: "md", md: "sm" });
const { onCopy, hasCopied } = useClipboard(imageUrl);
return (
<Grid
templateAreas={{
base: `
"input"
"output"
"preview"
`,
md: `
"preview input"
"preview output"
`,
}}
templateColumns={{ base: "auto", md: "auto 1fr" }}
columnGap="4"
rowGap="2"
justifyItems="center"
>
<FormControl gridArea="input" isInvalid={Boolean(parseError) || gqlError}>
<FormLabel fontWeight="bold">Enter an outfit image URL</FormLabel>
<Input
placeholder="https://openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png"
value={inputUrl}
onChange={(e) => setInputUrl(e.target.value)}
/>
<FormErrorMessage>
{parseError?.message ||
(gqlError && `Error loading outfit data. Try again?`) ||
null}
</FormErrorMessage>
</FormControl>
<FormControl gridArea="output">
<FormLabel fontSize="sm">
Then, use this new URL in your layouts instead:
</FormLabel>
<InputGroup size="sm">
<Input
placeholder="https://impress-outfit-images.openneo.net/outfits/123456789/v/1020304050/600.png"
isReadOnly
value={imageUrl}
/>
{imageUrl && (
<InputRightElement width="4rem" paddingRight="1">
<Button
height="calc(100% - .5rem)"
size="xs"
minWidth="100%"
onClick={onCopy}
>
{hasCopied ? "Copied!" : "Copy"}
</Button>
</InputRightElement>
)}
</InputGroup>
</FormControl>
<AspectRatio
gridArea="preview"
width={{ base: "100%", md: "150px" }}
maxWidth={{ base: "300px", md: "150px" }}
ratio={1}
background={previewBackground}
borderRadius="md"
boxShadow="sm"
marginTop={{ base: "4", md: "0" }}
overflow="hidden"
>
<Center>
{loading ? (
<Delay ms={1000}>
<HangerSpinner size={spinnerSize} />
</Delay>
) : imageUrl ? (
<Box
as="img"
src={imageUrl}
alt="Outfit image preview"
width={size}
height={size}
maxWidth="100%"
maxHeight="100%"
sx={{
// Don't let alt text flash in while loading
"&:-moz-loading": {
visibility: "hidden",
},
}}
/>
) : null}
</Center>
</AspectRatio>
</Grid>
);
}
function BulkImageConverter() {
return <Box>TODO: Bulk image converter</Box>;
}
const S3_OUTFIT_URL_PATTERN = /^https?:\/\/openneo-uploads\.s3\.amazonaws\.com\/outfits\/([0-9]{3})\/([0-9]{3})\/([0-9]{3})\/(preview|medium_preview|small_preview)\.png$/;
const S3_FILENAMES_TO_SIZES = {
preview: 600,
medium_preview: 300,
small_preview: 150,
};
function parseS3OutfitUrl(url) {
if (!url) {
return null;
}
const match = S3_OUTFIT_URL_PATTERN.exec(url);
if (!match) {
throw new Error(
`This URL didn't match the expected pattern. Make sure it's formatted like this: https://openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png`
);
}
// Convert ID to number to remove leading 0s, then convert back to string for
// consistency with how we handle outfit IDs in this app.
const outfitId = String(Number(`${match[1]}${match[2]}${match[3]}`));
const size = S3_FILENAMES_TO_SIZES[match[4]];
return { outfitId, size };
}
export default OutfitUrlsPage; export default OutfitUrlsPage;

View file

@ -2,6 +2,12 @@ import { gql } from "apollo-server";
import { getPoseFromPetState } from "../util"; import { getPoseFromPetState } from "../util";
const typeDefs = gql` const typeDefs = gql`
enum OutfitImageSize {
SIZE_600
SIZE_300
SIZE_150
}
type Outfit { type Outfit {
id: ID! id: ID!
name: String name: String
@ -19,6 +25,8 @@ const typeDefs = gql`
# This is a convenience field: you could query this from the combination of # This is a convenience field: you could query this from the combination of
# petAppearance and wornItems, but this gets you it in one shot! # petAppearance and wornItems, but this gets you it in one shot!
itemAppearances: [ItemAppearance!]! itemAppearances: [ItemAppearance!]!
imageUrl(size: OutfitImageSize): String!
} }
extend type Query { extend type Query {
@ -103,6 +111,21 @@ const resolvers = {
const outfit = await outfitLoader.load(id); const outfit = await outfitLoader.load(id);
return outfit.updatedAt.toISOString(); return outfit.updatedAt.toISOString();
}, },
imageUrl: async ({ id }, { size = "SIZE_600" }, { outfitLoader }) => {
const outfit = await outfitLoader.load(id);
const updatedAtTimestamp = Math.floor(
new Date(outfit.updatedAt).getTime() / 1000
);
const sizeNum = size.split("_")[1];
return (
`https://impress-outfit-images.openneo.net/outfits` +
`/${encodeURIComponent(outfit.id)}` +
`/v/${encodeURIComponent(updatedAtTimestamp)}` +
`/${sizeNum}.png`
);
},
}, },
Query: { Query: {