coding

Meu blog tem Stories agora, mas não me pergunte por quê

Quem disse que tédio não é produtivo?

Escrito em 4 de setembro de 2023 - 🕒 9 min. de leitura

Num mundo dominado por plataformas de mídia social, onde Stories se tornaram um recurso omnipresente, eu me peguei pensando - o meu blog realmente precisa de Stories? A resposta, claro, é um sonoro “não, né?“. Mas aqui estamos, o meu blog agora tem Stories, e eu não tenho totalmente certeza do porquê.

Stories

O nascimento de uma ideia desnecessária

Tudo começou quando eu estava usando o aplicativo do meu provedor de internet para celular e, para minha surpresa, eles haviam adicionado Stories no aplicativo deles. Isso foi simplesmente demais para mim. Uma mistura de alegria, admiração e confusão me dominou. Quer dizer, por que um aplicativo de provedor de internet precisaria de Stories? Foi nesse momento que decidi, se eles podem fazer, por que eu não posso? E assim, a jornada começou.

Naturalmente, como um desenvolvedor com uma inclinação para o absurdo, abri o meu editor de código e comecei a programar, e o meu objetivo era adicionar Stories ao meu blog. A razão por trás disso? Me pergunte semana que vem.

A jornada técnica

O processo de adicionar Stories ao meu blog foi surpreendentemente simples, graças ao poder do comunismo código aberto. Ferramentas e bibliotecas de código aberto são uma bênção para os desenvolvedores. Elas economizam tempo, esforço e muitas vezes levam a soluções melhores do que as que poderíamos criar sozinhos. Após um pouco de pesquisa, eu me deparei com um pacote fantástico que parecia ser a resposta para todas as minhas orações - react-insta-stories. Este pacote é projetado para criar Stories estilo Instagram no React, que era exatamente o que eu estava procurando.

O pacote react-insta-stories é incrivelmente versátil e personalizável. Ele permite que você crie stories com imagens, vídeos e até componentes personalizados. Você pode personalizar a duração de cada Story, adicionar botões de ação e muito mais. Era quase bom demais para ser verdade. Com este pacote, consegui adicionar Stories ao meu blog em pouco tempo e com o mínimo de esforço.

Decidindo o que mostrar nos Stories

Inicialmente, pensei em criar vídeos personalizados usando fundos padrão de sites de “stock videos”. Cheguei até a criar um script usando FFmpeg e Node.js para automatizar o processo. No entanto, após alguma consideração, decidi abandonar essa ideia. Para a primeira iteração, decidi criar um Story para cada um dos últimos cinco posts do meu blog.

Para o conteúdo dos Stories, decidi mantê-lo simples. Eu decidi usar uma “stock photo” relacionada à categoria do post do blog, sobreporia o título do post do blog e incluiria um link para acessá-lo. Dessa forma, os Stories não serviriam apenas como um teaser visual, mas também como um portal funcional para o conteúdo completo do post do blog.

Para torná-lo mais organizado, agrupei esses Stories pela categoria do post do blog. Por exemplo, se dos últimos cinco posts do blog, três estavam na categoria “programação” e dois na categoria “jogos”, eu exibiria duas prévias de Stories diferentes - aqueles pequenos círculos de imagem que você clica para ver os Stories. Então, “dentro” de cada um desses círculos agrupados, você clicaria e veria esses Stories.

Essa abordagem parecia mais gerenciável e, vamos ser honestos, exigia menos esforço da minha parte. Também proporcionava uma maneira dos visitantes se atualizarem rapidamente sobre os meus últimos posts de uma maneira mais interativa e envolvente. Não que eu esteja particularmente preocupado com a retenção ou engajamento dos visitantes. Estou apenas adicionando esse recurso porque, bem, por que não?

Partiu programar

Após a decisão (meio que no “ah, por que não, né”), era hora de mergulhar de cabeça no mundo da programação. Aqui vai um resumo dos passos que segui, ou melhor, do buraco de coelho que me enfiei:

  1. Criando o componente StoriesPreview: Este componente é responsável por exibir a pré-visualização dos Stories na página principal do blog.
const useStyles = makeStyles((theme) => ({
  circleImage: {
    width: 80,
    height: 80,
    borderRadius: '50%',
    cursor: 'pointer',
    objectFit: 'cover',
    marginLeft: '5px',
    position: 'relative',
  },
  storyImage: {
    width: '100%',
    height: '100%',
    objectFit: 'cover',
    objectPosition: 'center',
  },
  storiesWrapper: {
    margin: '15px 0',
  },
  previewWrapper: {
    margin: '15px 0',
  },
}));

const StoriesPreview = () => {
  const data = useStaticQuery(
    graphql`
      query {
        allMarkdownRemark(
          filter: { frontmatter: { show: { eq: true } } }
          sort: { fields: [frontmatter___date], order: DESC }
          limit: 10
        ) {
          edges {
            node {
              excerpt(pruneLength: 60)
              fields {
                path
                locale
                postHashId
              }
              frontmatter {
                title
                categories
              }
            }
          }
        }
      }
    `
  );

  const intl = useIntl();
  const posts = data.allMarkdownRemark.edges.filter(({ node }) => node.fields.locale === intl.locale);
  const classes = useStyles();
  const [showModal, setShowModal] = useState(false);
  const [currentStoriesIndex, setCurrentStoriesIndex] = useState(0);
  const [seenStories, setSeenStories] = useLocalStorage('seenStories', []);
  const [tempSeenStories, setTempSeenStories] = useState([]);
  const stories = useStories(posts, seenStories);

  const onStoryEnd = useCallback(
    (currentStoriesIndex, currentStoryIndex) => {
      const newTempSeenStories = new Set([
        ...tempSeenStories,
        stories[currentStoriesIndex][currentStoryIndex].postHashId,
      ]);
      setTempSeenStories([...newTempSeenStories]);
    },
    [tempSeenStories, stories]
  );

  const handleImageClick = useCallback((index) => {
    setCurrentStoriesIndex(index);
    setShowModal(true);
  }, []);

  const handleCloseModal = useCallback(() => {
    setShowModal(false);
    const newSeenStories = new Set([...seenStories, ...tempSeenStories]);
    setSeenStories([...newSeenStories]);
    setTempSeenStories([]);
  }, [seenStories, setSeenStories, tempSeenStories]);

  const handleNextStories = useCallback(() => {
    if (currentStoriesIndex < stories.length - 1) {
      setCurrentStoriesIndex(currentStoriesIndex + 1);
    } else {
      handleCloseModal();
    }
  }, [currentStoriesIndex, handleCloseModal, stories.length]);

  const handlePreviousStories = useCallback(() => {
    if (currentStoriesIndex > 0) {
      setCurrentStoriesIndex(currentStoriesIndex - 1);
    } else {
      handleCloseModal();
    }
  }, [currentStoriesIndex, handleCloseModal]);

  const handleOnAllStoriesEnd = useCallback(() => {
    handleNextStories();
  }, [handleNextStories]);

  return (
    <div className={classes.storiesWrapper}>
      <div className={classes.previewWrapper}>
        {stories.map((storiesArray, index) => {
          return (
            <StoryImage
              key={index}
              category={storiesArray[0].category}
              previewImage={storiesArray[0].previewImage}
              unseen={isClient() && storiesArray.some((story) => !story.viewed)}
              onClick={() => handleImageClick(index)}
            />
          );
        })}
      </div>
      <Divider />
      {showModal && (
        <StoryModal
          stories={stories[currentStoriesIndex]}
          onClose={handleCloseModal}
          onPrevious={handlePreviousStories}
          onNext={handleNextStories}
          showPrevious={currentStoriesIndex > 0}
          showNext={currentStoriesIndex < stories.length - 1}
          currentStoriesIndex={currentStoriesIndex}
          onAllStoriesEnd={handleOnAllStoriesEnd}
          onStoryEnd={(currentStoryIndex) => onStoryEnd(currentStoriesIndex, currentStoryIndex)}
          onStoryStart={(currentStoryIndex) => onStoryEnd(currentStoriesIndex, currentStoryIndex)}
        />
      )}
    </div>
  );
};
  1. Criando o componente StoryImage: Este componente é responsável por exibir a imagem do Story.
const useStyles = makeStyles((theme) => ({
  circleImage: ({ unseen }) => ({
    width: 80,
    height: 80,
    borderRadius: '50%',
    cursor: 'pointer',
    objectFit: 'cover',
    marginLeft: '5px',
    position: 'relative',
    ...(unseen
      ? {
        padding: 4,
        background: theme.palette.secondary.main,
      }
      : {}),
  }),
}));

const StoryImage = ({ category, previewImage, unseen, onClick }) => {
  const classes = useStyles({ unseen });

  return (
    // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
    <img alt={category} src={previewImage} className={classes.circleImage} onClick={onClick} />
  );
};
  1. Criando o componente StoryModal: Este componente é responsável por exibir o story num modal quando o leitor clica na imagem dos Stories.
const useStyles = makeStyles((theme) => ({
  modal: {
    position: 'fixed',
    top: 0,
    left: 0,
    width: '100%',
    height: '100%',
    backgroundColor: 'rgba(0, 0, 0, 0.5)',
    backdropFilter: 'blur(5px)',
    display: 'flex',
    justifyContent: 'center',
    alignItems: 'center',
    zIndex: 9999,
  },
  modalContent: {
    backgroundColor: 'transparent',
    padding: theme.spacing(2),
    display: 'flex',
    flexDirection: 'column',
    alignItems: 'flex-end',
  },
  closeButton: {
    marginBottom: theme.spacing(1),
  },
  arrowButton: {
    position: 'absolute',
    top: '50%',
    transform: 'translateY(-50%)',
    fontSize: '2rem',
    cursor: 'pointer',
  },
  arrowLeft: {
    left: theme.spacing(1),
  },
  arrowRight: {
    right: theme.spacing(1),
  },
}));

const StoryModal = ({
  stories,
  onClose,
  onPrevious,
  onNext,
  showPrevious,
  showNext,
  currentStoriesIndex,
  onStoryEnd,
  onStoryStart,
  onAllStoriesEnd,
}) => {
  const classes = useStyles();
  const intl = useIntl();
  const isMobileDevice = useMemo(isMobile, []);

  const handleKeyDown = useCallback(
    (event) => {
      if (event.key === 'ArrowRight' && showNext) {
        onNext();
      } else if (event.key === 'ArrowLeft' && showPrevious) {
        onPrevious();
      }
    },
    [onNext, onPrevious, showNext, showPrevious]
  );

  useEffect(() => {
    window.addEventListener('keydown', handleKeyDown);
    return () => {
      window.removeEventListener('keydown', handleKeyDown);
    };
  }, [handleKeyDown]);

  return (
    <div className={classes.modal} onClick={onClose}>
      <div className={classes.modalContent} onClick={(e) => e.stopPropagation()}>
        <Button className={classes.closeButton} onClick={onClose}>
          {intl.formatMessage({ id: 'close' })}
        </Button>
        {!isMobileDevice && showPrevious && (
          <ArrowBackIcon
            className={classNames(classes.arrowButton, classes.arrowLeft)}
            onClick={onPrevious}
          />
        )}
        {!isMobileDevice && showNext && (
          <ArrowForwardIcon
            className={classNames(classes.arrowButton, classes.arrowRight)}
            onClick={onNext}
          />
        )}
        <Stories
          stories={stories}
          key={currentStoriesIndex}
          currentIndex={0}
          onStoryEnd={onStoryEnd}
          onStoryStart={onStoryStart}
          onAllStoriesEnd={onAllStoriesEnd}
        />
      </div>
    </div>
  );
};
  1. Criando o hook useLocalStorage: Este hook personalizado é usado para gerenciar o estado dos Stories visualizadas no armazenamento local.
function useLocalStorage(key, initialValue) {
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.log(error);
      return initialValue;
    }
  });

  const setValue = (value) => {
    try {
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.log(error);
    }
  };

  return [storedValue, setValue];
}
  1. Criando o hook useStories: Este hook personalizado é usado para gerenciar o estado dos Stories.
const useStyles = makeStyles((theme) => ({
  seeMore: {
    color: theme.palette.text.primary,
    textTransform: 'uppercase',
    textDecoration: 'inherit',
    background: 'rgb(0 0 0 / 40%)',
    padding: '10px 0',
    textAlign: 'center',
    position: 'absolute',
    margin: 'auto',
    bottom: '0px',
    zIndex: '9999',
    width: '100%',
    height: 'auto',
  },
  contentWrapper: {
    display: 'flex',
    alignItems: 'center',
    justifyContent: 'center',
    background: '#333',
    width: '100%',
    color: 'white',
    height: '100%',
  },
  contentTextWrapper: {
    '& h1, p, a': {
      background: 'rgb(0 0 0 / 40%)',
      padding: '10px 25px',
      margin: 0,
    },
  },
}));

const useStories = (posts, seenStories) => {
  const intl = useIntl();
  const classes = useStyles();
  const imagesData = useStaticQuery(graphql`
    query {
      allFile(filter: { relativeDirectory: { eq: "categories" }, extension: { eq: "jpg" } }) {
        edges {
          node {
            base
            childImageSharp {
              gatsbyImageData
            }
          }
        }
      }
    }
  `);

  return useMemo(() => {
    const storiesMap = new Map();
    posts.forEach(({ node }) => {
      const { frontmatter, fields, excerpt } = node;
      const { title, categories } = frontmatter;
      const category = categories[0];
      const { path, postHashId } = fields;
      const imageNode = imagesData.allFile.edges.find((edge) => edge.node.base.includes(category));
      const gatsbyImageData = getImage(imageNode.node.childImageSharp);
      const categoryImage = gatsbyImageData.images.fallback.src;

      const story = {
        category,
        previewImage: categoryImage,
        postHashId,
        duration: 5000,
        viewed: seenStories.includes(postHashId),
        content: ({ action, isPaused }) => (
          <div
            className={classes.contentWrapper}
            style={{
              background: `linear-gradient(rgba(51, 51, 51, 0.5), rgba(51, 51, 51, 0.5)), url(${categoryImage}) no-repeat center center`,
            }}
          >
            <div className={classes.contentTextWrapper}>
              <h1>{title}</h1>
              <p>{excerpt}</p>
            </div>
            <Link to={path} className={classes.seeMore}>
              {intl.formatMessage({ id: 'go_to_post' })}
            </Link>
          </div>
        ),
      };

      const categoryStories = storiesMap.get(category) || [];
      categoryStories.push(story);
      storiesMap.set(category, categoryStories);
    });

    return Array.from(storiesMap.values()).sort((a, b) => {
      const aHasFalse = a.some((item) => !item.viewed);
      const bHasFalse = b.some((item) => !item.viewed);

      if (aHasFalse && bHasFalse) {
        return 0;
      } else if (aHasFalse) {
        return -1;
      } else {
        return 1;
      }
    });
  }, [
    posts,
    imagesData.allFile.edges,
    seenStories,
    classes.contentWrapper,
    classes.contentTextWrapper,
    classes.seeMore,
    intl,
  ]);
};

Conclusão

O processo de adicionar Stories ao meu blog foi surpreendentemente simples, graças ao pacote de código aberto react-insta-stories.

Eu estava adicionando Stories ao meu blog para melhorar a experiência do leitor? hahaha não. Eu me arrependo? Bem… não também. Num mundo onde somos constantemente bombardeados com novos recursos, atualizações e inovações, que até parece um episódio de Black Mirror.

Então, aqui estamos. Meu blog agora tem Stories, uma adição completamente desnecessária, mas que não posso deixar de achar divertida. Espero que você goste desta adição completamente desnecessária, mas estranhamente satisfatória ao meu blog.

Até a próxima, galera!

Tags:


Publicar um comentário

Comentários

Nenhum comentário.