codinggames

My blog now has Stories, and I'm not sure why

The saga of "Well, It's here now"

Written in September 4, 2023 - 🕒 9 min. read

In a world dominated by social media platforms, where ‘Stories’ have become an omnipresent feature, I found myself pondering - does my blog really need Stories? The answer, of course, is a resounding ‘fuck no’. But here we are, my blog now has Stories, and I’m not entirely sure why.

Stories

The genesis of an unnecessary idea

It all started when I was using my mobile internet provider app, and to my surprise, they had added Stories to their app. This was simply too much for me to handle. A mix of joy, wonder, and confusion overtook me. I mean, why would an internet provider app need Stories? It was at this moment that I decided, if they can do it, why can’t I? And so, the journey began.

Naturally, as a developer with a penchant for the absurd, I fired up my code editor and started coding, and my goal was to Stories to my blog. The reasoning behind it? That’s still a work in progress.

The technical journey

The process of adding Stories to my blog was surprisingly straightforward, thanks to the power of communism open-source. Open-source tools and libraries are a blessing for developers. They save time, effort, and often lead to better solutions than one could come up with on their own. After a bit of research, I stumbled upon a fantastic package that seemed to be the answer to all my prayers - react-insta-stories. This package is designed to create Instagram-like Stories on React, which was exactly what I was looking for.

The react-insta-stories package is incredibly versatile and customizable. It allows you to create stories with images, videos, and even custom components. You can customize the duration of each story, add action buttons, and much more. It was almost too good to be true. With this package, I was able to add Stories to my blog in no time, and with minimal effort.

Deciding what to show in the stories

Initially, I thought about creating custom videos using default backgrounds from stock video websites. I even went as far as creating a script using FFmpeg and Node.js to automate the process. However, after some consideration, I decided to drop that idea. For the first iteration, I decided to create a story for each of the last five blog posts on my website.

For the stories content, I decided to keep it simple. I would display a background image from a stock photo related to the category of the blog post, overlay the title of the blog post, and include a link to access it. This way, the stories would not only serve as a visual teaser but also as a functional gateway to the full content of the blog post.

To make it more organized, I grouped these stories by the category of the blog post. For example, if out of the past five blog posts, three were in the “coding” category and two in the “games” category, I would display two different story previews - those little image circles that you click to see the stories. Then, “inside” each of these grouped circles, you would click and see those stories.

This approach seemed more manageable and, let’s be honest, required less effort on my part. It also provided a way for visitors to quickly catch up on my latest posts in a more interactive and engaging manner. Not that I’m particularly concerned about visitor retention or engagement. I’m just adding this feature because, well, why not?

Let’s get coding

Once the decision was made (with a shrug and a “why not”), it was time to dive into the deep end of the coding pool. Here’s a brief overview of the steps I took, or rather, the rabbit hole I went down:

  1. Creating the StoriesPreview component: This component is responsible for displaying the preview of the stories on the main page of the 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. Creating the StoryImage component: This component is responsible for displaying the image of the 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. Creating the StoryModal component: This component is responsible for displaying the story in a modal when the user clicks on the story image.
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. Creating the useLocalStorage hook: This custom hook is used to manage the state of the seen stories in the local storage.
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. Creating the useStories hook: This custom hook is used to manage the state of the 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,
  ]);
};

Conclusion

The process of adding Stories to my blog was surprisingly straightforward, thanks to the open-source package react-insta-stories.

Was I adding Stories to my blog to enhance the user experience? lol no. Do I regret it? Well… no really. In a world where we are constantly bombarded with new features, updates, and innovations, it’s easy to get caught up in the whirlwind of “keeping up with the JS” 🥁.

So, here we are. My blog now has Stories, a completely unnecessary addition, but one that I can’t help but find amusing. I hope you enjoy this completely unnecessary, yet oddly satisfying addition to my blog.

See you in the next post!

Tags:


Post a comment

Comments

No comments yet.