Создание приложения ToDo в React

Приложение ToDo — это приложение самого начального уровня для любого фронтенд-разработчика. Базовое приложение ToDo имеет функциональность добавления, удаления и обновления дел из списка. Будучи разработчиком, мы легко забываем о задачах на день или на определенный период времени. Всегда желательно иметь такое приложение, в котором можно добавлять, удалять или изменять задачи.

В этом руководстве мы разработаем приложение ToDo App с нуля с базовой функциональностью crud (Create, Read, Update, Delete) и дополнительными возможностями, такими как поиск по фильтру, скрытие задач и обновление статуса.

Начало работы

Создавая React App из cra-шаблона с помощью create-react-app, нам не потребуется никаких внешних библиотек для проекта, кроме react-icons, которая нам понадобится для иконок, используемых в приложении.

ToDoApp.jsx

import React from 'react';

export default function ToDoApp() {
    return (
        <section className="ToDoApp">
            <h1>ToDo App</h1>
        </section>
    );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Для приложения мы реализуем два компонента, а именно ToDoCard и ToDoForm.

Реализация

Добавление основных стилей

ToDoApp.css

.ToDoApp {
    width: 800px;
    max-width: 100%;
    margin: auto;
    padding: 0.5rem;
    color: var(--black);
}

.grey_text {
    color: var(--grey);
}
.red_text {
    color: var(--red);
}
.blue_text {
    color: var(--blue);
}
.green_text {
    color: var(--green);
}

.ToDoApp input,
.ToDoApp textarea,
.ToDoApp select {
    width: 100%;
    padding: 0.5rem 0.75rem;
}

.ToDoApp textarea {
    height: 10rem;
}

.ToDoApp button {
    padding: 0.5rem 1.5rem;
    background: var(--white);
    border: 1px solid var(--black);
}

.ToDoApp__Search {
    margin-top: 0.5rem;
    display: flex;
    gap: 1.5rem;
}

.ToDoApp__Search input {
    border: 1px solid var(--black);
}

/* @ToDoList Layout */

.ToDoList {
    height: 100%;
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
    margin-top: 0.5rem;
}

.ToDoList__action {
    width: 100%;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Компонент карточки

Прежде чем начать, давайте установим react-icons, выполнив команду

npm i react-icons
Войти в полноэкранный режим Выйти из полноэкранного режима

Определение json-схемы для каждого todo

{
    "title": "string",
    "description": "string",
    "status": "integer(0,1,2)",
    "hide": "boolean",
    "id": "integer"
}
Войти в полноэкранный режим Выход из полноэкранного режима

ToDoCard.jsx

import React from 'react';
// Icons for Todo Card
import {
    FaCheckCircle,
    FaClock,
    FaExclamationCircle,
    FaEye,
    FaEyeSlash,
    FaPencilAlt,
    FaTimesCircle,
} from 'react-icons/fa';


export default function ToDoCard({
    id,
    title,
    description,
    status,
    hide,
    ...otherProps
}){
    // Checking if the card is to be hidden
    if (hide) return null;

    return (
        <div className="ToDoCard" {...otherProps}>
            <div className="ToDoCard__left">
                <span>
                    {status === 0 && <FaExclamationCircle title="Pending" className="ToDoCard__icon grey_text" />}
                    {status === 1 && <FaClock title="Working" className="ToDoCard__icon blue_text" />}
                    {status === 2 && <FaCheckCircle title="Done" className="ToDoCard__icon green_text" />}
                </span>
            </div>
            <div className="ToDoCard__center">
                <h2>{title}</h2>
                <p>{description}</p>
            </div>
            <div className="ToDoCard__right">
                <FaTimesCircle
                    className="ToDoCard__icon red_text"
                />
                <span>
                    <FaEye title="Show Description" className="ToDoCard__icon" />
                </span>

                <FaPencilAlt
                    className="ToDoCard__icon"
                />
            </div>
        </div>
    );
}
Войти в полноэкранный режим Выход из полноэкранного режима

Компонент ToDoCard принимает все свойства схемы ToDo, где hide используется для возврата null при true, а status показывает три разных символа при трех разных целочисленных значениях.

Кроме того, мы можем переключать описание с помощью переменной состояния,

ToDoCard.jsx

...
export default function ToDoCard({
...
}){
    const [showDescription, setShowDescription] = React.useState(false);
    ...
    return (
        <div className="ToDoCard" {...otherProps}>
            ...
            <div className="ToDoCard__center">
                <h2>{title}</h2>
                {showDescription && <p>{description}</p>}
            </div>
            <div className="ToDoCard__right">
                ...
                <span
                    onClick={() => {
                        setShowDescription(!showDescription);
                    }}
                >
                    {showDescription && <FaEye title="Show Description" className="ToDoCard__icon" />}
                    {!showDescription && <FaEyeSlash title="Hide Description" className="ToDoCard__icon" />}
                </span>
                ...
            </div>
        </div>
    );
}
Вход в полноэкранный режим Выйти из полноэкранного режима

Используя React.useState(), мы решаем проблему видимости описания и его переключения.

Со стилизацией карточки проблем меньше,

ToDoApp.css

...
/* @ToDo Card Layout */

.ToDoCard {
    border: 1px solid var(--black);
    width: 900px;
    max-width: 100%;
    padding: 0.5rem;
    font-size: 1rem;
    display: flex;
    flex-wrap: wrap;
    gap: 0.5rem;
}

.ToDoCard div {
    display: flex;
    flex-direction: column;
    justify-content: center;
    align-items: center;
}

.ToDoCard .ToDoCard__left {
    flex: 0 2.5rem;
}

.ToDoCard .ToDoCard__center {
    flex: 3;
    display: inline-block;
}

.ToDoCard .ToDoCard__right {
    flex: 0 2.5rem;
    gap: 0.5rem;
}

.ToDoCard h2 {
    font-size: larger;
}

.ToDoCard__icon {
    cursor: pointer;
}

@media screen and (max-width: 900px) {
    .ToDoCard {
        width: 100%;
        flex-direction: column;
    }
    .ToDoCard div {
        flex-direction: row;
        justify-content: flex-start;
    }
}
Вход в полноэкранный режим Выход из полноэкранного режима

Показ/скрытие карточек с ограничением

В этом разделе мы используем переменную состояния todos для хранения значений todos и переменную maxDisplayTodos для определения максимального количества видимых карточек todo.

ToDoApp.jsx

import React from 'react';
import ToDoCard from './ToDoCard';
import './ToDoApp.css';
import { FaPlusCircle } from 'react-icons/fa';


export default function ToDoApp() {
    const [todos, setTodos] = React.useState([]);
    const [hideTodos, setHideTodos] = React.useState(true);

    const maxDisplayTodos = 5;

    React.useEffect(() => {
        setTodos([
            {
                title: 'Learn React',
                description: 'Learn React and its ecosystem',
                status: 0,
                hide: false,
                id: 1,
            },
            {
                title: 'Create a React Component',
                description:
                    'Lorem ipsum dolor sit, amet consectetur adipisicing elit. Veritatis esse aut similique reprehenderit fuga cupiditate porro. Nostrum, ipsam perferendis! Fuga nisi nostrum odit nulla quia, sint harum eligendi recusandae dolore!',
                status: 0,
                hide: false,
                id: 2,
            },
            {
                title: 'Learn Vue',
                description:
                    'Far far away, behind the word mountains, far from the countries Vokalia and Consonantia, there live the blind texts. Separated they live in Bookmarksgrove right at the coast of the Semantics, a large language ocean. A small river named Duden flows by their place and supplies it with the necessary.',
                status: 0,
                hide: false,
                id: 3,
            },
            {
                title: 'Learn Angular',
                description:
                    'A wonderful serenity has taken possession of my entire soul, like these sweet mornings of spring which I enjoy with my whole heart. I am alone, and feel the charm of existence in this spot, which was created for the bliss of souls like mine. I am so happy, my',
                status: 0,
                hide: false,
                id: 4,
            },
            {
                title: 'Vue Typewriter',
                description:
                    'Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta.',
                status: 0,
                hide: false,
                id: 5,
            },
            {
                title: 'Learn jQuery',
                description:
                    'Li Europan lingues es membres del sam familie. Lor separat existentie es un myth. Por scientie, musica, sport etc, litot Europa usa li sam vocabular. Li lingues differe solmen in li grammatica, li pronunciation e li plu commun vocabules. Omnicos directe al desirabilite de un nov lingua franca: On refusa',
                status: 0,
                hide: false,
                id: 14,
            },
            {
                title: 'Learn Javascript',
                description:
                    'The European languages are members of the same family. Their separate existence is a myth. For science, music, sport, etc, Europe uses the same vocabulary. The languages only differ in their grammar, their pronunciation and their most common words. Everyone realizes why a new common language would be desirable: one',
                status: 0,
                hide: false,
                id: 15,
            },
        ]);
    }, []);

    function handleHideTodos() {
        const newHideTodos = !hideTodos;
        setHideTodos(newHideTodos);
        if (newHideTodos) {
            const newTodos = todos.map((todo, index) => {
                if (index >= maxDisplayTodos) todo.hide = false;
                return todo;
            });
            setTodos(newTodos);
        } else {
            const newTodos = todos.map((todo, index) => {
                if (index >= maxDisplayTodos) todo.hide = true;
                return todo;
            });
            setTodos(newTodos);
        }
    }


    return (
        <section className="ToDoApp">
            <h1>ToDo App</h1>
            <div className="ToDoList">
                {(todos || []).map((todo, index) => (
                    <ToDoCard
                        key={index}
                        {...todo}
                    />
                ))}
                {(!todos || todos.length === 0) && (
                    <div className="ToDoList__empty">
                        <p>No todos found</p>
                    </div>
                )}
                {todos.length > maxDisplayTodos && (
                    <button className="ToDoList__action" type="button" onClick={() => handleHideTodos()}>
                        {hideTodos ? 'HIDE' : 'SHOW'}
                    </button>
                )}
            </div>
        </section>
    );

}
Вход в полноэкранный режим Выход из полноэкранного режима

Есть еще одна переменная состояния hideTodos, используемая для определения того, когда скрывать todos, а когда нет. Также есть функция handleHideTodos(), которая обрабатывает переменную состояния hideTodos и на основе текущего состояния hideTodos мы либо скрываем, либо показываем предел maxDisplayTodos. У нас также есть индикатор no todos found для отсутствия todos и переключаемая кнопка show/hide на основе hideTodos.

Компонент формы

Прежде чем мы приступим к добавлению, редактированию и удалению записей, давайте представим наш компонент формы.

ToDoForm.jsx

import React from 'react';
import { FaTimes } from 'react-icons/fa';

function ToDoForm({
    title: titleProps,
    description: descriptionProps,
    status: statusProps,
    id,
}) {
    const [title, setTitle] = React.useState(titleProps);
    const [description, setDescription] = React.useState(descriptionProps);
    const [status, setStatus] = React.useState(statusProps);

    function handleTitleChange(e) {
        setTitle(e.target.value);
    }

    function handleDescriptionChange(e) {
        setDescription(e.target.value);
    }

    function handleStatusChange(e) {
        setStatus(parseInt(e.target.value));
    }

    return (
        <form className="ToDoForm">
            <FaTimes className="close-btn"/>
            <h2>ToDo Form</h2>
            <div className="ToDoForm__field">
                <label htmlFor="title">Title</label>
                <input type="text" id="title" value={title} onChange={(e) => handleTitleChange(e)} />
            </div>
            <div className="ToDoForm__field">
                <label htmlFor="description">Description</label>
                <textarea
                    type="text"
                    id="description"
                    value={description}
                    onChange={(e) => handleDescriptionChange(e)}
                />
            </div>
            <div className="ToDoForm__field">
                <label htmlFor="status">Status</label>
                <select id="status" value={status} onChange={(e) => handleStatusChange(e)}>
                    <option value="0">Pending</option>
                    <option value="1">Working</option>
                    <option value="2">Done</option>
                </select>
            </div>
            <div className="ToDoForm__action">
                <button type="submit">{id === -1 ? 'Add' : 'Update'}</button>
            </div>
        </form>
    );
}

ToDoForm.defaultProps = {
    title: '',
    description: '',
    status: 0,
    id: -1,
};

export default ToDoForm;
Вход в полноэкранный режим Выход из полноэкранного режима

Обработка элементов формы представляет собой проблему в React, если они обрабатываются с помощью переменных состояния, нам нужно обработать inputChange с помощью обработчика событий. Поэтому есть три переменные состояния (заголовок, описание и статус) и три обработчика inputChange (handleTitleChange, handleDescriptionChange, handleStatusChange).

Стилизация компонента ToDoForm

ToDoApp.css

...
/* @ToDo Form Layout */
.ToDoForm {
    padding: 0.5rem;
    border: 1px solid var(--black);
    margin-top: 1rem;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
    justify-content: space-around;
    position: relative;
}
.ToDoForm .close-btn {
    position: absolute;
    right: 0.5rem;
    top: 0.5rem;
}
.ToDoForm__field,
.ToDoForm__action {
    display: flex;
    align-items: center;
    flex-direction: row;
    gap: 0.5rem;
}
.ToDoForm__field label {
    flex: 0 0 6rem;
    font-size: 1rem;
}
.ToDoForm__action button {
    margin-left: auto;
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавление компонента формы & Закрыть форму

ToDoApp.jsx

...
export default function ToDoApp(){
    ...
    const [showForm, setShowForm] = React.useState(false);

    ...

    return (
        <section className="ToDoApp">
            ...
            {showForm && (
                <ToDoForm
                    closeForm={() => {
                        setShowForm(false);
                    }}
                />
            )}
        </section>
    );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавлена переменная состояния showForm, передайте ее компоненту формы.

ToDoForm.jsx

...
function ToDoForm({
    title: titleProps,
    description: descriptionProps,
    status: statusProps,
    id,
    closeForm,
)} {
    ...
    function handleCloseForm() {
        setTitle('');
        setDescription('');
        setStatus(0);
        closeForm();
    }

    return (
        <form className="ToDoForm">
            <FaTimes className="close-btn" onClick={() => handleCloseForm()} />
            ...
        </form>
    );
}
...
Войти в полноэкранный режим Выйти из полноэкранного режима

Добавление обработчика для closeform с установкой всех переменных состояния в начальное состояние.

Поиск элементов Todo

ToDoApp.jsx

...
export default function ToDoApp() {
    ...
    const [searchText, setSearchText] = React.useState('');

    ...

    function handleSearchChange(evt) {
        setSearchText(evt.target.value);

        const newTodos = todos.map((todo) => {
            todo.hide = !(
                todo.title.toLowerCase().includes(evt.target.value.toLowerCase()) ||
                todo.description.toLowerCase().includes(evt.target.value.toLowerCase())
            );
            return todo;
        });
        setTodos(newTodos);
    }

    return (
        <section className="ToDoApp">
            <h1>ToDo App</h1>
            <div className="ToDoApp__Search">
                <input
                    type="text"
                    value={searchText}
                    onChange={(evt) => handleSearchChange(evt)}
                    placeholder="Search"
                />
                <button className="ToDoApp__create_btn">
                    <FaPlusCircle />
                </button>
            </div>
            ...
        </section>

    );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Используется переменная состояния searchText для хранения входного значения поиска, также обрабатывается изменение поиска со скрытием списка, который не соответствует поиску. В случае длинного списка можно было бы запросить его из базы данных с помощью загрузчика.

Добавление пунктов Todo

ToDoApp.jsx

...
export default function ToDoApp() {
    ...

    function handleAddTodo(todo) {
        const newTodo = {
            title: todo.title,
            description: todo.description,
            status: 0,
            hide: false,
            id: Date.now() % 1000000,
        };
        setTodos([...todos, newTodo]);
        setShowForm(false);     
    }
    ...
    return (
        <section className="ToDoApp">
            <h1>ToDo App</h1>
            <div className="ToDoApp__Search">
                ...
                <button className="ToDoApp__create_btn" onClick={() => setShowForm(true)}>
                    <FaPlusCircle />
                </button>
            </div>

            {showForm && (
                <ToDoForm
                    handleAddTodo={handleAddTodo}
                    closeForm={() => {
                        setShowForm(false);
                    }}
                />
            )}
            ...
        </section>
    );
}

Вход в полноэкранный режим Выход из полноэкранного режима

Определение функции-обработчика handleAddToDo для добавления нового объекта ToDo в список ToDo и поддержание закрытия формы при отправке. Открытие формы при нажатии на кнопку создания Todo.

ToDoForm.jsx

...
function ToDoForm({
    title: titleProps,
    description: descriptionProps,
    status: statusProps,
    id,
    closeForm,
    handleAddTodo,
}) {
    ...

    function handleFormSubmit(e) {
        e.preventDefault();
        if (title === '' || description === '') {
            alert('Please fill in all fields');
            return;
        }
        handleAddTodo({ title, description, status });
        setTitle('');
        setDescription('');
        setStatus(0);
    }

    return (
        <form className="ToDoForm" onSubmit={(e) => handleFormSubmit(e)}>
            ...
        </form>
    );
}
...
Вход в полноэкранный режим Выход из полноэкранного режима

Определение функции handleFormSubmit для установки начальных значений и запуска обработчика addtodo.

Редактирование пункта Todo

Редактирование Todo Item немного сложнее, потому что нам нужно помнить id редактируемого элемента и его значение, передаваемое в форму todo. Давайте посмотрим, как это происходит.

ToDoApp.jsx

...
export default function ToDoApp() {
    const [currentTodo, setCurrentTodo] = React.useState({});
    ...
    function handleEditTodo(id) {
        setShowForm(true);
        const todo = todos.find((todo) => todo.id === id);
        setCurrentTodo(todo);
    }

    function handleAddTodo(todo) {
        if (todo.id === undefined) {
            const newTodo = {
                title: todo.title,
                description: todo.description,
                status: 0,
                hide: false,
                id: Date.now() % 1000000,
            };
            setTodos([...todos, newTodo]);
        } else {
            const newTodos = todos.map((todo_) => {
                if (todo.id === todo_.id) {
                    todo_.title = todo.title;
                    todo_.description = todo.description;
                    todo_.status = todo.status;
                }
                return todo_;
            });
            setTodos(newTodos);
        }
        setCurrentTodo({});
        setShowForm(false);
    }

    return (
        <section className="ToDoApp">
            ...

            {showForm && (
                <ToDoForm
                    handleAddTodo={handleAddTodo}
                    {...currentTodo}
                    closeForm={() => {
                        setCurrentTodo({});
                        setShowForm(false);
                    }}
                />
            )}


            <div className="ToDoList">
                {(todos || []).map((todo, index) => (
                    <ToDoCard
                        key={index}
                        {...todo}
                        handleEditTodo={handleEditTodo}
                    />
                ))}
                ...
            </div>
        </section>
    );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавление переменной состояния currentTodo для установки текущего объекта Todo для редактирования и передачи в качестве prop в форму ToDo, а также модификация функции handleAddTodo для обновления уже существующего объекта Todo. Добавьте функцию handleEditTodo для установки currentTodo для текущего элемента.

ToDoForm.jsx

...
function ToDoForm({
    title: titleProps,
    description: descriptionProps,
    status: statusProps,
    id,
    closeForm,
    handleAddTodo,
}) {
    ...

    function handleFormSubmit(e) {
        e.preventDefault();
        if (title === '' || description === '') {
            alert('Please fill in all fields');
            return;
        }
        if (id >= 0) handleAddTodo({ title, description, status, id: id });
        else handleAddTodo({ title, description, status });
        setTitle('');
        setDescription('');
        setStatus(0);
    }
    ...
}
...
Вход в полноэкранный режим Выход из полноэкранного режима

Модификация функции handleFormSubmit для обработки случаев создания и обновления.

ToDoCard.jsx

...
export default function ToDoCard({
    id,
    title,
    description,
    status,
    hide,
    handleEditTodo,
    ...otherProps
}){
    ...
    return (
        <div className="ToDoCard" {...otherProps}>
            ...
            <div className="ToDoCard__right">
                ...
                <FaPencilAlt
                    className="ToDoCard__icon"
                    onClick={() => {
                        handleEditTodo(id);
                    }}
                />
            </div>
        </div>
    );
}

Вход в полноэкранный режим Выход из полноэкранного режима

Срабатывание функции handleEditTodo для текущего элемента ToDo.

Удалить ToDo

ToDoApp.jsx

...

export default function ToDoApp() {
    ...

    function handleDeleteTodo(id) {
        const newTodos = todos.filter((todo) => todo.id !== id);
        setTodos(newTodos);
    }

    return (
        <section className="ToDoApp">
            ...

            <div className="ToDoList">
                {(todos || []).map((todo, index) => (
                    <ToDoCard
                        key={index}
                        {...todo}
                        handleEditTodo={handleEditTodo}
                        handleDeleteTodo={handleDeleteTodo}
                    />
                ))}
                ...
            </div>
        </section>
    );
}
Вход в полноэкранный режим Выход из полноэкранного режима

Создание функции handleDeleteTodo для id, обновление todos без данного id объекта todo и передача в ToDoCard.

ToDoCard.jsx

...
export default function ToDoCard({
    id,
    title,
    description,
    status,
    hide,
    handleEditTodo,
    handleDeleteTodo,
    ...otherProps
}){
    ...
    return (
        <div className="ToDoCard" {...otherProps}>
            ...
            <div className="ToDoCard__right">
                <FaTimesCircle
                    className="ToDoCard__icon red_text"
                    onClick={() => {
                        handleDeleteTodo(id);
                    }}
                />
                ...
            </div>
        </div>
    );
}
...

Вход в полноэкранный режим Выход из полноэкранного режима

Элемент ToDoCard при нажатии на кнопку удаления запускает функцию handleDeleteTodo для текущего id элемента.

Изменить статус

ToDoApp.jsx

...
export default function ToDoApp() {
    ...

    function handleChangeStatus(id) {
        const newTodos = todos.map((todo) => {
            if (todo.id === id) {
                todo.status = todo.status === 2 ? 0 : todo.status + 1;
            }
            return todo;
        });
        setTodos(newTodos);
    }


    return (
        <section className="ToDoApp">
            ...

            <div className="ToDoList">
                {(todos || []).map((todo, index) => (
                    <ToDoCard
                        key={index}
                        {...todo}
                        handleChangeStatus={handleChangeStatus}
                        handleEditTodo={handleEditTodo}
                        handleDeleteTodo={handleDeleteTodo}
                    />
                ))}
                ...
            </div>
        </section>
    );  
}
Вход в полноэкранный режим Выход из полноэкранного режима

Добавлен обработчик для changestatus для id, который передается ToDoCard для вызова. Обработчик обновляет последний статус с 0 до 2 и обратно до 0 по кругу.

ToDoCard.jsx

...
export default function ToDoCard({
    id,
    title,
    description,
    status,
    hide,
    handleEditTodo,
    handleDeleteTodo,
    handleChangeStatus,
    ...otherProps
}) {
    ...

    return (
        <div className="ToDoCard" {...otherProps}>
            <div className="ToDoCard__left">
                <span
                    onClick={() => {
                        handleChangeStatus(id);
                    }}
                >
                    {status === 0 && <FaExclamationCircle title="Pending" className="ToDoCard__icon grey_text" />}
                    {status === 1 && <FaClock title="Working" className="ToDoCard__icon blue_text" />}
                    {status === 2 && <FaCheckCircle title="Done" className="ToDoCard__icon green_text" />}
                </span>
            </div>
            ...
        </div>
    );
}

Вход в полноэкранный режим Выход из полноэкранного режима

Переданная функция для изменения статуса — это функция onclick, срабатывающая для иконки статуса, которая изменяется с изменением значения статуса.

Окончательный код

ToDoApp.css
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ToDoApp/ToDoApp.css

ToDoApp.jsx
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ToDoApp/ToDoApp.jsx

ToDoCard.jsx
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ToDoApp/ToDoCard.jsx

ToDoForm.jsx
https://github.com/shivishbrahma/nuclear-reactor/blob/main/src/ToDoApp/ToDoForm.jsx

Предварительный просмотр

Оставьте комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *