Login

How Saves Work

In the update to my Ghost theme, Smart, I added a saves feature. This article goes through how that feature works and shares some of the code!

by Ryan Feigenbaum

How Saves Work

Share this post

How Saves Work

How Saves Work

In the update to my Ghost theme, Smart, I implemented a Saves feature. In this post, I'll discuss what the feature is and some of the code behind how it works.

0:00
/

What's Saves?

Saves allows readers to save/favorite/bookmark articles. In the header of this article, you can see the Save text followed by a small heart icon. Clicking this button saves this article to your saves collection, which is accessible via the overflow (...) menu in the navbar.

Your saved articles sync across the site and represented by a filled heart icon. These saves persist in local storage, which means that they will be saved across browser visits (you can close and reopen your browser and they'll still be there). But only if you're using the same browser. The plus side is that you're not sending anything to the cloud, but the downside is there's no cross-device/browser support. (More on localStorage later.)

In addition to saving the article, Saves also keeps the date you favorited the article and your progress through it. This means that when you return to the article, you will be automatically scrolled to where you left off!

How It's Made

Saves is a feature that prima facie seems simple enough. A user clicks a heart and the app notes the change and updates the UI. But once you get into actually building it, the complexity becomes more apparent.

Perhaps it's naivety, but this feature turned out to be harder to code than I first thought.

I decided to take an object-oriented (OOP) approach, where I created a Saves class with a set of methods. Here's an outline of the class:

class Saves {
    constructor(e) {
    // Grab the id and title from the click event.
    // Grab the saved items list from local storage
    }
    
    generateItem() {
        // Construct the saved item object
    }
    
    save() {
        // Save an item
    }
    
    remove() {
        // Remove an item
    }
    
    scrollStatus() {
        // Get scroll status (how far the user has progressed down the page)
    }
    
    identifySaves() {
        // Find which items on the page are already saved and update the UI
    }
    
    isCurrentPageSaved() {
        // Determine whether the current page is saved
    }
    
    step() {
        // Continuously update scroll position when user is on a saved page
    
    updateScrollPosition() {
        // Update the scroll position
    }
    
    renderSavesMenu() {
        // Generate the Saves menu HTML
    }
    
    populateSavesMenu() {
        // Add that HTML to the page
    }
    
    init() {
        // Everything to do on initial page load
    }
    
}

You can see that a whole lot of methods are required to implement this feature. Because it's the bread and butter of this thing, let's go through the save method in detail.

Save method

Here's the code for the save method, which is called whenever a user saves an article.

save() {
    localStorage.setItem(
      'saves',
      JSON.stringify([this.generateItem(), ...this.items()]),
    );

    this.populateSavesMenu();
    this.updateScrollPosition();

    const els = document.querySelectorAll(`button[data-id=${this.id}]`);
    els.forEach((el) => {
      el.classList.add('sm-love-toggle');
    });
  }

The first part of the method saves the item to localStorage, which is an API for accessing a storage object.

Window.localStorage - Web APIs | MDN
The localStorage read-only property of the window interface allows you to access a Storage object for the Document’s origin; the stored data is saved across browser sessions.

This data is saved to the user's device in the browser and, importantly, is persisted across sessions. That means that you can close the browser or restart your device, but that data will still be available. However, as the name implies, localStorage is local—it only exists in your browser and doesn't sync across devices or even other browsers. (For that, you'd need a server and a whole lot more code.) You can see what's included in localStorage by opening your dev tools and going to the Storage (Firefox) or Application (Chrome) tab.

Example of local storage in dev tools

I write the saves to localStorage using its setItem method:

localStorage.setItem(
      'saves',
      JSON.stringify([this.generateItem(), ...this.items()]),
    );

This data gets the saves key. Then, I spread the existing saved items into array an with the new item. Because localStorage only accepts strings, I stringify the array before saving it. Here's a screenshot of how these saves look in dev tools:

The saved item includes an id, title, timestamp of when it's saved, scroll position, and a computed value of the progress through the article.

After committing the saved item to storage, I update the saves menu and scroll position. This makes the UI reflect the true state of things and tracks the position of the user in the article if they're on the page they saved.

Save menu on website

The last bit of the method is changing the icon from a heart outline to a filled-in heart, indicating that the item has been saved.

const els = document.querySelectorAll(`button[data-id=${this.id}]`);
els.forEach((el) => {
  el.classList.add('sm-love-toggle');
});

The first line of code selects all buttons on the page that have the id of the saved article. For this code to work, I added the id in my template:

<button class="sm-circle-icon-button sm-love-button" data-id="{{slug}}" data-title="{{title}}" aria-label="Save {{title}} to favorites">
    <span class="sm-heart-outline-icon">
        <svg><use href="#sm-heart-outline-icon"></use></svg>
    </span>
    <span class="sm-heart-fill-icon">
        <svg><use href="#sm-heart-fill-icon"></use></svg>
    </span>
</button>

The important bit is data-id="{{slug}}", which includes the article's slug (guaranteed to be unique) as a data attribute on the button. Having this identifier allows me to select it when needed, as in the save method. Because multiple buttons live on the page (in the desktop and mobile saves menu and in the post meta area), I opted for the data attribute (data-id) rather than just the id because it's illegal to have elements with the same id.

The final step is to add the CSS class sm-love-toggle to the button, which, through the power of CSS, hides the outlined version of the icon and shows the filled-in one.

.sm-love-button .sm-heart-fill-icon {
  display: none;
}

.sm-love-button.sm-love-toggle .sm-heart-outline-icon {
  display: none;
}

.sm-love-button.sm-love-toggle .sm-heart-fill-icon {
  display: inline-block;
}

If you're interested in how the rest of it works, the code is all open source. There are most certainly better ways to do things—please let me know or make a PR.

GitHub - royalfig/smart: A theme for the open source Ghost CMS.
A theme for the open source Ghost CMS. Contribute to royalfig/smart development by creating an account on GitHub.

Custom events?

I'm interested in whether custom events would simplify the saves feature. You're likely familiar with standard browser events like click and change, but it's also possible to define your own.

CustomEvent() - Web APIs | MDN
The CustomEvent() constructor creates a new CustomEvent object.

With this API, I could define custom events like onSave and onRemove. I suspect this could refine the code, but I'm not sure yet. If anyone has experience with customEvents, let me know!


My Smart Ghost theme is free and open source. Check it out:

Smart | An elegant Ghost theme
An elegant, free Ghost theme for publishers, creators, coders, and writers. Including features like a built-in contact form and syntax highlighting, automatic color theming, dark mode, and more. Created by Ryan Feigenbaum.