Skip to content

Entry read status indicator

Let's add some custom data to our entries, akin to what we'd do if we were storing them in a database. Let's add a read status.

We'll also add an endpoint for making an entry as read, which then redirects us to the actual entry post.

__init__.py
import feedparser
import jinja_partials
from flask import Flask, abort, redirect, render_template, request, url_for

feeds = {
    "https://blog.teclado.com/rss/": {"title": "The Teclado Blog", "href": "https://blog.teclado.com/rss/", "show_images": True, "entries": {}},
    "https://www.joshwcomeau.com/rss.xml": {"title": "Josh W. Comeau", "href": "https://www.joshwcomeau.com/rss.xml", "show_images": False, "entries": {}},
}

def create_app():
    app = Flask(__name__)
    jinja_partials.register_extensions(app)


    @app.route("/feed/")
    @app.route("/feed/<path:feed_url>")
    def render_feed(feed_url: str = None):
        for url, feed_ in feeds.items():
            parsed_feed = feedparser.parse(url)
            for entry in parsed_feed.entries:
                if entry.link not in feed_["entries"]:
                    feed_["entries"][entry.link] = {**entry, "read": False}

        if feed_url is None:
            feed = list(feeds.values())[0]
        else:
            feed = feeds[feed_url]
        return render_template("feed.html", feed=feed, feeds=feeds)


    @app.route("/entries/<path:feed_url>")
    def render_feed_entries(feed_url: str):
        try:
            feed = feeds[feed_url]
        except KeyError:
            abort(400)
        page = int(request.args.get("page", 0))

        # Below we're paginating the entries even though
        # in this application it's not necessary, just to
        # show what it might look like if it were.
        return render_template(
            "partials/entry_page.html",
            entries=list(feed["entries"].values())[page*5:page*5+5],
            href=feed["href"],
            page=page,
            max_page=len(feed["entries"])//5
        )

    @app.route("/add_feed", methods=["POST"])
    def add_feed():
        feed = request.form.get("url")
        title = request.form.get("title")
        show_images = request.form.get("showImages")
        feeds[feed] = {"title": title, "href": feed, "show_images": show_images, "entries": {}}
        return redirect(url_for("render_feed", feed=feed))

    @app.route("/feed/<path:feed_url>/entry/<path:entry_url>")
    def read_entry(feed_url: str, entry_url: str):
        feed = feeds[feed_url]
        entry = feed["entries"][entry_url]
        entry["read"] = True
        return redirect(entry_url)

    return app

Then we'll need to add some stuff to our entry partial as well, to show when an entry has been read or not.

We'll start with an icon that denotes the read status. It's just a green circle:

<svg viewBox="0 0 16 16"
xmlns="http://www.w3.org/2000/svg"
class="fill-emerald-500 h-3 w-3 inline-block mb-1">
    <circle cx="8" cy="8" r="8" />
</svg>

Now we need to make it so the SVG only shows up if the entry is not read. We can do that with Jinja. Pass the entry's read status to the partial and then just do this:

{% if not read %}
<!-- draw the svg -->
{% endif %}

But this will have one problem: when we click on an entry and read it, the app will show it as "unread" until we refresh the page. We need a solution that can update the app immediately, as well as update the backend state for when we refresh the page.

We can do this with HTMX! But it's one of these things that will be much simpler with JavaScript. So, we go back to AlpineJS.

We can add state to the entry div, which is populated from the Jinja value:

x-data="{ read: {{ read | lower }} }"

The {{ read | lower }} portion is a Jinja interpolation. The read value is a Python True or False. We turn it to lowercase so that the final result reads:

x-data="{ read: true }

This is valid JavaScript, so with this Alpine stores the boolean value read in the element.

Then we can change the link at the bottom to set the Alpine read value to true when we click the link. Here's the complete code, with the elements modified highlighted:

templates/partials/entry.html
{#
A rendered summarized entry
- title
- published
- summary
- media_content (post image)
- author
- link
- read
- feed_url
#}
<article class="grid grid-cols-[300px_600px] mb-4">
    {% if media_content %}
        <img class="aspect-video rounded-md shadow-md"
             src="{{ media_content[0]['url'] }}" />
    {% endif %}
    <div class="ml-8 pt-4 {{ 'col-start-2' if media_content else 'col-start-1 col-span-2' }}"
         x-data="{ read: {{ read | lower }} }">
        <h2 class="font-bold text-2xl">
            <svg x-show="!read"
                 viewBox="0 0 16 16"
                 xmlns="http://www.w3.org/2000/svg"
                 class="fill-emerald-500 h-3 w-3 inline-block mb-1">
                <circle cx="8" cy="8" r="8" />
            </svg>
            {{ title }}
        </h2>
        <p>
            Published by <span class="font-medium">{{ author }}</span> on <span class="font-medium">{{ published
        }}</span>
    </p>
    <p class="leading-6 mb-2">{{ summary | safe }}</p>
    <a x-on:click="read = true"
       href="{{ url_for('read_entry', feed_url=feed_url, entry_url=link) }}"
       class="underline"
       target="_blank">Read article</a>
</div>
</article>