Создание базового макета сайта и его дизайна

Долгое вступление и установка UI библиотеки + tailwind

Хороший дизайн сайта это уже половина дела. Ведь, если подумать, только его работа и видна. Вся та работа, которая совершается на сервере для конечного пользователя не важна. Важно то, что в итоге этой работы было совершенно нечто полезное для него и при этом функционал сайта легко читался и был не вырвиглазным.
После протчения данной статьи у тебя получится что-то вроде этого.
SearchResultParser десктопная версия
Десктопная версия
Я на своём опыте знаю, что дизайн это мастхев в наши дни. А для разработчика ещё важнее то, как этот дизайн делать и улучшать. И скажу я тебе мой дорогой читатель, заниматься этим на чистом JS и CSS это та ещё задачка.
Взять к примеру этот сайт. Он написан на чистом JS(ну окей, ещё и jQuery) и CSS. Поддерживать его тот ещё гемор, а добавить какой-нибудь новый компонент(типа приближение изображения, которое я до сих пор не сделал) вообще подвиг. Поэтому, в этом проекте (SearchResultParser) я буду использовать уже готовую UI библиотеку.
Я буду использовать MaterialUI + tailwindcss для более гибкой настройки стилей сайта, без необходимости залазить в CSS файлы. И будем использовать axios библиотеку для общения с сервером.
Итак, установим их:
npm install @mui/material @mui/icons-material/	 @emotion/react @emotion/styled tailwindcss
TailewindCSS нужно ещё настроить для его работы. Это создаст настроечный файл для него:
npx tailwindcss init
В tailwind.config.js вставьте следующий код.
/** @type {import('tailwindcss').Config} */
module.exports = {
  content: [
  "./src/**/*.{js,jsx,ts,tsx}",
  "./templates/**/*.html",
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}
В нём описано, к каким файлам применять данное расширение.
Также не забудь создать входной и выходной файл. В моём случае первый называется index.css, а другой zero.css. Первый в директории src, другой в static/css.
Во входном файле достаточно вставить несколько директив:
@tailwind base;
@tailwind components;
@tailwind utilities;
@tailwind variants;
И теперь, чтобы стили были применены необходимо запускать следующую команду каждый раз, когда изменяем файлы в которых используем tailwindcss, будь то html или js. Можно добавить —watch чтобы не перезапускать её каждый раз.
npx tailwindcss -i .\src\index.css -o .\static\css\index.css  --watch
TailwindCSS установлен и настроен. Теперь настроим axios. Тут всё довольно просто в файле package.json добавь следующую строку:
...
"keywords": [],
"author": "",
"proxy": "http://localhost:8000",
"license": "ISC",
"description": "",
...
Перейдём к настройке сервера.

Настройка маршрутов и представлений django

Скажу сразу, у меня не будет много маршрутов. Ибо, этот сайт это в первую очередь приложение, их ещё называют SAAS. У моего saas будут следующие маршруты/представления:
Вот, собственно говоря, и всё, теперь добавим эти представления и маршруты к ним. Добавим маршруты, в Frontend/urls.py:
from django.urls import path
from .views import main, about, contacts, articles

urlpatterns = [
    path('', main, name='main'),
    path('about/', about, name='about'),
    path('contacts/', contacts, name='contacts'),
]
Напишем представления( в файле views.py), пока просто для галочки, чтобы django не ругался:
from django.shortcuts import render

def main(request):
    return render(request, 'Frontend/app.html')

def about(request):
    return render(request, 'Frontend/about.html')

def contacts(request):
    return render(request, 'Frontend/contacts.html')
Это наша база. Больше, в этой статье, мы не вернёмся к django. Будем верстать, верстать и ещё раз верстать.

Верстка базового макета и его компонентов

Подготовка к работе, запуск сервера

Чтобы начать верстать и видеть результаты нашей работы, нужно будет запустить несколько команд в терминале. Во-первых, для того чтобы TailwindCSS мог сгенерировать для нас стили. Во-вторых, чтобы уже React успевал собирать компоненты.
Для генерации CSS стилей, tailwindcss:
npm run tailwind -i ./src/index.css -o ./static/css/zero.css –watch
Флаг -i для входного файла
Флаг -o для выходного файла
Также нужно указать аргумент —watch, чтобы не запускать данную команду каждый раз, когда начнём использовать новые стили.
Для компиляции и генерации JS, React:
npm run dev
Здесь, мы запускаем ранее записанный скрипт в package.json. Можно конечно и без скрипта, вот так:
npm run webpack –mode development –watch
Шеврон Очень важно чтобы ты понимал, для выполнения работы этими скриптами требуется время. Поэтому, иногда при обновлении страницы стили и скрипты не обновятся, и будет возникать много вопросов. И это значит, сначала смотрим была ли генерация успешна, а потом идём проверять сайт.
Остаётся только запустить Django-сервер, открыть вкладку и начать писать код.
./manage.py runserver

Работа с django шаблонами

Создадим базовый шаблон, base.html в templates/Frontend. Откроем его в текстовом редакторе и вставим следующий код:

{% load static %}

<!DOCTYPE html>
<html class="h-full w-full" lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <link rel="stylesheet" href="{% static 'css/zero.css' %}">
        <link rel="icon" href="http://localhost:8000/favicon.svg" type="image/svg+xml">
        {% block head %}
        {% endblock %}
    </head>
    <body class="w-full h-full flex flex-col">
        <div id="waiter" class="flex fixed left-0 top-0 w-full h-full hidden items-center justify-center z-10"></div>
        <div id="msg" class="flex fixed left-0 bottom-0 m-4 hidden"></div>
        <header class="flex justify-center items-center flex-shrink-0 basis-12 ">
            <div id="meta-header" class="hidden">
                {% block header %}
                {% endblock %}
                <div data-type="login"><a href="#">login</a></div>
            </div>
            <div id="header" class="flex w-full justify-center"></div>
        </header>
        <hr>
        
        <main class="flex-auto pl-2 pr-2 pt-3 pb-3">
            {% block main %}
            {% endblock %}
        </main>
        <hr>
        <footer class="items-center text-center flex-shrink-0 ">
            <div id="meta-footer" class="hidden">
                <a id="footer-in" href="https://timthewebmaster.com/en/">TimTheWebmaster ➥</a>
            </div>
            <div id="footer" class="flex flex-row flex-wrap justify-center p-3">
                Made by <div id="ref_to_place" class="pl-1 pr-1"></div> Copyright © 2024
            </div>
        </footer>
        <script>
            var IS_MOBILE = function() {
                let check = false;
                (function(a){if(/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test(a)||/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test(a.substr(0,4))) check = true;})(navigator.userAgent||navigator.vendor||window.opera);
                return check;
            }();
        </script>
        <script src="{% static 'js/main.js' %}"></script>
        {% block scripts %}
        {% endblock %}
    </body>
</html>
Сначала, мы загружаем значение глобальной переменной static, чтобы иметь доступ к CSS, JS, JPEG, PNG, SVG и прочим медиафайлам на нашем сервере.
Этот файл, как я называю их, базовики, эти шаблоны не рендерятся на прямую, их основная роль это быть скелетом/основанием/базой для других шаблонов. Например, этот сайт, на котором размещается эта статья, имеет следующие базовики:
И чтобы шаблон, который унаследует базовика, мог его модернизировать и добавлять что-то свою нужно добавить специальные блоки. В данном базовике их 4:
Все эти блоки выглядят примерно так:

{% block main %}
{% endblock %}
Теперь, когда мы разобрались с тем как данный шаблон устроен нужно сделать так, чтобы этот шаблон унаследовали следующие шаблоны:
Шаблон app.html:

{% extends 'Frontend/base.html' %}
{% load static %}

{% block head %}
    <title>SearchResultParser - main</title>
    <meta name="description" content="">
    <link rel="canonical" href="http://localhost:8000/">
{% endblock %}

{% block header %}
    <div data-type="inner-link"><a href="{% url 'about' %}">about</a></div>
    <div data-type="inner-link"><a href="{% url 'contacts' %}">contacts</a></div>
    <div data-type="inner-link"><a href="https://timthewebmaster.com/en/articles/series-of-articles-about-search-result-parser-webtool/">articles</a></div>
    <div data-type="tutorial"><a href="#">tutorial</a></div>
{% endblock %}

{% block main %}
{% endblock %}

{% block scripts %}
{% endblock %}
Вот пример шаблона about.html

{% extends 'Frontend/base.html' %}
{% load static %}

{% block head %}
    <title>SearchResultParser - About this project</title>
    <meta name="description" content="">
    <link rel="canonical" href="{% url 'about' %}">
{% endblock %}

{% block header %}
    <div data-type="inner-link"><a href="{% url 'about' %}">about</a></div>
    <div data-type="inner-link"><a href="{% url 'contacts' %}">contacts</a></div>
    <div data-type="inner-link"><a href="https://timthewebmaster.com/en/articles/series-of-articles-about-search-result-parser-webtool/">articles</a></div>
    <div data-type="inner-link"><a href="{% url 'main' %}">main</a></div>
{% endblock %}

{% block main %}
{% endblock %}

{% block scripts %}
{% endblock %}
Шаблон для contacts.html идентичен выше написанному шаблону, с той лишь разницей, что у них отличаются тайтлы, канонический адрес и описание.
Шеврон Функция url в шаблоне принимает значения переменной name. Которую, мы заполняли в Frontend/urls.py
Одними шаблонами сыт не будешь, нужен React. Причём использовать его нужно окуратно. В чём дело? Ты мог заметить, что у меня есть специальные элементы с айдишниками header и footer и рядом с ними их аналоги, meta-header и meta-footer. Почему я так сделал? Почему бы не отрендерить всё в одном блоке через react ?
Причиной этого является то, как react и django рендерят страницы. Если react отдаёт рендеринг пользовательской машине CSR, то django занимается этим сам, на сервере SSR.
Ну и что? Какая разница, кто что рендерит. Главное рендерит.
Разница всё-таки есть. И она особенно заметна для поисковиков. Поисковой робот, краулер, зайдёт на страницу отрендеренной django и сможет увидеть все ссылки и контент сайта. Но если всё тот же краулер зайдёт на страницу отрендеренной React-ом, он ничего не увидит, посчитает страницу либо бесполезной, либо не доделанной и уйдёт.
То есть, для SEO это имеет критическое значение.
Шеврон Хоть гугл уже и может самостоятельно рендерить страницы с JS, я бы на это не расчитывал, и положился бы на статический контент.
И поэтому у меня есть это meta-* элементы. Они отрисовываются django и доступны поисковикам. Реакт эти элементы подхватывает и обрабатывает.
Закончили с HTML перейдём к JS и React коду.

Работа с React элементами

Создадим необходимые элементы и файлы. Нам их потребуется 4:
Начнём с самого сложного элемента нашего сайта, header.js, это его шапка. Код достаточно объёмен, но по сути своей он берёт отрендереную django информацию и формирует из неё либо горизонтальные (Десктопная версия), либо вертикальные (Мобильная версия) кнопки. Вот и всё.
Ну и если это мобильная версия, он эти кнопки оборачивает в боковое меню. Потому что мне оно больше всего нравится. А вот и код:
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import Button from '@mui/material/Button';
import ButtonGroup from '@mui/material/ButtonGroup';
import MenuAppBar from './MobileAppBar'
import LangSwitch from './LangSwitch';
import Drawer from '@mui/material/Drawer';
import Box from '@mui/material/Box';
import AppBar from '@mui/material/AppBar';
import LinkIcon from '@mui/icons-material/Link';
import QuestionMarkIcon from '@mui/icons-material/QuestionMark';
import LoginIcon from '@mui/icons-material/Login';

export default function Header() {
  const header_buttons = document.getElementById('meta-header')
  const btns = []
  for ( const btn of header_buttons.children){
    const ref = btn.firstElementChild.getAttribute('href')
    if (btn.dataset.type == 'inner-link'){
      btns.push(<Button color='primary'><a href={ref}>{btn.innerText}</a><LinkIcon className='mb-2' fontSize='small' /></Button>)
    }
    else if (btn.dataset.type == 'tutorial' ){
      btns.push(<Button color='primary'><a href={ref}>{btn.innerText}</a><QuestionMarkIcon className='mb-2'  fontSize='small'/></Button>)
    }
    else{
      btns.push(<Button color='primary'><a href={ref}>{btn.innerText}</a><LoginIcon className='mb-2' fontSize='small' /></Button>)
    }
  }

  if (IS_MOBILE){
    const [open, setOpen] = React.useState(false);
    const toggleSideMenu = (newOpen) => () => {
        setOpen(newOpen)
    }

    return (
      <div>
        <MenuAppBar toggleSideMenu={toggleSideMenu} ></MenuAppBar>
        <Drawer open={open} onClose={toggleSideMenu(false)}>
          <Box className="flex flex-col justify-between h-full min-w-52">
            <ButtonGroup orientation='vertical'  color='primary' variant="text" aria-label="Basic button group">
                {btns}
            </ButtonGroup>
            <Box className="flex felx-row justify-between">
                <LangSwitch></LangSwitch>
            </Box>
          </Box>
        </Drawer>
      </div>
    );
  }else{
    return (
      <AppBar position="fixed" sx={{bgcolor: "white", paddingBottom: "10px", paddingTop: "10px" }}>
        <ButtonGroup  color='primary' className='flex flex-grow justify-center' variant="text" aria-label="Basic button group">
          {btns}
        </ButtonGroup>
        <Box  className="fixed top-0 right-0 flex felx-row justify-between">
            <LangSwitch></LangSwitch>
        </Box>
      </AppBar>
    );
  }
}

const container1 = document.getElementById('header');
const root1 = createRoot(container1);
root1.render(<Header></Header>);
В этом компоненте мы используем два других, это LangSwitch и MenuAppBar.
В MobileAppBar.js:
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Box from '@mui/material/Box';
import Toolbar from '@mui/material/Toolbar';
import IconButton from '@mui/material/IconButton';
import MenuIcon from '@mui/icons-material/Menu';

export default function MenuAppBar({ toggleSideMenu }) {
    
    return (
        <Box sx={{ flexGrow: 1 }}>
            <AppBar sx={{bgcolor: "white"}}  position="fixed">
                <Toolbar color="error">
                    <IconButton
                        size="large"
                        edge="start"
                        color="black"
                        aria-label="menu"
                        sx={{ mr: 2 }}
                        onClick={toggleSideMenu(true)}
                    >
                        <MenuIcon />
                    </IconButton>
                </Toolbar>
            </AppBar>
        </Box>
    );
}
В LangSwitch.js:
import * as React from 'react';
import { styled } from '@mui/material/styles';
import FormGroup from '@mui/material/FormGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import Switch from '@mui/material/Switch';

const MaterialUISwitch = styled(Switch)(({ theme }) => ({
  width: 62,
  height: 34,
  padding: 7,
  '& .MuiSwitch-switchBase': {
    margin: 1,
    padding: 0,
    transform: 'translateX(6px)',
    '&.Mui-checked': {
      color: '#fff',
      transform: 'translateX(22px)',
      '& .MuiSwitch-thumb:before': {
        backgroundImage: `url('data:image/svg+xml,<svg width="512" height="512" viewBox="0 0 512 512" style="color:%231C2033" xmlns="http://www.w3.org/2000/svg" class="h-full w-full"><rect width="512" height="512" x="0" y="0" rx="30" fill="transparent" stroke="transparent" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><svg width="19px" height="19px" viewBox="0 0 512 512" fill="%231C2033" x="246.5" y="246.5" role="img" style="display:inline-block;vertical-align:middle" xmlns="http://www.w3.org/2000/svg"><g fill="%231C2033"><mask id="circleFlagsUm0"><circle cx="256" cy="256" r="256" fill="%23fff"/></mask><g mask="url(%23circleFlagsUm0)"><path fill="%23eee" d="M256 0h256v64l-32 32l32 32v64l-32 32l32 32v64l-32 32l32 32v64l-256 32L0 448v-64l32-32l-32-32v-64z"/><path fill="%23d80027" d="M224 64h288v64H224Zm0 128h288v64H256ZM0 320h512v64H0Zm0 128h512v64H0Z"/><path fill="%230052b4" d="M0 0h256v256H0Z"/><path fill="%23eee" d="m187 243l57-41h-70l57 41l-22-67zm-81 0l57-41H93l57 41l-22-67zm-81 0l57-41H12l57 41l-22-67zm162-81l57-41h-70l57 41l-22-67zm-81 0l57-41H93l57 41l-22-67zm-81 0l57-41H12l57 41l-22-67Zm162-82l57-41h-70l57 41l-22-67Zm-81 0l57-41H93l57 41l-22-67zm-81 0l57-41H12l57 41l-22-67Z"/></g></g></svg></svg>')`,
      },
      '& + .MuiSwitch-track': {
        opacity: 1,
        backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
      },
    },
  },
  '& .MuiSwitch-thumb': {
    backgroundColor: theme.palette.mode === 'dark' ? '#003892' : '#001e3c',
    width: 32,
    height: 32,
    '&::before': {
      content: "''",
      position: 'absolute',
      width: '100%',
      height: '100%',
      left: 0,
      top: 0,
      backgroundRepeat: 'no-repeat',
      backgroundPosition: 'center',
      backgroundImage: `url('data:image/svg+xml,<svg width="512" height="512" viewBox="0 0 512 512" style="color:%231C2033" xmlns="http://www.w3.org/2000/svg" class="h-full w-full"><rect width="512" height="512" x="0" y="0" rx="30" fill="transparent" stroke="transparent" stroke-width="0" stroke-opacity="100%" paint-order="stroke"></rect><svg width="19px" height="19px" viewBox="0 0 512 512" fill="%231C2033" x="246.5" y="246.5" role="img" style="display:inline-block;vertical-align:middle" xmlns="http://www.w3.org/2000/svg"><g fill="%231C2033"><mask id="circleFlagsRu0"><circle cx="256" cy="256" r="256" fill="%23fff"/></mask><g mask="url(%23circleFlagsRu0)"><path fill="%230052b4" d="M512 170v172l-256 32L0 342V170l256-32z"/><path fill="%23eee" d="M512 0v170H0V0Z"/><path fill="%23d80027" d="M512 342v170H0V342Z"/></g></g></svg></svg>')`,
    },
  },
  '& .MuiSwitch-track': {
    opacity: 1,
    backgroundColor: theme.palette.mode === 'dark' ? '#8796A5' : '#aab4be',
    borderRadius: 20 / 2,
  },
}));

export default function LangSwitch() {
  return (
    <FormGroup>
      <FormControlLabel
        control={<MaterialUISwitch sx={{ m: 1 }} defaultChecked />}
      />
    </FormGroup>
  );
}
Компонент LangSwitch, не такой уж и большой. Большую часть пространства занимает настройка SVG изображений. Сейчас он, правда, не рабочий, то есть языки не переключает. Но это потому что мы ещё не настроили django для этого. Это будет в другой раз. Ну а пока имеем просто рабочий переключатель.
Осталось лишь рассмотреть компонент Footer в Footer.js:
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import Link from '@mui/material/Link'

export default function Footer() {
    const ref_blk = document.getElementById('footer-in')
    const ref = ref_blk.getAttribute('href')
    const text_in = ref_blk.innerText 

    return (
        <Link href={ref} underline="hover">{text_in}</Link>
    );
}

const container = document.getElementById('ref_to_place');
const root = createRoot(container);
root.render(<Footer></Footer>);
Изначально, я планировал добавить туда много-много ссылок, но мне стало лень, да и зачем там ссылки? Только лишняя нагрузка на восприятие пользователя. Поэтому оставил лишь одну ссылку на себя любимого)
Ну и конечно не забываем подключить наши компоненты Header и Footer в index.js:
import Header from './components/Header';
import Footer from './components/Footer';

Верстка главной страницы и её компонентов

Процесс работы пользователя с приложением

Итак, мы переходим к самой тяжёлой части этой статьи. По крайней мере, она самая большая. Я даже думал разделить эту статью, но не сделал этого по причине потери целостности. Как вообще будет выглядеть процесс работы пользователя с приложением?

Django-шаблон приложения, app.html

А теперь к приложению и коду. Давай немного изменим шаблон app.html чтобы можно было легко с ним работать из реакта.
{% extends 'Frontend/base.html' %}
{% load static %}

{% block head %}
    <title>SearchResultParser - main</title>
    <meta name="description" content="">
    <link rel="canonical" href="http://localhost:8000/">
{% endblock %}

{% block header %}
    <div data-type="inner-link"><a href="{% url 'about' %}">about</a></div>
    <div data-type="inner-link"><a href="{% url 'contacts' %}">contacts</a></div>
    <div data-type="inner-link"><a href="{% url 'articles' %}">articles</a></div>
    <div data-type="tutorial"><a href="#">tutorial</a></div>
{% endblock %}

{% block main %}
<div id="app" class="flex flex-col h-full justify-center max-w-screen-md ml-auto mr-auto">
        {# Initially takeover by React #}
        <div id="app_settings" class="flex-grow-0 basis-10  content-center p-2"></div>
        <div id="app_body"  class="flex flex-grow">
            <div id="meata-engines">
                <div data-src="{% static '/img/Google.png' %}"></div>
                <div data-src="{% static '/img/Yahoo.png' %}"></div>
                <div data-src="{% static '/img/Bing.png' %}"></div>
                <div data-src="{% static '/img/DuckDuckGo.png' %}"></div>
                <div data-src="{% static '/img/Baidu.png' %}"></div>
                <div data-src="{% static '/img/Yandex.png' %}"></div>
                <div data-src="{% static '/img/Aol.png' %}"></div>
                <div data-src="{% static '/img/StackOverflow.png' %}"></div>
                <div data-src="{% static '/img/GitHub.png' %}"></div>
                <div data-src="{% static '/img/Ask.png' %}"></div>
                <div data-src="{% static '/img/YouTube.png' %}"></div>
                <div data-src="{% static '/img/MyAnimeList.png' %}"></div>
                <div data-src="{% static '/img/GoogleScholar.png' %}"></div>
                <div data-src="{% static '/img/GoogleNews.png' %}"></div>
                <div data-src="{% static '/img/Coursera.png' %}"></div>
            </div>
            <div id="engines" class="flex-grow-0  p-2 border-r">
                <h2 class="">Search Engines</h2>
                <hr>
                <div id="engines_list" class="flex gap-1 flex-col p-2">
                </div>
            </div>
            <div id="queries" class="flex-grow text-left  p-2">
                <h2>Queries</h2>
                <hr>
                <div id="queries_list"class="flex gap-1 flex-col p-2">
                </div>
            </div>
        </div>
        {# Initially takeover by React #}
        <hr>
        <div  class="flex justify-between flex-grow-0 basis-10 items-center">
            <div id="meta-app_utills" class="hidden">
                {# Saved presets #}
                <div id="utill_saved"></div>
                {# Popular presets #}
                <div id="utill_popular"></div>
            </div>
            <div id="app_utils" class="p-2"></div>
            <div id="app_actions" class="p-2"></div>
        </div>
    </div>
{% endblock %}

{% block scripts %}
{% endblock %}
Из шаблона можно заметить что моё приложение разбито на несколько независимых частей. Это настройки(id=”app_settings”), таблица запросов и движков(id=”engines” + id=”queries”), утилиты(id=”app_utils”) и действия (id=”app_actions”).
Хочу отметить блок meta-engines. Здесь я написал вручную все движки которые собираюсь парсить, но в будущем этот блок будет заполняться django (джангом?). Просто в будущем я возможно захочу добавить другие движки или убрать старые и лучше делать это на сервере.

React компоненты приложения

Приложение разбито на 4 части + ещё два компонента:
Создай их в src/components и перейдём к их разбору.
AppSettings.js
AppUtils.js
AppActions.js
AppQueries.js
Waiter.js
Msg.js
Это просто группа переключателей с чекбоксами для настройки работы парсера. Нужно отметить, что выбранные данные сохраняются в так называемом атрибуте data. Дабы дальше можно было легко их достать из другого приложения, AppActions.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { IconButton, Box } from '@mui/material';
import SettingsApplicationsIcon from '@mui/icons-material/SettingsApplications';
import CloseIcon from '@mui/icons-material/Close';
import Paper from '@mui/material/Paper';
import Radio from '@mui/material/Radio';
import RadioGroup from '@mui/material/RadioGroup';
import FormControlLabel from '@mui/material/FormControlLabel';
import FormLabel from '@mui/material/FormLabel';
import FormGroup from '@mui/material/FormGroup';
import Checkbox from '@mui/material/Checkbox';

// To render an actual options to choose
function SettingsContent(){
    const [exportType, setExport] = React.useState('')
    const [title, setTitle] = React.useState(false)
    const [description, setDesr] = React.useState(false)
    const [url, setUrl] = React.useState(false)
    const [verbose, setVerb] = React.useState(false)

    return (
        <Box className="w-full flex flex-nowrap flex-row justify-between p-2">
            <Box>
                <FormLabel data-export='exel' id="export-as">Export as:</FormLabel>
                <RadioGroup
                    aria-labelledby="export-as"
                    defaultValue="exel"
                    name="export-as"
                >
                    <FormControlLabel value="exel" control={<Radio onChange={(ev,checked)=>{ 
                        var root = document.getElementById('export-as')
                        root.dataset.export = ev.target.value
                        setExport(ev.target.value)
                    }}/>} label="exel" />
                    <FormControlLabel value="csv" control={<Radio  onChange={(ev,checked)=>{ 
                        var root = document.getElementById('export-as')
                        root.dataset.export = ev.target.value
                        setExport(ev.target.value)
                    }}/>} label="csv" />
                    <FormControlLabel value="json" control={<Radio  onChange={(ev,checked)=>{ 
                        var root = document.getElementById('export-as')
                        root.dataset.export = ev.target.value
                        setExport(ev.target.value)
                    }}/>} label="json" />
                </RadioGroup>
            </Box>
            <Box>
                <FormGroup>
                    <FormLabel id="save">Save:</FormLabel>
                    <FormControlLabel id="dataTitle" value="title" control={<Checkbox checked={title} onChange={(ev, checked)=>{
                        setTitle(checked)
                    }} />} label="title" />
                    <FormControlLabel id="dataUrl" value="url" control={<Checkbox checked={url} onChange={(ev, checked)=>{
                        setUrl(checked)
                    }} />} label="url" />
                    <FormControlLabel id="dataDescription" value="description" control={<Checkbox checked={description} onChange={(ev, checked)=>{
                        setDesr(checked)
                    }} />} label="description" />
                </FormGroup>
            </Box>
            <Box>
                <FormGroup>
                    <FormLabel id="other">Other:</FormLabel>
                    <FormControlLabel value="verbose" control={<Checkbox onChange={(ev, checked)=>{
                        setVerb(checked)
                        const console = document.getElementById('console-button')
                        console.classList.toggle('hidden')
                    }} />} label="verbose" />
                </FormGroup>
            </Box>
        </Box>
    )
}
export default function AppSettings(){
    const [isSettings, setSettings] = React.useState(false);

    // To hide and show available choises for user
    const ToggleSettings = () => {
        if (isSettings == true){
            setSettings(false);
            const set_cont = document.getElementById('settings_content')
            set_cont.classList.add('hidden')
        }
        else{
            const set_cont = document.getElementById('settings_content')
            set_cont.classList.remove('hidden')
            setSettings(true)
        }
    }

    return (
        <div>
            <Paper elevation={2} className="z-10">
                <div id="settings_content" className='hidden'>
                    <SettingsContent />
                </div>
            </Paper>
            {/* Icon button is gonna be changed */}
            <IconButton onClick={ToggleSettings} className='w-fit'>
                { isSettings    ? <CloseIcon  className=" border-2 rounded-md"/>
                                : <SettingsApplicationsIcon className=" border-2 rounded-md"/>}
            </IconButton>
        </div>
    )
}

const settings_container = document.getElementById('app_settings');
const settings_root = createRoot(settings_container);
settings_root.render(<AppSettings></AppSettings>);
Данный компонент реализован при помощи связки, кнопканижний слайдермодальное окно. И на каждом из этапов будут делаться запросы на сервер, чтобы получить необходимые пресеты.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { IconButton, Box } from '@mui/material';
import TrendingUpIcon from '@mui/icons-material/TrendingUp';
import FeaturedPlayListIcon from '@mui/icons-material/FeaturedPlayList';
import SavedSearchIcon from '@mui/icons-material/SavedSearch';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import UploadIcon from '@mui/icons-material/Upload';
import InfoIcon from '@mui/icons-material/Info';
import Drawer from '@mui/material/Drawer';
import Typography from '@mui/material/Typography';
import Modal from '@mui/material/Modal';
import Button from '@mui/material/Button';
import List from '@mui/material/List';
import ListItemText from '@mui/material/ListItemText';
import {Wait, StopWait} from './Waiter';
import DownloadIcon from '@mui/icons-material/Download';
import axios from "axios";

// To get info about preset
function onUtilsContentInfo(rec){
    // Make a GET request for a specific preset
    var uid = 0
    Wait()
    axios.get(`/api/presets/${uid}`)
    .then(response => {
        // Here recieve a file 
        StopWait('Successfully obtain info about preset.', 'success')
    })
    .catch(error => {
        StopWait('Cant obtain info about preset. ' + error , 'error')
    });
    // Apply a recieved data to a poped up modal window
    const utils_modal = document.getElementById('utils-modal')
    const utils_modal_root = createRoot(utils_modal);
    utils_modal_root.render( 
        <div className="flex flex-col gap-3  min-w-52">
            <Typography id="modal-modal-title" variant="h6" component="h3">
            Preset info
            </Typography>
            <hr></hr>
            <List sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}>
                <ListItemText primary="Export as:" />
                <List component="div" disablePadding>
                    <ListItemText sx={{ pl: 4 }} primary="JSON" />
                </List>
                <ListItemText primary="Save:" />
                <List component="div" disablePadding>
                    <ListItemText sx={{ pl: 4 }} primary="title" />
                    <ListItemText sx={{ pl: 4 }} primary="description" />
                    <ListItemText sx={{ pl: 4 }} primary="url" />
                </List>
                <ListItemText primary="Other:" />
                <List component="div" disablePadding>
                    <ListItemText sx={{ pl: 4 }} primary="verbose" />
                    <ListItemText sx={{ pl: 4 }} primary="send to email" />
                </List>
                <hr></hr>
                <ListItemText primary="Search engines:" />
                <List component="div" disablePadding>
                    <ListItemText sx={{ pl: 4 }} primary="Google" />
                    <List component="div" disablePadding>
                        <ListItemText sx={{ pl: 6 }} primary="'how to find a cats'" />
                        <ListItemText sx={{ pl: 6 }} primary="'how to find'" />
                    </List>
                    <ListItemText sx={{ pl: 4 }} primary="Yandex" />
                    <List component="div" disablePadding>
                        <ListItemText sx={{ pl: 6 }} primary="'how to get better'" />
                    </List>
                </List>
            </List>
        </div>
    );
}

// To apply preset for current user
function onUtilsContentUpload(rec){
    var uid = 0
    Wait()
    axios.get(`/api/presets/${uid}`)
    .then(response => {
        // Here recieve a file 
        StopWait('Successfully apply preset for user.', 'success')
    })
    .catch(error => {
        StopWait('Cant apply preset for user. ' + error , 'error')
    });
    // Make a GET request for a specific preset
    // Apply recieved preset for user 
}

// To delete preset of current user
function onUtilsContentDelete(rec){
    var uid = 0
    // Make a DEL request to remove a specific preset
    axios.delete(`/api/presets/${uid}`)
    .then(data => {
        // Here recieve a file 
        StopWait('Successfully delete a preset.', 'success')
        Wait()
        axios.get('/api/presets/')
        .then(response => {
            // Here recieve a file 
            StopWait('Successfully refreshed all users presets.', 'success')
        })
        .catch(error => {
            StopWait('Cant refresh user presets. ' + error , 'error')
        });
    })
    .catch(error => {
        StopWait('Cant delete a data. ' + error , 'error')
    });
    
    const utils_modal = document.getElementById('utils-modal')
    const utils_modal_root = createRoot(utils_modal);
    utils_modal_root.render( 
        <div className="flex flex-col gap-3  min-w-52">
            <Typography id="modal-modal-title" variant="h6" component="h3">
            Deleting preset
            </Typography>
            <hr></hr>
            <div className="p-1">
            Are you sure ?
            </div>
            <div className='flex flex-row justify-between items-center'>
                <Button className='p-2' variant="outlined" onClick={()=>{
                    // DEL request
                    rec.setModal(false)
                }}>Yes</Button>
                <Button className='p-2' variant="outlined" onClick={()=>{
                    rec.setModal(false)
                }}>No</Button>
            </div>
        </div>
    );
}

// Wait till modal is present and ready to be interactable
// Then launch callback with args
function waitTillModalIsUp(func, args){
    const observer = new MutationObserver((mutationList, observer) => {
        for (const mutation of mutationList) {
            if (mutation.addedNodes.length > 0){ 
                const utils_modal = mutation.target.querySelector("#utils-modal");
                if(utils_modal){
                    func(args)
                    observer.disconnect()
                }
            }
        }
    });
    observer.observe(document, {subtree: true, childList: true});
}

// Popular presets, I will create them by myself
function TrendingContent( props ){
    const [isModal, setModal] = React.useState(false);
    // Make a POST request to collect most popular presets
    Wait()
    axios.get('/api/popular-presets/')
    .then(response => {
        // Here recieve a file 
        StopWait('Successfully obtain popular presets.', 'success')
    })
    .catch(error => {
        StopWait('Cant get popular presets. ' + error , 'error')
    });
    const rec = {'setModal': setModal}
    return (
        <Box className="w-full min-h-32 max-h-96 flex flex-nowrap flex-col justify-between p-2">
            <Modal
                open={isModal}
                onClose={()=>{setModal(false)}}
                >
                <Box id='utils-modal' className="absolute top-1/2 left-1/2 -translate-x-2/4 -translate-y-2/4 shadow-md p-4 bg-white">
                </Box>
            </Modal>
            <Typography variant="h5" component='h2' gutterBottom className="p-2">Trending presets</Typography>
            <hr className='w-full'></hr>
            <Box className='w-full h-full'>
                {/* Template to be rendered by Django*/}
                <div>
                    <div className='flex justify-between items-center'>
                        <div className='flex flex-row gap-2 '>
                            <div>export_as_exel_save-title-description-verbose-show-final-result</div>
                        </div>
                        <div>
                            <IconButton onClick={()=>{setModal(true); waitTillModalIsUp(onUtilsContentInfo,rec)}}  className='w-fit'><InfoIcon/></IconButton>
                            <IconButton onClick={()=>onUtilsContentUpload(rec)} className='w-fit'><UploadIcon/></IconButton>
                        </div>
                    </div>
                    <hr className='w-full'></hr>
                </div>
            </Box>
        </Box>
    )
}

// Saved presets of user
function OwnSavesContent(props){
    const [isModal, setModal] = React.useState(false);
    // Make GET firts request to obtain all presets by user
    Wait()
    axios.get('/api/presets/')
    .then(response => {
        // Here recieve a file 
        StopWait('Successfully obtain all users presets.', 'success')
    })
    .catch(error => {
        StopWait('Cant get presets. ' + error , 'error')
    });
    const rec = {'setModal': setModal}
    return (
        <Box className="w-full min-h-32  max-h-96 flex flex-nowrap flex-col justify-between p-2">
            <Modal
                open={isModal}
                onClose={()=>{setModal(false)}}
                >
                <Box id='utils-modal' className="absolute top-1/2 left-1/2 -translate-x-2/4 -translate-y-2/4 shadow-md p-4 bg-white">
                </Box>
            </Modal>
            <Typography variant="h5" component='h2' gutterBottom className="p-2">Your presets</Typography>
            <hr className='w-full'></hr>
            <Box className='w-full h-full'>
            {/* Template to be rendered by Django */}
            <div>
                <div className='flex justify-between  items-center'>
                    <div className='flex flex-row gap-2 '>
                        <div>01.01.2000</div>
                        <div>Save name</div>
                    </div>
                    <div>
                        <IconButton onClick={()=>{setModal(true); waitTillModalIsUp(onUtilsContentInfo,rec)}}  className='w-fit'><InfoIcon/></IconButton>
                        <IconButton onClick={()=>{onUtilsContentUpload(rec); }} className='w-fit'><UploadIcon/></IconButton>
                        <IconButton onClick={()=>{setModal(true); waitTillModalIsUp(onUtilsContentDelete,rec)}} className='w-fit'><DeleteForeverIcon/></IconButton>
                    </div>
                </div>
                <hr className='w-full'></hr>
            </div>
            </Box>
        </Box>
    )
}

// Console for curious ones
function ConsoleContent(props){
    /* Make requests here */
    return (
        <Box className="w-full min-h-32  max-h-96 flex flex-nowrap flex-col justify-between p-2">
            <Typography variant="h5" component='h2' gutterBottom className="p-2">Console</Typography>
            <hr className='w-full'></hr>
            <Box className='w-full h-full '>
                {/* Paste response here */}
                <Box>user@localhost $: npm run test</Box>
            </Box>
        </Box>
    )
}

export default function AppUtils(){
    const [isTrending, setTrending] = React.useState(false);
    const [isOwnSaves, setOwnSaves] = React.useState(false);
    const [isConsole, setConsole] = React.useState(false);

    // To show or hide sticked to bottom side of screen the "Drawer"
    const ToggleTrending = (value) => (event) => {
        setTrending(value);
    }
    const ToggleOwnSaves = (value) => (event) => {
        setOwnSaves(value);
    }
    const ToggleConsole = (value) => (event) => {
        setConsole(value);
    }

    return (
        <Box className="flex flex-col">
            <Box className="flex gap-1">
                <IconButton onClick={ToggleTrending(true)} className='w-fit  border'><TrendingUpIcon className=" border-2 rounded-md"/></IconButton>
                <IconButton onClick={ToggleOwnSaves(true)} className='w-fit'><SavedSearchIcon className=" border-2 rounded-md"/></IconButton>
                <div id="console-button" className='hidden'><IconButton onClick={ToggleConsole(true)} className='w-fit'><FeaturedPlayListIcon  className=" border-2 rounded-md"/></IconButton></div>
                <div id="results-button" className='hidden'><IconButton id="results-button-ref" href='#' onClick={()=>{}} className='w-fit'><DownloadIcon color='warning' className=" border-2 rounded-md"></DownloadIcon></IconButton></div>
            </Box>
            <Drawer open={isTrending} anchor='bottom' onClose={ToggleTrending(false)}>
                <TrendingContent isActive={isTrending}/>
            </Drawer>
            <Drawer open={isOwnSaves} anchor='bottom' onClose={ToggleOwnSaves(false)}>
                <OwnSavesContent isActive={isOwnSaves}/>
            </Drawer>
            <Drawer open={isConsole} anchor='bottom' onClose={ToggleConsole(false)}>
                <ConsoleContent isActive={isConsole}/>
            </Drawer>
        </Box>
    )
}

const utils_container = document.getElementById('app_utils');
const utils_root = createRoot(utils_container);
utils_root.render(<AppUtils></AppUtils>);
Состоит только из двух кнопок, сохранения пресета и запуск парсинга. Здесь же, мы собираем данные из других приложений, здесь же проверяем их на правильность и здесь же отправляем их на сервер.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import { IconButton, Box } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import NotStartedIcon from '@mui/icons-material/NotStarted';
import Typography from '@mui/material/Typography';
import Modal from '@mui/material/Modal';
import Button from '@mui/material/Button';
import TextField from '@mui/material/TextField';
import { Wait, StopWait, Msg } from './Waiter';
import axios from "axios";

// Wait till modal is present and ready to be interactable
// Then launch callback with args
function waitTillModalIsUp(func, args){
    const observer = new MutationObserver((mutationList, observer) => {
        for (const mutation of mutationList) {
            if (mutation.addedNodes.length > 0){ 
                const utils_modal = mutation.target.querySelector("#utils-modal");
                if(utils_modal){
                    func(args)
                    observer.disconnect()
                }
            }
        }
    });
    observer.observe(document, {subtree: true, childList: true});
}

// A set of checks to be checked if user make everything is in a right way
function checkIfValidPreset(){
    // Collect data
    var exportAs = document.getElementById('export-as').dataset.export
    var isTitle = document.getElementById('dataTitle').querySelector('input').checked
    var isUrl = document.getElementById('dataUrl').querySelector('input').checked
    var isDescription = document.getElementById('dataDescription').querySelector('input').checked
    if (!isTitle && !isUrl && !isDescription){
        Msg('You must "check" one of these: title, url, description','error')
        return false
    }
    var queries = []
    var queries_raw = document.querySelectorAll('.query')
    queries_raw.forEach((que) => {
        var engine = que.dataset.engine
        var query = que.querySelector('input').value
        queries.push({
            engine: engine,
            query: query
        })
    })
    if (queries.length >= 1){
        var isValid = true
        queries.forEach((que) => {
            if (que.query == ""){
                Msg('Query string cant be empty.','error')
                isValid = false
            }
        })
        return isValid
    }
    else{
        Msg('At least 1 query must be.','error')
        return false
    }
}

// Make a PUT request to save preset
function SaveRequest(req){
    // Collect data
    var exportAs = document.getElementById('export-as').dataset.export
    var isTitle = document.getElementById('dataTitle').querySelector('input').checked
    var isUrl = document.getElementById('dataUrl').querySelector('input').checked
    var isDescription = document.getElementById('dataDescription').querySelector('input').checked
    var queries = []
    var queries_raw = document.querySelectorAll('.query')
    queries_raw.forEach((que) => {
        var engine = que.dataset.engine
        var query = que.querySelector('input').value
        queries.push({
            engine: engine,
            query: query
        })
    })
    if (checkIfValidPreset()){
        const actions_modal = document.getElementById('utils-modal')
        const actions_modal_root = createRoot(actions_modal);
        actions_modal_root.render( 
            <div className="flex flex-col gap-3  min-w-72 ">
                <Typography variant="h6" component="h3">
                    Saving preset
                </Typography>
                <hr></hr>
                <Box className="flex flex-row gap-4 items-center justify-between">
                    <TextField id="preset-name" label="Preset name" variant="outlined" />
                    <Button onClick={()=>{
                        var preset_name = document.getElementById('preset-name').value
                        if (preset_name.length >= 3){
                            Wait()
                            var form_data = new FormData();
                            form_data.append("exportAs", exportAs)
                            form_data.append("isTitle", isTitle)
                            form_data.append("isUrl", isUrl)
                            form_data.append("isDescription", isDescription)
                            form_data.append("queries", JSON.stringify(queries))
                            axios.put(`/api/presets/${preset_name}`, form_data)
                            .then(data => {
                                // Here recieve a file 
                                StopWait('Successfully saved preset', 'success')
                            })
                            .catch(error => {
                                StopWait('Cant save a preset. ' + error , 'error')
                            });
                        }
                        else{
                            Msg('At least 3 character long.', 'error')
                            req.setModal(false)
                        }
                    }} variant='text'>Save</Button>
                </Box>
            </div>
        )
    }
    
}

//Make a POST request to server to get results
function StartParsingRequest(req){
    // Collect data
    var exportAs = document.getElementById('export-as').dataset.export
    var isTitle = document.getElementById('dataTitle').querySelector('input').checked
    var isUrl = document.getElementById('dataUrl').querySelector('input').checked
    var isDescription = document.getElementById('dataDescription').querySelector('input').checked
    var queries = []
    var queries_raw = document.querySelectorAll('.query')
    queries_raw.forEach((que) => {
        var engine = que.dataset.engine
        var query = que.querySelector('input').value
        queries.push({
            engine: engine,
            query: query
        })
    })
    if (checkIfValidPreset()){
        Wait()
        var form_data = new FormData();
        form_data.append("exportAs", exportAs)
        form_data.append("isTitle", isTitle)
        form_data.append("isUrl", isUrl)
        form_data.append("isDescription", isDescription)
        form_data.append("queries", JSON.stringify(queries))
        axios.post('/api/parse/', form_data)
        .then(data => {
            // Here recieve a file 
            StopWait('Successfully parsed a data.', 'success')
        })
        .catch(error => {
            StopWait('Cant parse data. ' + error , 'error')
        });
    }
}

export default function AppActions(){
    const [isModal, setModal] = React.useState(false);
    const req = {'setModal': setModal}
    return (
        <Box className="flex gap-1">
            <IconButton id='onSaveRequest'  onClick={()=>{setModal(true); waitTillModalIsUp(SaveRequest, req)}} className='w-fit'><SaveIcon className=" border-2 rounded-md"/></IconButton>
            <Modal
                open={isModal}
                onClose={()=>{setModal(false)}}
                >
                <Box id='utils-modal' className="absolute top-1/2 left-1/2 -translate-x-2/4 -translate-y-2/4 shadow-md p-4 bg-white">
                </Box>
            </Modal>
            <IconButton id='onStartParsing'  onClick={()=>{StartParsingRequest(req)}} className='w-fit'><NotStartedIcon className=" border-2 rounded-md"/></IconButton>
        </Box>
    )
}

const actions_container = document.getElementById('app_actions');
const actions_root = createRoot(actions_container);
actions_root.render(<AppActions></AppActions>);
Получение доступных к парсингу движков и создание таблицы движок-запрос. Изначально я планировал его сделать таким образом, чтобы пользователь добавлял движок, потом добавлял к нему столько запросов сколько бы ему хотелось. Но потом я понял что можно это всё реализовывать гораздо проще и через одну кнопку.
И ещё, тебе потребуется иконки для всех движков. Нужно будет скачать этот архив и распаковать его в папке Frontend/static/img
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import AddBoxIcon from '@mui/icons-material/AddBox';
import { IconButton, Box } from '@mui/material';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import TextField from '@mui/material/TextField';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemButton from '@mui/material/ListItemButton';
import {Wait, StopWait} from './Waiter';
import Popover from '@mui/material/Popover';
import Divider from '@mui/material/Divider';

// Remove a line of query from UI
function onDeleteQuery(event){
    var id = event.currentTarget.dataset.id
    var elsToDelete = document.querySelectorAll('#'+id)
    elsToDelete.forEach((el)=>{
        el.remove()
    })
}

// Compiling and inserting a choosen engine with text field
function onAddQuery(event){

    var uid = 'uid_'+Math.random().toString(16).slice(2)
    // Inserting engine before + button and showing up all activity buttons
    var child = event.currentTarget.children[0].cloneNode(true)
    child.id = uid
    child.querySelectorAll('#toRemoveEngineStuff').forEach( (el) => {
        el.classList.remove('hidden')
    })

    var deleteButton = child.querySelector('#onDeleteQuery')
    deleteButton.dataset.id = uid
    deleteButton.addEventListener('click', onDeleteQuery)

    var parent = document.getElementById('engines_list')
    parent.insertBefore(child, parent.lastChild);
    var engine_name = event.currentTarget.dataset.engine_name
    // Activate 'save' and 'parse' buttons

    // Inserting query text field
    var querCont = document.getElementById('queries_list');
    var query = document.createElement("div");
    query.id = uid
    querCont.insertBefore(query, querCont.lastChild);
    const querRoot = createRoot(query);

    querRoot.render(
        <div className='flex flex-col gap-2'>
            <div className='flex flex-row items-center'>
                <TextField required data-engine={engine_name} id="outlined-basic" label="query" variant="outlined"  className="query items-center"/>
            </div>
            <Divider className="w-full self-center" component='div' variant="middle"/>
        </div>
    )
            
}
export default function AppQueries(){
    const [anchorEngine, setAnchoreEngine] = React.useState(null)
    var engine_list = []
    const list = document.getElementById('meata-engines').children
    for (var i = 0; i < list.length; i++){
        // Find name of engine
        var end = list[i].dataset.src.indexOf('.')
        var start = list[i].dataset.src.lastIndexOf('/') + 1
        var name = String(list[i].dataset.src).substring(start, end)
        // Push engines to pop up window, for to be selected later
        engine_list.push(
            <ListItem>
            <ListItemButton data-engine_name={name} onClick={(event)=>{
                onAddQuery(event)
            }}>
                <div className="flex flex-col gap-6">
                    <div className="flex flex-row gap-2 items-center justify-between" >
                        <div className='flex flex-row gap-1 items-center'>
                            <img className='w-4 h-4' src={list[i].dataset.src}></img>
                            <div>{name}</div>
                        </div>
                        <div id='toRemoveEngineStuff' className='hidden flex flex-row gap-1 items-center w-fit'>
                            <IconButton id='onDeleteQuery' className='w-fit'><DeleteForeverIcon/></IconButton>
                        </div>
                    </div>
                    <Divider id='toRemoveEngineStuff' className="hidden w-full self-center" component='div' variant="middle"/>
                </div>
            </ListItemButton>
            </ListItem>
        )
    }
    
    const handleClick = (event, engine_list) => {
        setAnchoreEngine(event.currentTarget);    
    };

    const handleClose = () => {
        setAnchoreEngine(null);
    };

    const open = Boolean(anchorEngine);
    const id = open ? 'simple-popover_addengine' : undefined;
    return (
        <div>
            <Popover
                id={id}
                open={open}
                anchorEl={anchorEngine}
                onClose={handleClose}
                anchorOrigin={{
                    vertical: 'bottom',
                    horizontal: 'left',
                }}
                >
                <List sx={{ width: '100%', maxWidth: 360, bgcolor: 'background.paper' }}>
                    {engine_list}
                </List>
            </Popover>
            <IconButton onClick={(ev)=>handleClick(ev,engine_list)} className='w-fit'><AddBoxIcon/></IconButton>
        </div>
    )
}

const engines_container = document.getElementById('engines_list');
const engines_root = createRoot(engines_container);
engines_root.render(<AppQueries></AppQueries>);
Существует только для того, чтобы показывать пользователю, что сервер сейчас занят работай и нужно немного подождать. Он же управляет отображением сообщений об успехе или неудаче при работе сервера.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import CircularProgress from '@mui/material/CircularProgress';
import Backdrop from '@mui/material/Backdrop';

export function Msg(msg, status){
    const msg_container = document.getElementById('msg');
    msg_container.classList.remove('hidden')
    const msgText_container = document.getElementById('msg-text');
    msgText_container.querySelector('.MuiAlert-message').innerText = msg
}

export function CloseMsg(){
    const msg_container = document.getElementById('msg');
    msg_container.classList.add('hidden')
    const msgText_container = document.getElementById('msg-text');
    msgText_container.querySelector('.MuiAlert-message').innerText = ''
}

// Show up a waiter
export function Wait(){
    const waiter_container = document.getElementById('waiter');
    waiter_container.classList.remove('hidden')
    const header = document.getElementById('header');
    header.style.zIndex = 0
}

// Hide waiter and shop up a status message
export function StopWait(msg, status){
    const waiter_container = document.getElementById('waiter');
    waiter_container.classList.add('hidden')
    const header = document.getElementById('header');
    header.style.zIndex = 1100

    Msg(msg, status)
}

export default function Waiter(){
    return (
        <Backdrop
        sx={{ color: '#fff', zIndex: 2000 }}
        open={true}
        >
            <CircularProgress color='inherit' />
        </Backdrop>
    )
}

const waiter_container = document.getElementById('waiter');
const waiter_root = createRoot(waiter_container);
waiter_root.render(<Waiter></Waiter>)
В этом файле идёт подготовка(отрисовка) определённого блока к тому, чтобы быть заполненным информацией о результатах работы сервера. Управляется через Waiter компонент.
import * as React from 'react';
import { createRoot } from 'react-dom/client';
import Alert from '@mui/material/Alert';
import { CloseMsg } from './Waiter';

export default function Msg(){
    return (
        <Alert
        id="msg-text"
        onClose={CloseMsg}
        severity='error'
        variant="outlined"
        sx={{ width: '100%' }}
        >
        </Alert>
    )
}

const msg_container = document.getElementById('msg');
const msg_root = createRoot(msg_container);
msg_root.render(<Msg/>)
Осталось только подключить все эти компоненты в index.js:
import Header from './components/Header';
import Footer from './components/Footer';
import AppSettings from './components/AppSettings';
import AppUtils from './components/AppUtils';
import AppActions from './components/AppActions';
import AppQueries from './components/AppQueries';
import Msg from './components/Msg';

Другие страницы и разделы сайта.

Такие страницы как, about и contacts я не буду так же подробно освещать. Почему?
Это общие(я бы даже сказал, стандартные) страницы. И в основе своей они будут статичный, там не будет никакого реакта. Они никак не влияют на основной функционал сайта. И просто, смысл показывать что я там написал? Или что важнее, что рассказывать? То какой шрифт я использую, или какие отступы делаю?) Вот.

Выводы и заключение

В этой статье я рассказал и показал как можно сделать фронтенд часть сайта, используя React и Django + MaterialUI, чтобы не изобретать колёса заново. TailwindCSS, чтобы приобрести максимальную гибкость в стилизации элементов страницы (ну ладно, чтобы не лезть в css файлы :)).
Вообще, фронтенд для меня всегда был самой тяжёлой частью в разработке, ну не моё это. Сделать что-то функциональное и работающее это да, это я могу. Но сделать это красивым и стильным, тут я кончаюсь.
Ты наверняка знаешь эту аналогию фронтенда и бэкенда.
PLACEHOLDER
Так вот, у меня наоборот. В любом случае, с самой тяжёлой частью мы покончили и дальше будет только легче. Добавим интерактивный туториал, поддержку нескольких языков, бэкенд в конце концов и аутентификацию пользователей.
Если ты пропустил всё что было выше и хочешь просто готовое-к-использованию решение, то ты можешь скачать его здесь. Это архив с настроенной структурой директорий и готовыми зависимостями. Всё что тебе остаётся сделать, так это скачать, создать виртуальное окружение, установить все необходимые python и npm пакеты.

сердце 0
3 соединённые точки 0