Доброго времени суток, друзья!
Сегодня мы с вами, как следует из названия, напишем простое приложение для формирования и хранения заметок.
Возможности нашего приложения будут следующими:
- Создание заметки.
- Хранение заметок.
- Удаление заметки.
- Отметка о выполнении задачи.
- Информация о дате выполнения задачи.
- Напоминание о необходимости выполнения задачи.
Приложение будет написано на 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>
Замечания:
- Поля для ввода можно было создать с помощью тегов «figure» и «figcaption». Это было бы так сказать «семантичнее».
- Как впоследствии оказалось, выбор тега «input» с типом «date», был не лучшим решением. Об этом ниже.
- В одном из приложений напоминания (уведомления) реализованы с помощью Notifications API. Однако мне показалось странным запрашивать у пользователя разрешение на показ уведомлений и добавлять возможность их отключения, поскольку, во-первых, когда мы говорим о приложении для заметок (задач), напоминания подразумеваются, во-вторых, их можно реализовать так, чтобы они не раздражали пользователя при многократном появлении, т.е. ненавязчиво.
- Изначально в приложении предусматривалась возможность указывать не только дату, но и время напоминания. Впоследствии я решил, что даты достаточно. Впрочем, при желании ее легко добавить.
Подключаем стили:
* {
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
Буду рад любым замечаниям.
Благодарю за внимание.
smart_alex
Для тех кто в танке поясните как это предполагается использовать: где хранится база (на сервере? или где?), кто и как подключается к «приложению для заметок» (по URL сайта или как-то локально или ещё как)?
Я понимаю, что для автора всё это само-собой разумеется, но не всем это понятно из статьи.
lexasss
Локально в самом браузере
smart_alex
Что-то смысл всей этой затеи от меня ускользает. Если «Локально в браузере», то удалил/переустановил браузер и вся информация пропала?
Или это просто абстрактный пример без какого-либо практического смысла?
lexasss
Вот аргументация автора:
smart_alex
А, ну теперь более-менее понятно.
jaiprakash
Можно обернуть в кордову и будет мобильное приложение.
vadim5june
Приложение работает в браузере
На сервере только сам код приложения нужно разместить-взять с гитхаба-там 3 файла html,css и js
Используется браузерная база данных indexedDb(используется сторонняя библиотека-небольшая) либо в другом варианте localStorsge
learn.javascript.ru/indexeddb
housequake
IndexedDB хранится в браузере, как и localStorage.