Front: Add user interactions
This commit is contained in:
parent
c5350120e6
commit
07a774aef4
12 changed files with 244 additions and 22 deletions
|
|
@ -74,7 +74,7 @@ docker compose exec fastapi-app mypy app/
|
||||||
- `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
|
- `/api/v1/trainings/` (GET list, GET/PUT/DELETE `{id}/`; PUT replaces the whole training), `/api/v1/exercises/`, `/api/v1/exercises/progress/?name=`, `/api/v1/users/` (POST, GET list with `?role=`, GET/PATCH/DELETE `{id}/`; role is `trainer` or `trainee`, passwords are hashed with scrypt, no login yet), `/api/v1/parser/parse/`, `/api/v1/health/`
|
||||||
|
|
||||||
**Frontend** (`frontend/`):
|
**Frontend** (`frontend/`):
|
||||||
- `index.html`, `exercises.html`, `parse.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/edit-dialog.js` + `js/edit-form.js` (training edit Bootstrap modal)
|
- `index.html`, `exercises.html`, `parse.html`, `users.html` - pages; `js/common.js` (API + DOM helpers), `js/table.js` (DataTables 3 wrapper), one module per page, `js/modal.js` (Bootstrap modal shell), `js/edit-dialog.js` + `js/edit-form.js` (training edit modal), `js/user-dialog.js` (user create/edit modal)
|
||||||
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
|
- `vendor/` - Bootstrap (CSS + JS bundle), Chart.js and DataTables 3 (jQuery-free): the only third-party code
|
||||||
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ services:
|
||||||
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
|
||||||
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.html:ro
|
||||||
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
|
- ./frontend/parse.html:/usr/share/nginx/html/parse.html:ro
|
||||||
|
- ./frontend/users.html:/usr/share/nginx/html/users.html:ro
|
||||||
- ./frontend/css:/usr/share/nginx/html/css:ro
|
- ./frontend/css:/usr/share/nginx/html/css:ro
|
||||||
- ./frontend/js:/usr/share/nginx/html/js:ro
|
- ./frontend/js:/usr/share/nginx/html/js:ro
|
||||||
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
FROM nginx:1.27-alpine
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY index.html exercises.html parse.html /usr/share/nginx/html/
|
COPY index.html exercises.html parse.html users.html /usr/share/nginx/html/
|
||||||
COPY css /usr/share/nginx/html/css
|
COPY css /usr/share/nginx/html/css
|
||||||
COPY js /usr/share/nginx/html/js
|
COPY js /usr/share/nginx/html/js
|
||||||
COPY vendor /usr/share/nginx/html/vendor
|
COPY vendor /usr/share/nginx/html/vendor
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
<li class="nav-item"><a class="nav-link text-white active" href="exercises.html">Упражнения</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
<li class="nav-item"><a class="nav-link text-white active" href="index.html">Тренировки</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,11 @@ export function formatDate(iso) {
|
||||||
return `${day}.${month}.${year}`;
|
return `${day}.${month}.${year}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** "2026-10-23T17:27:00+00:00" -> "23.10.2026 17:27" in the viewer's timezone */
|
||||||
|
export function formatDateTime(iso) {
|
||||||
|
return new Date(iso).toLocaleString('ru-RU', { dateStyle: 'short', timeStyle: 'short' }).replace(',', '');
|
||||||
|
}
|
||||||
|
|
||||||
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
/** Rounded to one decimal without trailing zeros: 22.5 -> "22.5", 20 -> "20" */
|
||||||
export function num(value) {
|
export function num(value) {
|
||||||
return String(Math.round(value * 10) / 10);
|
return String(Math.round(value * 10) / 10);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
// Bootstrap modal with the training edit form
|
// Modal with the training edit form
|
||||||
|
|
||||||
import { alert, api, h } from './common.js';
|
import { alert, api, h } from './common.js';
|
||||||
import { editForm } from './edit-form.js';
|
import { editForm } from './edit-form.js';
|
||||||
|
import { openModal } from './modal.js';
|
||||||
|
|
||||||
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
|
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
|
||||||
export function editButton(trainingId) {
|
export function editButton(trainingId) {
|
||||||
|
|
@ -12,31 +13,15 @@ export function editButton(trainingId) {
|
||||||
|
|
||||||
/** Opens the form for the training; onChange() is called after it was saved or deleted. */
|
/** Opens the form for the training; onChange() is called after it was saved or deleted. */
|
||||||
export async function openEditDialog(trainingId, onChange) {
|
export async function openEditDialog(trainingId, onChange) {
|
||||||
const body = h('div', { class: 'modal-body' }, h('div', { class: 'text-muted' }, 'Загрузка…'));
|
const { body, hide } = openModal('Редактирование тренировки');
|
||||||
const element = h('div', { class: 'modal fade', tabindex: '-1', 'aria-labelledby': 'edit-modal-title', 'aria-hidden': 'true' },
|
|
||||||
h('div', { class: 'modal-dialog modal-lg modal-dialog-scrollable' },
|
|
||||||
h('div', { class: 'modal-content' },
|
|
||||||
h('div', { class: 'modal-header' },
|
|
||||||
h('h2', { class: 'modal-title h5', id: 'edit-modal-title' }, 'Редактирование тренировки'),
|
|
||||||
h('button', { type: 'button', class: 'btn-close', 'data-bs-dismiss': 'modal', 'aria-label': 'Закрыть' })),
|
|
||||||
body)));
|
|
||||||
|
|
||||||
const modal = new bootstrap.Modal(element);
|
|
||||||
element.addEventListener('hidden.bs.modal', () => {
|
|
||||||
modal.dispose();
|
|
||||||
element.remove();
|
|
||||||
});
|
|
||||||
document.body.append(element);
|
|
||||||
modal.show();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [training, exerciseNames] = await Promise.all([
|
const [training, exerciseNames] = await Promise.all([
|
||||||
api(`/trainings/${trainingId}/`),
|
api(`/trainings/${trainingId}/`),
|
||||||
api('/exercises/'),
|
api('/exercises/'),
|
||||||
]);
|
]);
|
||||||
body.replaceChildren(editForm(training, exerciseNames, {
|
body.replaceChildren(editForm(training, exerciseNames, {
|
||||||
onDone: () => { modal.hide(); onChange(); },
|
onDone: () => { hide(); onChange(); },
|
||||||
onCancel: () => modal.hide(),
|
onCancel: hide,
|
||||||
}));
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
body.replaceChildren(alert('danger', error.status === 404
|
body.replaceChildren(alert('danger', error.status === 404
|
||||||
|
|
|
||||||
24
frontend/js/modal.js
Normal file
24
frontend/js/modal.js
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
// Bootstrap modal shell: a title, a close button and an empty body to fill
|
||||||
|
|
||||||
|
import { h } from './common.js';
|
||||||
|
|
||||||
|
/** Opens the modal at once; returns { body, hide }. The element is removed when it closes. */
|
||||||
|
export function openModal(title) {
|
||||||
|
const body = h('div', { class: 'modal-body' }, h('div', { class: 'text-muted' }, 'Загрузка…'));
|
||||||
|
const element = h('div', { class: 'modal fade', tabindex: '-1', 'aria-labelledby': 'modal-title', 'aria-hidden': 'true' },
|
||||||
|
h('div', { class: 'modal-dialog modal-lg modal-dialog-scrollable' },
|
||||||
|
h('div', { class: 'modal-content' },
|
||||||
|
h('div', { class: 'modal-header' },
|
||||||
|
h('h2', { class: 'modal-title h5', id: 'modal-title' }, title),
|
||||||
|
h('button', { type: 'button', class: 'btn-close', 'data-bs-dismiss': 'modal', 'aria-label': 'Закрыть' })),
|
||||||
|
body)));
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(element);
|
||||||
|
element.addEventListener('hidden.bs.modal', () => {
|
||||||
|
modal.dispose();
|
||||||
|
element.remove();
|
||||||
|
});
|
||||||
|
document.body.append(element);
|
||||||
|
modal.show();
|
||||||
|
return { body, hide: () => modal.hide() };
|
||||||
|
}
|
||||||
99
frontend/js/user-dialog.js
Normal file
99
frontend/js/user-dialog.js
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
// Modal with the form for creating and editing a user
|
||||||
|
|
||||||
|
import { alert, api, h } from './common.js';
|
||||||
|
import { openModal } from './modal.js';
|
||||||
|
|
||||||
|
const ROLES = { trainee: 'Тренирующийся', trainer: 'Тренер' };
|
||||||
|
|
||||||
|
const jsonRequest = (method, body) => ({
|
||||||
|
method,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
function field(label, input, className = 'col-12 col-md-6') {
|
||||||
|
return h('div', { class: className }, h('label', { class: 'form-label small text-muted mb-1' }, label), input);
|
||||||
|
}
|
||||||
|
|
||||||
|
function errorText(error) {
|
||||||
|
if (error.status === 409) return 'Пользователь с таким email уже есть.';
|
||||||
|
if (error.status === 422) return 'Проверьте поля: email должен быть корректным, пароль — не короче 8 символов.';
|
||||||
|
if (error.status === 404) return 'Пользователь не найден.';
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** user is null for a new user; onChange() is called after it was saved or deleted */
|
||||||
|
function userForm(user, { onDone, onCancel }) {
|
||||||
|
const isNew = !user;
|
||||||
|
const input = (name, attrs) => h('input', { class: 'form-control', name, ...attrs });
|
||||||
|
|
||||||
|
const status = h('div', { class: 'mt-3' });
|
||||||
|
const save = h('button', { type: 'submit', class: 'btn btn-primary' }, isNew ? 'Создать' : 'Сохранить');
|
||||||
|
const cancel = h('button', { type: 'button', class: 'btn btn-outline-secondary' }, 'Отмена');
|
||||||
|
cancel.addEventListener('click', onCancel);
|
||||||
|
const remove = isNew ? null : h('button', { type: 'button', class: 'btn btn-outline-danger ms-auto' }, 'Удалить');
|
||||||
|
|
||||||
|
const role = h('select', { class: 'form-select', name: 'role' },
|
||||||
|
...Object.entries(ROLES).map(([value, title]) =>
|
||||||
|
h('option', { value, selected: (user?.role || 'trainee') === value }, title)));
|
||||||
|
|
||||||
|
const form = h('form', {},
|
||||||
|
h('div', { class: 'row g-3' },
|
||||||
|
field('Email', input('email', { type: 'email', required: true, maxlength: 254, value: user?.email || '', autocomplete: 'off' })),
|
||||||
|
field(isNew ? 'Пароль' : 'Новый пароль', input('password', {
|
||||||
|
type: 'password', minlength: 8, maxlength: 128, required: isNew, autocomplete: 'new-password',
|
||||||
|
placeholder: isNew ? 'не короче 8 символов' : 'оставьте пустым, чтобы не менять',
|
||||||
|
})),
|
||||||
|
field('Имя', input('first_name', { type: 'text', maxlength: 100, value: user?.first_name || '' })),
|
||||||
|
field('Фамилия', input('last_name', { type: 'text', maxlength: 100, value: user?.last_name || '' })),
|
||||||
|
field('Роль', role),
|
||||||
|
isNew ? null : h('div', { class: 'col-12 col-md-6 d-flex align-items-end' },
|
||||||
|
h('div', { class: 'form-check mb-2' },
|
||||||
|
h('input', { type: 'checkbox', class: 'form-check-input', name: 'is_active', id: 'user-is-active', checked: user.is_active }),
|
||||||
|
h('label', { class: 'form-check-label', for: 'user-is-active' }, 'Активен')))),
|
||||||
|
h('div', { class: 'd-flex gap-2 align-items-center mt-4' }, save, cancel, remove),
|
||||||
|
status);
|
||||||
|
|
||||||
|
form.addEventListener('submit', async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const { elements } = form;
|
||||||
|
const data = {
|
||||||
|
email: elements.email.value,
|
||||||
|
first_name: elements.first_name.value,
|
||||||
|
last_name: elements.last_name.value,
|
||||||
|
role: elements.role.value,
|
||||||
|
};
|
||||||
|
if (elements.password.value) data.password = elements.password.value;
|
||||||
|
save.disabled = true;
|
||||||
|
try {
|
||||||
|
if (isNew) {
|
||||||
|
await api('/users/', jsonRequest('POST', data));
|
||||||
|
} else {
|
||||||
|
data.is_active = elements.is_active.checked;
|
||||||
|
await api(`/users/${user.id}/`, jsonRequest('PATCH', data));
|
||||||
|
}
|
||||||
|
onDone();
|
||||||
|
} catch (error) {
|
||||||
|
status.replaceChildren(alert('danger', `Не удалось сохранить: ${errorText(error)}`));
|
||||||
|
save.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
remove?.addEventListener('click', async () => {
|
||||||
|
if (!confirm(`Удалить пользователя ${user.email}?`)) return;
|
||||||
|
try {
|
||||||
|
await api(`/users/${user.id}/`, jsonRequest('DELETE'));
|
||||||
|
onDone();
|
||||||
|
} catch (error) {
|
||||||
|
status.replaceChildren(alert('danger', `Не удалось удалить: ${errorText(error)}`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the form; user is the existing user to edit or null to create one. */
|
||||||
|
export function openUserDialog(user, onChange) {
|
||||||
|
const { body, hide } = openModal(user ? 'Редактирование пользователя' : 'Новый пользователь');
|
||||||
|
body.replaceChildren(userForm(user, { onDone: () => { hide(); onChange(); }, onCancel: hide }));
|
||||||
|
}
|
||||||
69
frontend/js/users.js
Normal file
69
frontend/js/users.js
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { alert, api, formatDateTime, h } from './common.js';
|
||||||
|
import { createTable } from './table.js';
|
||||||
|
import { openUserDialog } from './user-dialog.js';
|
||||||
|
|
||||||
|
const ROLES = {
|
||||||
|
trainer: { title: 'Тренер', badge: 'text-bg-primary' },
|
||||||
|
trainee: { title: 'Тренирующийся', badge: 'text-bg-secondary' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const container = document.getElementById('users');
|
||||||
|
const reload = () => location.reload();
|
||||||
|
|
||||||
|
const addButton = () => {
|
||||||
|
const button = h('button', { type: 'button', class: 'btn btn-primary mb-3' }, '+ Добавить пользователя');
|
||||||
|
button.addEventListener('click', () => openUserDialog(null, reload));
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editButton = (user) => {
|
||||||
|
const button = h('button', { type: 'button', class: 'btn btn-sm btn-outline-secondary' }, 'Изменить');
|
||||||
|
button.addEventListener('click', () => openUserDialog(user, reload));
|
||||||
|
return button;
|
||||||
|
};
|
||||||
|
|
||||||
|
function usersView(users) {
|
||||||
|
if (!users.length) {
|
||||||
|
return h('div', {}, addButton(), alert('info', 'Пользователей нет.'));
|
||||||
|
}
|
||||||
|
return h('div', {},
|
||||||
|
addButton(),
|
||||||
|
h('p', { class: 'text-muted' },
|
||||||
|
'Пользователей: ', h('strong', {}, `${users.length}`),
|
||||||
|
', тренеров: ', h('strong', {}, `${users.filter((user) => user.role === 'trainer').length}`)),
|
||||||
|
createTable({
|
||||||
|
id: 'users-table',
|
||||||
|
rows: users,
|
||||||
|
columns: [
|
||||||
|
{ title: 'ID', className: 'text-nowrap', sort: (user) => user.id, cell: (user) => `${user.id}` },
|
||||||
|
{ title: 'Email', className: 'text-nowrap', sort: (user) => user.email, cell: (user) => user.email },
|
||||||
|
{ title: 'Имя', sort: (user) => user.first_name, cell: (user) => user.first_name },
|
||||||
|
{ title: 'Фамилия', sort: (user) => user.last_name, cell: (user) => user.last_name },
|
||||||
|
{
|
||||||
|
title: 'Роль',
|
||||||
|
className: 'text-nowrap',
|
||||||
|
sort: (user) => user.role,
|
||||||
|
cell: (user) => h('span', { class: `badge ${ROLES[user.role].badge}` }, ROLES[user.role].title),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Активен',
|
||||||
|
sort: (user) => Number(user.is_active),
|
||||||
|
cell: (user) => user.is_active ? 'Да' : h('span', { class: 'text-muted' }, 'Нет'),
|
||||||
|
},
|
||||||
|
{ title: 'Создан', className: 'text-nowrap', sort: (user) => user.created_at, cell: (user) => formatDateTime(user.created_at) },
|
||||||
|
{
|
||||||
|
title: 'Последний вход',
|
||||||
|
className: 'text-nowrap',
|
||||||
|
sort: (user) => user.last_login || '',
|
||||||
|
cell: (user) => user.last_login ? formatDateTime(user.last_login) : h('span', { class: 'text-muted' }, '—'),
|
||||||
|
},
|
||||||
|
{ title: '', cell: editButton },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
container.replaceChildren(usersView(await api('/users/')));
|
||||||
|
} catch (error) {
|
||||||
|
container.replaceChildren(alert('danger', `Не удалось загрузить пользователей: ${error.message}`));
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li>
|
<li class="nav-item"><a class="nav-link text-white active" href="parse.html">Разобрать заметку</a></li>
|
||||||
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="users.html">Пользователи</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
|
||||||
36
frontend/users.html
Normal file
36
frontend/users.html
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>Пользователи - Fitness Parser</title>
|
||||||
|
|
||||||
|
<link href="vendor/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="vendor/dataTables.bootstrap5.min.css" rel="stylesheet">
|
||||||
|
<link href="css/main.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="d-flex flex-column flex-md-row">
|
||||||
|
<!-- Sidebar navigation: on narrow screens it turns into a strip on top -->
|
||||||
|
<aside class="sidebar bg-dark p-3">
|
||||||
|
<a class="d-block text-white text-decoration-none fs-5 fw-bold mb-md-4 me-3" href="index.html">Fitness parser</a>
|
||||||
|
<ul class="nav nav-pills flex-row flex-md-column gap-1 mt-2 mt-md-0">
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="index.html">Тренировки</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="parse.html">Разобрать заметку</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white" href="exercises.html">Упражнения</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link text-white active" href="users.html">Пользователи</a></li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="flex-grow-1 p-4 content" id="content">
|
||||||
|
<h1 class="h3 mb-3">Пользователи</h1>
|
||||||
|
<div id="users"><div class="text-muted">Загрузка…</div></div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="vendor/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="vendor/dataTables.min.js"></script>
|
||||||
|
<script src="vendor/dataTables.bootstrap5.min.js"></script>
|
||||||
|
<script type="module" src="js/users.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in a new issue