Доброго времени суток, друзья!

Сегодня мы с вами, как следует из названия, напишем простое приложение для формирования и хранения заметок.

Возможности нашего приложения будут следующими:

  1. Создание заметки.
  2. Хранение заметок.
  3. Удаление заметки.
  4. Отметка о выполнении задачи.
  5. Информация о дате выполнения задачи.
  6. Напоминание о необходимости выполнения задачи.

Приложение будет написано на JavaScript.

Заметки будут храниться в индексированной базе данных (IndexedDB). Для облегчения работы с IndexedDB будет использована эта библиотека. Как заявляют разработчики данной библиотеки, она представляет собой «тоже самое, что и IndexedDB, но с промисами».

Предполагается, что вы знакомы с азами IndexedDB. Если нет, то прежде чем продолжить рекомендую прочитать эту статью.

Я понимаю, что для решения такой задачи, как хранение заметок, вполне достаточно LocalStorage. Однако, мне хотелось исследовать некоторые возможности IndexedDB. Таким образом, выбор в пользу последней был сделан исключительно из гносеологических соображений. В конце будут приведены ссылки на похожее приложение, где хранение данных реализовано с помощью LocalStorage.

Итак, поехали.

Наша разметка выглядит так:

<!-- head -->
<!-- шрифт -->
<link href="https://fonts.googleapis.com/css2?family=Stylish&display=swap" rel="stylesheet">
<!-- библиотека -->
<script src="https://cdn.jsdelivr.net/npm/idb@3.0.2/build/idb.min.js"></script>

<!-- body -->
<!-- основной контейнер -->
<div class="box">
    <!-- изображение-заполнитель -->
    <img src="https://placeimg.com/480/240/nature" alt="#">
    <!-- поле для ввода текста заметки -->
    <p>Note text: </p>
    <textarea></textarea>
    <!-- поле для ввода даты напоминания -->
    <p>Notification date: </p>
    <input type="date">

    <!-- кнопка для добавления заметки -->
    <button class="add-btn">add note</button>
    <!-- кнопка для очистки хранилища -->
    <button class="clear-btn">clear storage</button>
</div>

Замечания:

  1. Поля для ввода можно было создать с помощью тегов «figure» и «figcaption». Это было бы так сказать «семантичнее».
  2. Как впоследствии оказалось, выбор тега «input» с типом «date», был не лучшим решением. Об этом ниже.
  3. В одном из приложений напоминания (уведомления) реализованы с помощью Notifications API. Однако мне показалось странным запрашивать у пользователя разрешение на показ уведомлений и добавлять возможность их отключения, поскольку, во-первых, когда мы говорим о приложении для заметок (задач), напоминания подразумеваются, во-вторых, их можно реализовать так, чтобы они не раздражали пользователя при многократном появлении, т.е. ненавязчиво.
  4. Изначально в приложении предусматривалась возможность указывать не только дату, но и время напоминания. Впоследствии я решил, что даты достаточно. Впрочем, при желании ее легко добавить.

Подключаем стили:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    height: 100vh;
    background: radial-gradient(circle, skyblue, steelblue);
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    font-family: 'Stylish', sans-serif;
    font-size: 1.2em;
}

.box,
.list {
    margin: 0 .4em;
    width: 320px;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    background: linear-gradient(lightyellow, darkorange);
    border-radius: 5px;
    padding: .6em;
    box-shadow: 0 0 4px rgba(0, 0, 0, .6)
}

img {
    padding: .4em;
    width: 100%;
}

h3 {
    user-select: none;
}

p {
    margin: .2em 0;
    font-size: 1.1em;
}

textarea {
    width: 300px;
    height: 80px;
    padding: .4em;
    border-radius: 5px;
    font-size: 1em;
    resize: none;
    margin-bottom: .7em;
}

input[type="date"] {
    width: 150px;
    text-align: center;
    margin-bottom: 3em;
}

button {
    width: 140px;
    padding: .4em;
    margin: .4em 0;
    cursor: pointer;
    border: none;
    background: linear-gradient(lightgreen, darkgreen);
    border-radius: 5px;
    font-family: inherit;
    font-size: .8em;
    text-transform: uppercase;
    box-shadow: 0 2px 2px rgba(0, 0, 0, .5);
}

button:active {
    box-shadow: 0 1px 1px rgba(0, 0, 0, .7);
}

button:focus,
textarea:focus,
input:focus {
    outline: none;
}

.note {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: center;
    font-style: italic;
    user-select: none;
    word-break: break-all;
    position: relative;
}

.note p {
    width: 240px;
    font-size: 1em;
}

.note span {
    display: block;
    cursor: pointer;
    font-weight: bold;
    font-style: normal;
}

.info {
    color: blue;
}

.notify {
    color: #ddd;
    font-size: .9em;
    font-weight: normal !important;
    text-align: center;
    line-height: 25px;
    border-radius: 5px;
    width: 130px;
    height: 25px;
    position: absolute;
    top: -10px;
    left: -65px;
    background: rgba(0, 0, 0, .6);
    transition: .2s;
    opacity: 0;
}

.show {
    opacity: 1;
}

.info.null,
.notify.null {
    display: none;
}

.complete {
    padding: 0 .4em;
    color: green;
}

.delete {
    padding-left: .4em;
    color: red;
}

.line-through {
    text-decoration: line-through;
}


Пока не обращайте на них много внимания.

Переходим к скрипту.

Находим поля для ввода и создаем контейнер для заметок:

let textarea = document.querySelector('textarea')
let dateInput = document.querySelector('input[type="date"]')

let list = document.createElement('div')
list.classList.add('list')
document.body.appendChild(list)

Создаем базу данных и хранилище:

let db;
// IIFE
(async () => {
    // создаем базу данных
    // название, версия...
    db = await idb.openDb('db', 1, db => {
        // создаем хранилище
        db.createObjectStore('notes', {
            keyPath: 'id'
        })
    })

    // формируем список
    createList()
})();

Рассмотрим функцию добавления заметки, чтобы понимать, что из себя представляет или, точнее, что содержит одна заметка. Это поможет понять, как формируется список:

// добавляем к кнопке для добавления заметки обработчик события "клик"
document.querySelector('.add-btn').onclick = addNote

const addNote = async () => {
    // если поле для ввода текста пустое, ничего не делаем
    if (textarea.value === '') return

    // получаем значение этого поля
    let text = textarea.value

    // объявляем переменную для даты напоминания
    // с помощью тернарного оператора
    // присваиваем этой переменной null или значение соответствующего поля
    let date
    dateInput.value === '' ? date = null : date = dateInput.value

    // заметка представляет собой объект
    let note = {
        id: id,
        text: text,
        // дата создания
        createdDate: new Date().toLocaleDateString(),
        // индикатор выполнения
        completed: '',
        // дата напоминания
        notifyDate: date
    }

    // пробуем записать данные в хранилище
    try {
        await db.transaction('notes', 'readwrite')
            .objectStore('notes')
            .add(note)
        // формируем список
        await createList()
            // обнуляем значения полей
            .then(() => {
                textarea.value = ''
                dateInput.value = ''
            })
    } catch { }
}

Теперь займемся формированием списка:

let id

const createList = async () => {
    // добавляем заголовок
    // дату формируем с помощью API интернационализации
    list.innerHTML = `<h3>Today is ${new Intl.DateTimeFormat('en', { year: 'numeric', month: 'long', day: 'numeric' }).format()}</h3>`

    // получаем заметки из базы данных
    let notes = await db.transaction('notes')
        .objectStore('notes')
        .getAll()

    // массив для дат напоминаний
    let dates = []

    // если в базе имеются данные
    if (notes.length) {
        // присваиваем переменной "id" номер последней заметки
        id = notes.length

        // итерация по массиву
        notes.map(note => {
           // добавляем заметки в список
            list.insertAdjacentHTML('beforeend',
            // добавляем заметке атрибут "data-id"
            `<div class = "note" data-id="${note.id}">
            // дата уведомления
            <span class="notify ${note.notifyDate}">${note.notifyDate}</span>
            // значок (кнопка) отображения уведомления
            // обратите внимание, что в качестве дополнительного класса
            // мы добавляем тексту и значку уведомления дату напоминания
            // если дата не указана
            // текст и значок уведомления не отображаются (CSS: .info.null, .notify.null)
            <span class="info ${note.notifyDate}">?</span>

            // значок (кнопка) выполнения задачи
            <span class="complete">V</span>
            // в качестве класса к тексту заметки добавляется индикатор выполнения
            <p class="${note.completed}">Text: ${note.text}, <br> created: ${note.createdDate}</p>
            // значок (кнопка) удаления заметки
            <span class="delete">X</span>
        </div>`)
            // заполняем массив с датами напоминаний
            // если дата не указана
            if (note.notifyDate === null) {
                return
            // если дата указана
            } else {
                // массив объектов
                dates.push({
                    id: note.id,
                    date: note.notifyDate.replace(/(\d+)-(\d+)-(\d+)/, '$3.$2.$1')
                })
            }
        })
    // если в базе не имеется данных
    } else {
        // присваиваем переменной "id" значение 0
        id = 0

        // выводим в список текст об отсутствии заметок
        list.insertAdjacentHTML('beforeend', '<p class="note">empty</p>')
    }
    // ...to be continued

Массив объектов для хранения дат напоминаний имеет два поля: «id» для идентификации заметки и «date» для сравнения дат. Записывая значение даты напоминания в поле «date», мы вынуждены это значение преобразовывать, поскольку inputDate.value возвращает данные в формате «гггг-мм-дд», а мы собираемся сравнивать эти данные с данными в привычном нам формате, т.е. «дд.мм.гггг». Поэтому мы используем метод «replace» и регулярное выражение, где с помощью группировки инвертируем блоки и заменяем дефисы точками. Возможно, существует более универсальное или элегантное решение.

Далее работаем с заметками:

       // ...
       // находим все заметки и добавляем к каждой обработчик события "клик"
       // мы делаем это внутри функции формирования списка
       // поскольку наш список при добавлении/удалении заметки формируется заново
       document.querySelectorAll('.note').forEach(note => note.addEventListener('click', event => {
        // если целью клика является элемент с классом "complete" (кнопка выполнения задачи)
        if (event.target.classList.contains('complete')) {
            // добавляем/удаляем у следующего элемента (текст заметки) класс "line-through", отвечающий за зачеркивание текста
            event.target.nextElementSibling.classList.toggle('line-through')

            // меняем значение индикатора выполнения заметки
            // в зависимости от наличия класса "complete"
            note.querySelector('p').classList.contains('line-through')
                ? notes[note.dataset.id].completed = 'line-through'
                : notes[note.dataset.id].completed = ''

            // перезаписываем заметку в хранилище
            db.transaction('notes', 'readwrite')
                .objectStore('notes')
                .put(notes[note.dataset.id])

        // если целью клика является элемент с классом "delete" (кнопка удаления заметки)
        } else if (event.target.classList.contains('delete')) {
            // вызываем соответствующую функцию со значением идентификатора заметки в качестве параметра
            // обратите внимание, что нам необходимо преобразовать id в число
            deleteNote(+note.dataset.id)

        // если целью клика является элемент с классом "info" (кнопка отображения даты напоминания)
        } else if (event.target.classList.contains('info')) {
            // добавляем/удаляем у предыдущего элемента (дата напоминания) класс "show", отвечающий за отображение
            event.target.previousElementSibling.classList.toggle('show')
        }
    }))

    // запускаем проверку напоминаний
    checkDeadline(dates)
}

Функция удаления заметки из списка и хранилища выглядит так:

const deleteNote = async key => {
    // открываем транзакцию и удаляем заметку по ключу (идентификатор)
    await db.transaction('notes', 'readwrite')
        .objectStore('notes')
        .delete(key)
    await createList()
}

В нашем приложении отсутствует возможность удаления базы данных, но соответствующая функция могла бы выглядеть следующим образом:

document.querySelector('.delete-btn').onclick = async () => {
    // удаляем базу данных
    await idb.deleteDb('dataBase')
        // перезагружаем страницу
        .then(location.reload())
}

Функция проверки напоминаний сравнивает текущую дату и даты напоминаний, введенные пользователем:

const checkDeadline = async dates => {
    // получаем текущую дату в формате "дд.мм.гггг"
    let today = `${new Date().toLocaleDateString()}`

    // итерация по массиву
    dates.forEach(date => {
        // если текущая дата и одна из дат напоминаний совпадают
        if (date.date === today) {
            // меняем кнопку отображения напоминания с "?" на "!"
            document.querySelector(`div[data-id="${date.id}"] .info`).textContent = '!'
        }
    })
}

В завершение добавляем к объекту Window обработчик ошибок, которые не были обработаны в соответствующих блоках кода:

window.addEventListener('unhandledrejection', event => {
    console.error('error: ' + event.reason.message)
})

Результат выглядит так:



> Код на Github

Вот похожее приложение на Local Storage:



> Код этого приложения на Github

Буду рад любым замечаниям.

Благодарю за внимание.