Build An Interactive NoteBook application Using Django, React and tailwind

Build An Interactive NoteBook application Using Django, React and tailwind
Photo by Clément Hélardot / Unsplash

In this article we'll be building a responsive notebook application with Django, React and Tailwind CSS. The design inspiration is gotten from dribble

Let's have a preview of our notebook application on mobile and desktop

Backend

Create the django project

django-admin startproject notebook
python manage.py startapp core

Create the Note model

# core/models.py

from django.db import models
from django.conf import settings


class Note(models.Model):
    user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
    note = models.JSONField()
    date_created = models.DateTimeField(auto_now_add=True)
    date_updated = models.DateTimeField(auto_now=True)

Create the Note Serializer

# core/serializers.py

from rest_framework.serializers import ModelSerializer
from .models import Note


class NoteSerializer(ModelSerializer):
    class Meta:
        model = Note
        exclude = ['user']

    def create(self, validated_data):
        return Note.objects.create(user=self.context['request'].user, **validated_data)

Create the View Class

# core/views.py

from rest_framework.generics import ListCreateAPIView, GenericAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.mixins import UpdateModelMixin, DestroyModelMixin

from .searializers import NoteSerializer
from .models import Note


class NoteView(ListCreateAPIView, UpdateModelMixin, DestroyModelMixin):
    serializer_class = NoteSerializer
    queryset = Note.objects.all()
    permission_classes = [IsAuthenticated]

    def get_serializer_context(self):
        return {'request': self.request}

    def delete(self, request, *args, **kwargs):
        return self.destroy(request, *args, **kwargs)

    def put(self, request, *args, **kwargs):
        return self.update(request, *args, **kwargs)

Create the URL routes

# notebook/urls.py

from django.contrib import admin
from django.urls import path, include
from core.views import NoteView

urlpatterns = [
    path('admin/', admin.site.urls),
    path('api-auth/', include('rest_framework.urls')),
    path('api/v1/note', NoteView.as_view(), name='note'),
    path('api/v1/note/<int:pk>', NoteView.as_view(), name='note')
]

For the sake of this tutorial, we'll be using session authentication. So to be authenticated, we'll create a super user via the Django CLI and use it to login to the admin. This way we will establish a session with the backend and we can use that to make authenticated API calls to the backend from the frontend

Next, we'll run db migrations

python manage.py migrate

Then we create a superuser and login from the admin URL http://localhost:8000 to have a session established in the browser so our API calls will be authenticated

# Create a superuser
python manage.py createsupseruser

Start the django server

python manage.py runserver

Frontend

Now that we have the notebook backend API application, we can go ahead to build the react application

Create the React app

npx create-react-app notebook

Install the necessary libraries we'll be using in this project

npm install @editorjs/editorjs @editorjs/header react-spinners react-toastify

Install and initialize tailwind CSS

npm install -D tailwindcss

npx tailwindcss init

Copy and paste the following code in the tailwind.config.js file

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,jsx,ts,tsx}'],
  theme: {
    extend: {
      colors: ({ colors }) => ({
        primary: {
          100: '#24A0ED',
          200: '#0086D0',
        },
        secondary: {
          100: '#E2E2D5',
          200: '#888883',
        },
        dark: {
          100: '#343434',
          200: '#28282B',
          300: '#1B1212',
        },
      }),
      fontFamily: {
        body: ['Poppins'],
      },
    },
  },
  plugins: [],
};

Create API Utilities

// src/api/endpoints.js

export const endpoints = {
  ADD_NOTE: '/api/v1/note',
  EDIT_NOTE: (id) => `/api/v1/note/${id}`,
  GET_NOTES: '/api/v1/note',
};

export const getUrl = (path) => {
  return process.env.REACT_APP_API_URL + path;
};

# src/api/notes.js

import { endpoints, getUrl } from './endpoints';

function getCookie(cName) {
  const name = cName + '=';
  const cDecoded = decodeURIComponent(document.cookie); //to be careful
  const cArr = cDecoded.split('; ');
  let res;
  cArr.forEach((val) => {
    if (val.indexOf(name) === 0) res = val.substring(name.length);
  });
  return res;
}

export function addNote(note, successCb, errorCb) {
  return fetch(getUrl(endpoints.ADD_NOTE), {
    method: 'POST',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRFTOKEN': getCookie('csrftoken'),
    },
    body: JSON.stringify(note),
  })
    .then(successCb)
    .catch(errorCb);
}

export function editNote(id, note, successCb, errorCb) {
  return fetch(getUrl(endpoints.EDIT_NOTE(id)), {
    method: 'PUT',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRFTOKEN': getCookie('csrftoken'),
    },
    body: JSON.stringify({
      note,
    }),
  })
    .then(successCb)
    .catch(errorCb);
}

export function deleteNote(id, successCb, errorCb) {
  return fetch(getUrl(endpoints.EDIT_NOTE(id)), {
    method: 'DELETE',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRFTOKEN': getCookie('csrftoken'),
    },
  })
    .then(successCb)
    .catch(errorCb);
}

export function getNotes(successCb, errorCb) {
  fetch(getUrl(endpoints.GET_NOTES), {
    method: 'GET',
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
    },
  })
    .then(successCb)
    .catch(errorCb);
}

Create the Notification Component

We'll be using this component to notify the user of the app state when we make API calls. For example when we get success response after creating, editing or deleting a note.

We'll wrap the ToastContainer from react-toastify and create wrapper functions that we will use to notify success and error messages

// src/components/Notification/Notification.jsx

import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';

const Notification = () => {
  return (
    <ToastContainer
      position="top-right"
      autoClose={5000}
      hideProgressBar={false}
      newestOnTop={false}
      closeOnClick
      rtl={false}
      pauseOnFocusLoss
      draggable
      pauseOnHover
    />
  );
};

export const notifySuccess = (message) => {
  toast.success(message);
};

export const notifyError = (message) => {
  toast.error(message);
};

export default Notification;

Create the Loading Button Component

We'll use this component as our submit button that will show a loader when clicked and remove the loader when the async click handler passed to it as prop completes

// src/components/LoadingButton/LoadingButton.jsx

import React, { useState } from 'react';
import { ClipLoader } from 'react-spinners';

const LoadingButton = ({ onClick, className, children }) => {
  const [loading, setLoading] = useState(false);
  const handleClick = async () => {
    setLoading(true);
    try {
      await onClick();
    } finally {
      setLoading(false);
    }
  };

  return (
    <button onClick={handleClick} className={className} disabled={loading}>
      {loading ? <ClipLoader color="white" size={20} /> : children}
    </button>
  );
};

export default LoadingButton;

Create the Editor Component

Here we'll be using editor.js as the editor library for creating, editing and viewing our notes

// src/components/Editor/Editor.jsx

import EditorJS from '@editorjs/editorjs';
import Header from '@editorjs/header';

import { useEffect, useRef } from 'react';
import LoadingButton from '../LoadingButton/LoadingButton';
import { notifyError } from '../Notification/Notification';

// Let the start of new not be a header with 'Your title'
const DEFAULT_INITIAL_DATA = {
  time: new Date().getTime(),
  blocks: [
    {
      type: 'header',
      data: {
        text: 'Your title',
        level: 1,
      },
    },
  ],
};

const Editor = ({ handleClose, handleSave, note }) => {
  const ejInstance = useRef();
  useEffect(() => {
    if (!ejInstance.current) {
      initEditor();
    }
    return () => {
      ejInstance?.current?.destroy();
      ejInstance.current = null;
    };
  });

  const saveNote = async () => {
    const outputData = await ejInstance.current.save();
    if (outputData.blocks.length) {
      if (outputData.blocks[0].type !== 'header') {
        outputData.blocks.unshift({
          type: 'header',
          data: {
            text: 'No title',
            level: 1,
          },
        });
      }
    } else {
      notifyError('Error!!! Cannot Save, Notes is empty');
    }
    await handleSave(outputData);
  };

  const initEditor = () => {
    const editor = new EditorJS({
      holder: 'editorjs',
      onReady: () => {
        ejInstance.current = editor;
      },
      autofocus: true,
      data: note ? note : DEFAULT_INITIAL_DATA,
      placeholder: 'Tell a Story',
      tools: {
        header: {
          class: Header,
          config: {
            levels: [1, 2, 3, 4],
            defaultLevel: 3,
          },
        },
      },
    });
  };

  return (
    <>
      <div className="fixed top-0 left-0 w-full h-full bg-dark-300 bg-opacity-70 flex items-center justify-center">
        <div className="bg-white w-2/3 h-2/3 relative rounded-sm flex flex-col">
          <svg
            onClick={handleClose}
            dataslot="icon"
            fill="currentColor"
            viewBox="0 0 16 16"
            xmlns="http://www.w3.org/2000/svg"
            aria-hidden="true"
            className="w-8 cursor-pointer self-end mr-4"
          >
            <path d="M5.28 4.22a.75.75 0 0 0-1.06 1.06L6.94 8l-2.72 2.72a.75.75 0 1 0 1.06 1.06L8 9.06l2.72 2.72a.75.75 0 1 0 1.06-1.06L9.06 8l2.72-2.72a.75.75 0 0 0-1.06-1.06L8 6.94 5.28 4.22Z" />
          </svg>
          <div className="overflow-y-scroll w-full h-4/5 mt-2">
            <div id="editorjs" className="mt-10"></div>
          </div>
          <div className="flex w-full justify-end">
            <LoadingButton
              onClick={saveNote}
              className={'btn btn-primary text-white mr-12 mt-8'}
            >
              Submit
            </LoadingButton>
          </div>
        </div>
      </div>
    </>
  );
};

export default Editor;

From the above, we have a saveNote function that checks to ensure your note has a header. If no header is present, It adds a default header with text No Title

Create the NoteCard Component

This component will be a card that will show the note header and text. It'll also present action buttons for editing and deleting the note

This component expects the note as an object with a header and body. So we create a utility that will parse the note object and return an object with a header and a body that can be used by this component

// src/helpers/utils.js

export function getNotesPlainTextFromBlocks(blocks) {
  let header = '';
  let body = '';
  if (blocks?.length > 0) {
    if (blocks[0].type === 'header') {
      header = blocks[0].data.text;
    } else {
      body = blocks[0].data.text;
    }
  }
  if (!body && blocks?.length > 1) {
    body = blocks[1].data.text;
  }
  return {
    header,
    body,
  };
}

Then we create the NoteCard component.

//src/components/NoteCard/NoteCard.jsx

import { useEffect, useState } from 'react';
import { getNotesPlainTextFromBlocks } from '../../helpers/utils';

const NoteCard = ({ handleDelete, handleEdit, note, onClick }) => {
  const [showAction, setShowAction] = useState(false);
  let [formattedNote, setFormattedNote] = useState({
    header: '',
    body: '',
  });
  useEffect(() => {
    setFormattedNote(getNotesPlainTextFromBlocks(note.blocks));
  }, [note]);
  const handleAction = (event, handler) => {
    event.stopPropagation();
    handler();
  };

  const actionPopOverClickHandler = (event) => {
    event.stopPropagation();
    setShowAction(!showAction);
  };

  return (
    <div className="card overflow-visible cursor-pointer" onClick={onClick}>
      <div className="flex justify-between">
        <h3
          className={
            'text-white text-lg ' + (!formattedNote.header ? 'opacity-40' : '')
          }
        >
          {formattedNote?.header ? formattedNote.header : 'No title'}
        </h3>
        <div
          onClick={actionPopOverClickHandler}
          className="text-gray-300 cursor-pointer hover:bg-dark-100 hover:rounded-md h-fit relative"
        >
          <svg
            dataslot="icon"
            fill="currentColor"
            viewBox="0 0 16 16"
            xmlns="http://www.w3.org/2000/svg"
            aria-hidden="true"
            className="w-5"
          >
            <path d="M2 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM6.5 8a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0ZM12.5 6.5a1.5 1.5 0 1 0 0 3 1.5 1.5 0 0 0 0-3Z" />
          </svg>
          {showAction && (
            <div className="absolute top-5 right-0 border border-gray-700 rounded-lg bg-dark-200 p-2 min-w-32">
              <div
                className="text-gray-500 flex item-center z-50"
                onClick={(event) => handleAction(event, handleEdit)}
              >
                <svg
                  dataslot="icon"
                  fill="currentColor"
                  viewBox="0 0 16 16"
                  xmlns="http://www.w3.org/2000/svg"
                  aria-hidden="true"
                  className="w-5"
                >
                  <path
                    clipRule="evenodd"
                    fillRule="evenodd"
                    d="M11.013 2.513a1.75 1.75 0 0 1 2.475 2.474L6.226 12.25a2.751 2.751 0 0 1-.892.596l-2.047.848a.75.75 0 0 1-.98-.98l.848-2.047a2.75 2.75 0 0 1 .596-.892l7.262-7.261Z"
                  />
                </svg>
                <span className="pl-2 text-sm">Edit</span>
              </div>
              <div
                onClick={(event) => handleAction(event, handleDelete)}
                className="text-red-500 flex items-center mt-3"
              >
                <svg
                  dataslot="icon"
                  fill="currentColor"
                  viewBox="0 0 16 16"
                  xmlns="http://www.w3.org/2000/svg"
                  aria-hidden="true"
                  className="w-5"
                >
                  <path
                    clipRule="evenodd"
                    fillRule="evenodd"
                    d="M5 3.25V4H2.75a.75.75 0 0 0 0 1.5h.3l.815 8.15A1.5 1.5 0 0 0 5.357 15h5.285a1.5 1.5 0 0 0 1.493-1.35l.815-8.15h.3a.75.75 0 0 0 0-1.5H11v-.75A2.25 2.25 0 0 0 8.75 1h-1.5A2.25 2.25 0 0 0 5 3.25Zm2.25-.75a.75.75 0 0 0-.75.75V4h3v-.75a.75.75 0 0 0-.75-.75h-1.5ZM6.05 6a.75.75 0 0 1 .787.713l.275 5.5a.75.75 0 0 1-1.498.075l-.275-5.5A.75.75 0 0 1 6.05 6Zm3.9 0a.75.75 0 0 1 .712.787l-.275 5.5a.75.75 0 0 1-1.498-.075l.275-5.5a.75.75 0 0 1 .786-.711Z"
                  />
                </svg>
                <span className="pl-2 text-sm">Delete</span>
              </div>
            </div>
          )}
        </div>
      </div>
      <div>
        <p className="text-gray-300 text-xs mt-3">{formattedNote.body}</p>
      </div>
    </div>
  );
};

export default NoteCard;

Create the Home Page

Here we get the notes from the backend on page load. We also handle the deleting, editing and deleting actions of the note

// src/pages/Home/Home.jsx

import './Home.css';
import NoteCard from '../../components/NoteCard/NoteCard';
import Editor from '../../components/Editor/Editor';
import { useEffect, useState } from 'react';
import { addNote, editNote, deleteNote, getNotes } from '../../api/notes';
import Notification, {
  notifyError,
  notifySuccess,
} from '../../components/Notification/Notification';

const Home = () => {
  const [showEditor, setShowEditor] = useState(false);
  const [selectedNote, setSelectedNote] = useState();
  const [notes, setNotes] = useState([]);

  useEffect(() => {
    getNotes(async (response) => {
      const data = await response.json();
      setNotes(data.results);
    });
  }, []);
  const handleEdit = (note) => {
    setSelectedNote(note);
    setShowEditor(true);
  };

  const handleDelete = (note) => {
    deleteNote(
      note.id,
      (_) => {
        setNotes([...notes.filter((_note) => _note.id !== note.id)]);
        notifySuccess(`Success!!! Deleted Note ${note.id}`);
      },
      (error) => notifyError('Something Went Wrong!!!')
    );
  };

  const handleNewNote = () => {
    setSelectedNote(null);
    setShowEditor(true);
  };

  const handleSave = async (data) => {
    let response;

    if (selectedNote) {
      editNote(
        selectedNote.id,
        data,
        async (response) => {
          const edittedNote = await response.json();
          const updatedNotes = notes.map((note) =>
            note.id === selectedNote.id ? edittedNote : note
          );
          setNotes([...updatedNotes]);
          notifySuccess('Success!!! Edited Note successfully');
        },
        (error) => notifyError('Something Went Wrong')
      ).finally(() => setShowEditor(false));
    } else {
      addNote({ note: data }, async (response) => {
        const newNote = await response.json();
        setNotes([...notes, newNote]);
        notifySuccess('Success!!! Saved Note successfully');
      }).finally(() => setShowEditor(false));
    }
    return response;
  };

  return (
    <>
      <Notification />
      <div className="font-body bg-dark-100 h-full mx-10 mt-10 md:mx-40 md:mt-40 lg:mx-96">
        <div className="flex flex-col md:flex-row md:justify-between text-white">
          <h2 className="text-4xl mb-5 md:mb-0">Notes</h2>
          <div className="flex">
            <button className="btn btn-primary ml-4">
              <svg
                dataslot="icon"
                fill="currentColor"
                viewBox="0 0 16 16"
                xmlns="http://www.w3.org/2000/svg"
                aria-hidden="true"
                className="w-5"
              >
                <path d="M8.75 3.75a.75.75 0 0 0-1.5 0v3.5h-3.5a.75.75 0 0 0 0 1.5h3.5v3.5a.75.75 0 0 0 1.5 0v-3.5h3.5a.75.75 0 0 0 0-1.5h-3.5v-3.5Z" />
              </svg>
              <span onClick={handleNewNote} className="pl-1">
                Add a new note
              </span>
            </button>
          </div>
        </div>
        <main className="mt-8">
          {notes.map((note) => (
            <div className="mb-6" key={note.id}>
              <NoteCard
                note={note.note}
                onClick={() => handleEdit(note)}
                handleEdit={() => handleEdit(note)}
                handleDelete={() => handleDelete(note)}
              ></NoteCard>
            </div>
          ))}
          {showEditor && (
            <Editor
              note={selectedNote?.note}
              handleClose={() => setShowEditor(false)}
              handleSave={async (note) => await handleSave(note)}
            ></Editor>
          )}
        </main>
      </div>
    </>
  );
};

export default Home;

Next, run the application

npm run start

Improvements

  • Add note update date to the NoteCard
  • Paginate Notes display in the Home Page
  • Include a Sort button to sort the notes by date in the Home Page
  • Include Notes Drafts

You can find the complete source codes on github:

GitHub - sonegillis/notebook-backend: A notebook application API
A notebook application API. Contribute to sonegillis/notebook-backend development by creating an account on GitHub.

GitHub - sonegillis/notebook-frontend: Interactive NoteBook application with React and Tailwind CSS
Interactive NoteBook application with React and Tailwind CSS - sonegillis/notebook-frontend