2021-05-24 21:07:30 -07:00
import React from "react" ;
import { css } from "@emotion/react" ;
2021-05-25 03:35:32 -07:00
import {
AspectRatio ,
Box ,
Button ,
Center ,
2021-05-25 04:04:35 -07:00
Flex ,
2021-05-25 03:35:32 -07:00
FormControl ,
FormErrorMessage ,
FormLabel ,
Grid ,
Input ,
InputGroup ,
InputRightElement ,
2021-05-25 05:28:02 -07:00
Popover ,
PopoverArrow ,
PopoverBody ,
PopoverContent ,
PopoverTrigger ,
Spinner ,
2021-05-25 03:35:32 -07:00
Tab ,
TabList ,
TabPanel ,
TabPanels ,
Tabs ,
2021-05-25 04:04:35 -07:00
Textarea ,
2021-05-25 03:35:32 -07:00
useBreakpointValue ,
useClipboard ,
useColorModeValue ,
VStack ,
} from "@chakra-ui/react" ;
2021-05-24 21:07:30 -07:00
2021-05-25 05:28:02 -07:00
import { Delay , ErrorMessage , Heading1 , Heading2 } from "./util" ;
2021-05-25 03:35:32 -07:00
import HangerSpinner from "./components/HangerSpinner" ;
import { gql , useQuery } from "@apollo/client" ;
2021-05-25 05:28:02 -07:00
import { CheckIcon , WarningIcon } from "@chakra-ui/icons" ;
2021-05-24 21:07:30 -07:00
function OutfitUrlsPage ( ) {
return (
< >
< Heading1 marginBottom = "4" > Changing our outfit URLs < / H e a d i n g 1 >
< VStack
spacing = "4"
alignItems = "flex-start"
css = { css `
max - width : 800 px ;
p {
margin - bottom : 1 em ;
}
a {
text - decoration : underline ;
}
h2 ,
h3 {
margin - bottom : 0.5 em ;
}
` }
>
< section >
< p >
2021-05-25 03:35:32 -07:00
Hi , friends ! Sorry for the trouble 😓 In short , by switching to the
new outfit URLs below , we ' ll decrease our hosting costs by
$20 / month ! 🙏
2021-05-24 21:07:30 -07:00
< / p >
2021-05-25 03:35:32 -07:00
< OutfitUrlConverter / >
2021-05-24 21:07:30 -07:00
< / s e c t i o n >
2021-05-25 03:35:32 -07:00
< Box height = "2" / >
2021-05-24 21:07:30 -07:00
< section >
< Heading2 > The history < / H e a d i n g 2 >
< p >
When we started hosting outfit images back in 2012 , we didn ' t know a
lot about web infrastructure , and we weren ' t thinking a lot about
permanent URLs 😅 We uploaded images directly to { " " }
< a href = "https://aws.amazon.com/s3/" > Amazon S3 < / a > , a n d g a v e y o u
Amazon ' s URL for them , at < code > amazonaws . com < / c o d e > .
< / p >
< p >
Since then , we ' ve grown a lot , and our Amazon costs have increased a
lot too ! These days , it costs about $30 / month to serve outfit images
from S3 — and $20 of that is just to < em > store < / e m > o u r m i l l i o n s o f
outfit images , including the ones nobody visits 😅
< / p >
< p >
So , we ' ve moved our apps to a new , more cost - efficient way to share
outfit images ! But , until we delete the old images from Amazon S3
altogether , we ' re still paying $20 / month < em > just < / e m > t o s u p p o r t
the old < code > amazonaws . com < / c o d e > U R L s .
< / p >
< p >
I looked hard for a way to redirect the old Amazon URLs to our new
service , but it seems to not be possible , and it seems like
$20 / month could be better spent another way 😖
< / p >
< p >
I ' m truly sorry for breaking some of the lookups and petpages out
there , and I hope this tool helps folks migrate to the new version
quickly and easily ! 🙏
< / p >
< / s e c t i o n >
< / V S t a c k >
< / >
) ;
}
2021-05-25 03:35:32 -07:00
function OutfitUrlConverter ( ) {
return (
< Tabs >
< TabList >
< Tab > Convert an image < / T a b >
< Tab > Convert a lookup / petpage < / T a b >
< / T a b L i s t >
< TabPanels >
< TabPanel >
< SingleImageConverter / >
< / T a b P a n e l >
< TabPanel >
< BulkImageConverter / >
< / T a b P a n e l >
< / T a b P a n e l s >
< / T a b s >
) ;
}
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 ) ,
}
) ;
2021-05-25 05:30:57 -07:00
const imageUrl = data ? . outfit ? . imageUrl || "" ;
2021-05-25 03:35:32 -07:00
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 < / F o r m L a b e l >
< 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 }
< / F o r m E r r o r M e s s a g e >
< / F o r m C o n t r o l >
< FormControl gridArea = "output" >
< FormLabel fontSize = "sm" >
Then , use this new URL in your layouts instead :
< / F o r m L a b e l >
< 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" }
< / B u t t o n >
< / I n p u t R i g h t E l e m e n t >
) }
< / I n p u t G r o u p >
< / F o r m C o n t r o l >
< 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 } / >
< / D e l a y >
) : 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 }
< / C e n t e r >
< / A s p e c t R a t i o >
< / G r i d >
) ;
}
function BulkImageConverter ( ) {
2021-05-25 04:04:35 -07:00
const [ inputHtml , setInputHtml ] = React . useState ( "" ) ;
2021-05-25 05:28:02 -07:00
const parsedUrls = parseManyS3OutfitUrlsFromHtml ( inputHtml ) ;
const outfitIds = parsedUrls . map ( ( pu ) => pu . outfitId ) ;
// TODO: Do this query in batches for large pages?
const { loading , error , data } = useQuery (
gql `
query OutfitUrlsBulkImageConverter ( $outfitIds : [ ID ! ] ! ) {
outfits ( ids : $outfitIds ) {
id
# Rather than send requests for different sizes separately , I ' m just
# requesting them all and having the client choose , to simplify the
# query . gzip should compress it very efficiently !
imageUrl600 : imageUrl ( size : SIZE _600 )
imageUrl300 : imageUrl ( size : SIZE _300 )
imageUrl150 : imageUrl ( size : SIZE _150 )
}
}
` ,
{
variables : { outfitIds } ,
skip : outfitIds . length === 0 ,
onError : ( e ) => console . error ( e ) ,
}
) ;
const { outputHtml , numReplacements , replacementErrors } = React . useMemo (
( ) =>
inputHtml && data ? . outfits
? replaceS3OutfitUrlsInHtml ( inputHtml , data . outfits )
: { outputHtml : "" , numReplacements : 0 , replacementErrors : [ ] } ,
[ inputHtml , data ? . outfits ]
) ;
React . useEffect ( ( ) => {
for ( const replacementError of replacementErrors ) {
console . error ( "Error replacing outfit URLs in HTML:" , replacementError ) ;
}
} , [ replacementErrors ] ) ;
2021-05-25 04:04:35 -07:00
const { onCopy , hasCopied } = useClipboard ( outputHtml ) ;
return (
< Grid
templateAreas = { `
"input"
"output"
` }
rowGap = "4"
>
< FormControl gridArea = "input" >
< FormLabel fontWeight = "bold" > Enter your lookup / petpage HTML < / F o r m L a b e l >
< Textarea
fontFamily = "monospace"
fontSize = "xs"
2021-05-25 04:17:09 -07:00
placeholder = { ` <table> <!-- Example input, paste your HTML here! -->
< tr >
< td > < img src = "https://openneo-uploads.s3.amazonaws.com/outfits/123/456/700/preview.png" > < / t d >
< td > < img src = "https://openneo-uploads.s3.amazonaws.com/outfits/123/456/701/preview.png" > < / t d >
< td > < img src = "https://openneo-uploads.s3.amazonaws.com/outfits/123/456/702/preview.png" > < / t d >
... ` }
2021-05-25 04:04:35 -07:00
value = { inputHtml }
onChange = { ( e ) => setInputHtml ( e . target . value ) }
/ >
< / F o r m C o n t r o l >
< FormControl gridArea = "output" >
2021-05-25 05:28:02 -07:00
< Grid
templateAreas = { {
base : `
"header"
"textarea"
"status"
` ,
md : `
"header status"
"textarea textarea"
` ,
} }
alignItems = "center"
rowGap = "2"
>
< Flex gridArea = "header" alignItems = "center" >
< FormLabel fontSize = "sm" margin = "0" >
Then , use this new HTML for your page instead :
< / F o r m L a b e l >
< Box width = "2" flex = { { base : "1 0 auto" , sm : "0 0 auto" } } / >
{ outputHtml && (
< Button size = "xs" onClick = { onCopy } >
< Grid templateAreas = "the-area" >
< Box gridArea = "the-area" >
{ hasCopied ? "Copied!" : "Copy" }
< / B o x >
{ / * T h i s i n v i s i b l e " C o p i e d ! " e n f o r c e s a m i n s i z e f o r t h e b u t t o n
* content , so that the button never changes size . * / }
< Box gridArea = "the-area" aria - hidden visibility = "hidden" >
Copied !
< / B o x >
< / G r i d >
< / B u t t o n >
) }
< / F l e x >
< Textarea
gridArea = "textarea"
isReadOnly
fontFamily = "monospace"
fontSize = "xs"
placeholder = { ` <table> <!-- Example output, your new HTML will appear here! -->
2021-05-25 04:17:09 -07:00
< tr >
< td > < img src = "https://impress-outfit-images.openneo.net/outfits/123456700/v/1234/600.png" > < / t d >
< td > < img src = "https://impress-outfit-images.openneo.net/outfits/123456701/v/5678/600.png" > < / t d >
< td > < img src = "https://impress-outfit-images.openneo.net/outfits/123456702/v/9012/600.png" > < / t d >
... ` }
2021-05-25 05:28:02 -07:00
value = { outputHtml }
/ >
< Box gridArea = "status" textAlign = "right" justifySelf = "end" >
{ loading ? (
< Flex alignItems = "center" opacity = "0.8" >
< Spinner size = "xs" marginRight = "1.5" / >
< Box fontSize = "sm" >
Found { outfitIds . length } outfit images , converting …
< / B o x >
< / F l e x >
) : error ? (
< ErrorMessage fontSize = "sm" >
Error loading outfits . Try again ?
< / E r r o r M e s s a g e >
) : inputHtml && ! outputHtml && outfitIds . length === 0 ? (
< Popover trigger = "hover" >
< PopoverTrigger >
< Flex
as = { ErrorMessage }
alignItems = "center"
fontSize = "sm"
tabIndex = "0"
borderRadius = "md"
paddingX = "2"
marginRight = "-2"
_focus = { { outline : "0" , boxShadow : "outline" } }
>
< WarningIcon marginRight = "1.5" / >
< Box > No outfit image URLs found < / B o x >
< / F l e x >
< / P o p o v e r T r i g g e r >
< PopoverContent >
< PopoverArrow / >
< PopoverBody >
< Box fontSize = "xs" textAlign = "center" >
< b > Make sure they ' re in the right format : < / b >
< br / >
https : //openneo-uploads.s3.amazonaws.com/outfits/123/456/789/preview.png
< br / >
< br / >
< b >
If they 're already in the new format, then you' re
already done !
< / b > { " " }
The new format is :
< br / >
https : //impress-outfit-images.openneo.net/outfits/123456789/v/1020304050/600.png
< / B o x >
< / P o p o v e r B o d y >
< / P o p o v e r C o n t e n t >
< / P o p o v e r >
) : outputHtml && replacementErrors . length > 0 ? (
< Popover trigger = "hover" >
< PopoverTrigger >
< Flex
as = {
replacementErrors . length > numReplacements
? ErrorMessage
: undefined
}
alignItems = "center"
fontSize = "sm"
tabIndex = "0"
borderRadius = "md"
paddingX = "2"
marginRight = "-2"
_focus = { { outline : "0" , boxShadow : "outline" } }
>
< WarningIcon marginRight = "1.5" / >
< Box >
Converted { numReplacements } outfit images , with { " " }
{ replacementErrors . length } errors
< / B o x >
< / F l e x >
< / P o p o v e r T r i g g e r >
< PopoverContent width = "50ch" >
< PopoverArrow / >
< PopoverBody >
< Box fontSize = "xs" textAlign = "center" >
Errors are unusual at this point in the process . Sorry
about this !
< br / >
Email me at { " " }
< a href = "mailto:matchu@openneo.net" >
matchu @ openneo . net
< / a > { " " }
and I ' ll try to help !
< br / >
We ' ve left the { replacementErrors . length } erroring images
unchanged for now .
< / B o x >
< / P o p o v e r B o d y >
< / P o p o v e r C o n t e n t >
< / P o p o v e r >
) : outputHtml ? (
< Flex alignItems = "center" fontSize = "sm" opacity = "0.8" >
< CheckIcon marginRight = "1.5" / >
< Box > Converted { numReplacements } outfit images ! < / B o x >
< / F l e x >
) : null }
< / B o x >
< / G r i d >
2021-05-25 04:04:35 -07:00
< / F o r m C o n t r o l >
< / G r i d >
) ;
2021-05-25 03:35:32 -07:00
}
2021-05-25 05:28:02 -07:00
// These patterns have the same content, but different boundary conditions and
// flags. EXACT is for checking a single string for an exact match, GLOBAL is
// for finding multiple matches in large text.
const S3 _OUTFIT _URL _EXACT _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 _OUTFIT _URL _GLOBAL _PATTERN = /https?:\/\/openneo-uploads\.s3\.amazonaws\.com\/outfits\/([0-9]{3})\/([0-9]{3})\/([0-9]{3})\/(preview|medium_preview|small_preview)\.png/g ;
2021-05-25 03:35:32 -07:00
const S3 _FILENAMES _TO _SIZES = {
preview : 600 ,
medium _preview : 300 ,
small _preview : 150 ,
} ;
function parseS3OutfitUrl ( url ) {
if ( ! url ) {
return null ;
}
2021-05-25 05:28:02 -07:00
const match = S3 _OUTFIT _URL _EXACT _PATTERN . exec ( url ) ;
2021-05-25 03:35:32 -07:00
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 } ;
}
2021-05-25 05:28:02 -07:00
function parseManyS3OutfitUrlsFromHtml ( html ) {
const matches = html . match ( S3 _OUTFIT _URL _GLOBAL _PATTERN ) || [ ] ;
return matches . map ( parseS3OutfitUrl ) ;
}
function replaceS3OutfitUrlsInHtml ( html , outfits ) {
const outfitsById = new Map ( ) ;
for ( const outfit of outfits ) {
if ( ! outfit ) {
continue ;
}
outfitsById . set ( outfit . id , outfit ) ;
}
// Use the `replace` method to scan the HTML for matches, which will run this
// function on each match to decide what to replace it with. We'll count
// successes and log failures along the way!
let numReplacements = 0 ;
const replacementErrors = [ ] ;
const outputHtml = html . replace ( S3 _OUTFIT _URL _GLOBAL _PATTERN , ( match ) => {
let newUrl ;
try {
const { outfitId , size } = parseS3OutfitUrl ( match ) ;
const outfit = outfitsById . get ( outfitId ) ;
if ( ! outfit ) {
throw new Error ( ` could not find outfit ${ outfitId } ` ) ;
}
const sizeKey = ` imageUrl ` + size ;
if ( ! ( sizeKey in outfit ) ) {
throw new Error (
` outfit ${ outfitId } has no image key ${ sizeKey } : ${ JSON . stringify (
outfit
) } `
) ;
}
newUrl = outfit [ sizeKey ] ;
} catch ( e ) {
e . message += ` ( ${ match } ) ` ; // help us understand which URL failed!
replacementErrors . push ( e ) ;
return match ;
}
numReplacements ++ ;
return newUrl ;
} ) ;
return { outputHtml , numReplacements , replacementErrors } ;
}
2021-05-24 21:07:30 -07:00
export default OutfitUrlsPage ;