In part 1 and part 2, we got the basic project workflow set up. Now it’s time to add some basic models and views.

Tweak tailwind.config.js

Before we get into the meat of this, it’s necessary to alter the tailwind configuration so that the tailwind CLI will look for templates in subdirectories. This is only necessary because of how I choose to structure my templates; if you use a flatter structure, you can skip this step. Modify the content item in the tailwind confiuration theme/static_src/tailwind.config.js to search one level deeper for templates:

module.exports = {
    content: [
 ...,
        /*
         * Templates in other django apps (BASE_DIR/<any_app_name>/templates).
         * Adjust the following line to match your project structure.
         */
        '../../**/templates/**/*.html',
        '../../**/templates/**/**/*.html',
...,
    ],   
}

Add some initial models

This application is intended to allow someone to track their reading activity. To start off, we need a way to record books and authors. For this, I’ll add a couple of very bare-bones models to reading_journal/models.py:

from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=255)
    other_names = models.JSONField(blank=True)
    open_library_id = models.CharField(max_length=64, blank=True)

    def __str__(self):
        return self.name


class Book(models.Model):
    title = models.CharField(max_length=255)
    authors = models.ManyToManyField(Author)
    open_library_id = models.CharField(max_length=64, blank=True)

    def __str__(self):
        return self.title

If these look odd, it’s because I ultimately want this data to come from the Open Library but I’m not going to integrate that yet. These are placeholders that will let me get my workflow and display right.

Since one book can have many authors and one author can write many books, a little bit of customization makes these books much easier to enter in the Django admin UI. I add the following to reading_journal/admin.py:

from django.contrib import admin

from .models import Book, Author

admin.site.register(Author)


class AuthorInline(admin.TabularInline):
    model = Book.authors.through
    extra = 1


@admin.register(Book)
class BookAdmin(admin.ModelAdmin):
    inlines = (AuthorInline,)
    exclude = ("authors",)

Now it’s easy to add a few books using the admin interface so that I can work on some of the views I’ll need.

General Setup

I’ll include code inline here as it seems useful for explaining things. The full source for the project is available to fill in any gaps.

Adding Alpine

Creating the first view usually involves adding a navigation bar, and that’s approximately where I decide it’s nice to have alpine.js available, because that’s the lowest-friction way to make links in a nav bar into a responsive menu on small devices. It’s useful for many other little things as well, and everything after this will assume it’s around.

I prefer to include the javascript with my static files just to avoid adding a CDN to my runtime dependencies. Since I’ve already got my theme app for tailwind, that’s a natural place to add it.

mkdir -p theme/static/js
cd theme/static/js
curl -L -o alpine.min.js "https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"

With that file in place, I can include it in reading_journal/templates/reading_journal/base.html by adding this script tag to the head:

        <script src="{% static 'js/alpine.min.js' %}" defer></script>

Index

For this application, it makes sense to:

  1. Always require login. Too much of it is tied to the current user.
  2. Show a dashboard after login.

So I like to start by creating a place for the dashboard to land. I start by creating a set of URL patterns for the reading_journal.app in a file called urls.py (that I already added to the global set of patterns in part 2):

from django.urls import path, include

from . import views

urlpatterns = [
    path("", views.index, name="index"),
]

Then adding an index() function to views.py:

def get_nav_links(current_page: str):
    nav_links = {
        "index":
            {
                "active": False,
                "text": "Home",
                "href": reverse("index")
            },
        "admin":
            {
                "active": False,
                "text": "Admin",
                "href": reverse("admin:index"),
            },
        "library":
            {
                "active": False,
                "text": "Library",
                "href": "#",
            },
        "reading_list":
            {
                "active": False,
                "text": "Reading List",
                "href": "#",
            },
    }
    if current_page in nav_links:
        nav_links[current_page]["active"] = True
    return list(nav_links.values())


@login_required
def index(request: HttpRequest):
    nav_links = get_nav_links("index")
    ctx = {"nav_links": nav_links}
    return render(request, "reading_journal/index.html", ctx)

I update my `reading_journal/base.html template to include a navigation bar and a content block:

{% load static tailwind_tags %}
<!DOCTYPE html>
<html lang="en">
    <head>
        <title>{% block title %}Reading Journal{% endblock title %}</title>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <meta http-equiv="X-UA-Compatible" content="ie=edge">
        {% tailwind_css %}
        <script src="{% static 'js/alpine.min.js' %}" defer></script>
    </head>

    <body>
        <div class="container mx-auto">
            {% include 'reading_journal/partials/nav.html' %}
            {% block content %}
                <div class="bg-blue-100">
                    <section class="flex items-center justify-center h-screen">
                        <h1 class="text-5xl">Django + Tailwind = ❤️</h1>
                    </section>
                </div>
            {% endblock content %}
        </div>
    </body>
</html>

And I set up a generic navigation bar that takes a list of menu items from the rendering context to display: partials/nav.html

{% load static %}
{% comment %}
Based on https://flowbite.com/docs/components/navbar/
Modified to use alpine instead of pulling in flowbite js
{% endcomment %}
<nav class="bg-white border-sky-500 border-b-2 px-2 sm:px-4 py-2.5 dark:bg-gray-800">
    <div x-data="{showMobileMenu: false}" class="container flex flex-wrap justify-between items-center mx-auto">
        <a href="{% url "index" %}" class="flex items-center">
            <img src="{% static "images/book.svg" %}" class="mr-3 h-6 sm:h-9" alt="Logo">
            <span class="self-center text-xl font-semibold whitespace-nowrap text-sky-500 dark:text-white">Reading Journal</span>
        </a>
        <button x-on:click="showMobileMenu = !showMobileMenu" type="button" class="inline-flex items-center p-2 ml-3 text-sm text-gray-500 rounded-lg md:hidden hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-gray-200 dark:text-gray-400 dark:hover:bg-gray-700 dark:focus:ring-gray-600" aria-controls="mobile-menu" aria-expanded="false">
            <span class="sr-only">Open main menu</span>
            <svg class="w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M3 5a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zM3 15a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clip-rule="evenodd"></path></svg>
            <svg class="hidden w-6 h-6" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg>
        </button>
        <div class="w-full md:block md:w-auto" :class="showMobileMenu ? 'block':'hidden'" id="mobile-menu">
            <ul class="flex flex-col mt-4 md:flex-row md:space-x-8 md:mt-0 md:text-sm md:font-medium dark:text-white">
                {% for link in nav_links %}
                    <li>
                        {% if link.active %}
                            <a href="{{ link.href }}" class="block py-2 pr-4 pl-3 rounded md:p-0 text-white bg-blue-700 md:bg-transparent md:text-sky-500" aria-current="page">{{ link.text }}</a>
                        {% else %}
                            <a href="{{ link.href }}" class="block py-2 pr-4 pl-3 text-gray-700 border-b border-gray-100 hover:bg-gray-50 md:hover:bg-transparent md:border-0 md:hover:text-sky-700 md:p-0 dark:text-gray-400 md:dark:hover:text-white dark:hover:bg-gray-700 dark:hover:text-white md:dark:hover:bg-transparent dark:border-gray-700">{{ link.text }}</a>
                        {% endif %}
                    </li>
                {% endfor %}
            </ul>
        </div>
    </div>
</nav>

That gives me the creature comfort of having a quick link back to the admin UI as I look at different views.

I create a new template to render my index page:

{% extends 'reading_journal/base.html' %}
{% block content %}
    <!-- Dashboard -->
    <!-- X Most recently added books -->
    <div class="border-gray-700 border-1 rounded-xl p-4 m-4">
        <div class="flex space-x-4">
            <div class="flex items-end"><h2 class="text-xl text-gray-900 font-bold">Available Books</h2></div>
            <div class="inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-sky-700 bg-sky-100 hover:bg-sky-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-500"><a href="#" class="text-l text-sky-500 font-medium">Manage Library</a></div>
        </div>
        <div class="bg-white shadow overflow-hidden sm:rounded-md p-2 m-2">
            <ul class="divide-y divide-gray 200" role="list">
                {% for book in books %}
                    <li>
                        <a href="{% url "book" book.id %}" class="block hover:bg-sky-100">
                            <p class="font-medium text-gray-700">{{ book.title }}</p>
                        </a>
                    </li>
                {% endfor %}
            </ul>
        </div>
    </div>    <!-- stats -->
    <!-- Last log entries -->
{% endblock content %}

A new placeholder template to render book details reading_journal/book_details.html:

{% extends 'reading_journal/base.html' %}
{% block content %}
    <div class="p-4 m-4">
    <h2 class="text-4xl text-sky-500">{{ book.title }}</h2>
    </div>
{% endblock content %}

Add that to the URL and views, along with a placeholder function to get the list of books for a user:

reading_journal/urls.py

urlpatterns = [
    path("", views.index, name="index"),
    path("book/<int:book_id>", views.book_details, name="book"),
]

reading_journal/views.py

def get_books_for_current_user(request: HttpRequest):
    books = Book.objects.all()
    return books

@login_required
def index(request: HttpRequest):
    nav_links = get_nav_links("index")
    books = get_books_for_current_user(request)
    ctx = {
        "nav_links": nav_links,
        "books": books,
    }
    return render(request, "reading_journal/index.html", ctx)

@login_required
def book_details(request: HttpRequest, book_id: int):
    book = get_object_or_404(Book, pk=book_id)
    nav_links = get_nav_links("")
    ctx = {
        "book": book,
        "nav_links": nav_links,
    }
    return render(request, "reading_journal/book_details.html", ctx)

and I kick the tires to make sure I see a list of books on the dashboard and can click through to the details.

In the next part, I’ll add edit forms, then move them over to use htmx where appropriate.


I’m trying on Kev Quirk’s “100 Days To Offload” idea. You can see details and join yourself by visiting 100daystooffload.com.

This is day 4.