Front: Add user interactions

This commit is contained in:
t0xa 2026-10-23 20:33:45 +03:00
parent c5350120e6
commit 07a774aef4
12 changed files with 244 additions and 22 deletions

View file

@ -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/`
**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
- `nginx.conf` - serves static files and proxies `/api/` to `fastapi-app:8000` (same origin, no CORS)

View file

@ -31,6 +31,7 @@ services:
- ./frontend/index.html:/usr/share/nginx/html/index.html:ro
- ./frontend/exercises.html:/usr/share/nginx/html/exercises.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/js:/usr/share/nginx/html/js:ro
- ./frontend/vendor:/usr/share/nginx/html/vendor:ro

View file

@ -1,7 +1,7 @@
FROM nginx:1.27-alpine
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 js /usr/share/nginx/html/js
COPY vendor /usr/share/nginx/html/vendor

View file

@ -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="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" href="users.html">Пользователи</a></li>
</ul>
</aside>

View file

@ -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" 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="users.html">Пользователи</a></li>
</ul>
</aside>

View file

@ -34,6 +34,11 @@ export function formatDate(iso) {
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" */
export function num(value) {
return String(Math.round(value * 10) / 10);

View file

@ -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 { editForm } from './edit-form.js';
import { openModal } from './modal.js';
/** "Изменить" button: opens the modal, the page reloads after a save to show fresh data */
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. */
export async function openEditDialog(trainingId, onChange) {
const body = h('div', { class: 'modal-body' }, h('div', { class: 'text-muted' }, 'Загрузка…'));
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();
const { body, hide } = openModal('Редактирование тренировки');
try {
const [training, exerciseNames] = await Promise.all([
api(`/trainings/${trainingId}/`),
api('/exercises/'),
]);
body.replaceChildren(editForm(training, exerciseNames, {
onDone: () => { modal.hide(); onChange(); },
onCancel: () => modal.hide(),
onDone: () => { hide(); onChange(); },
onCancel: hide,
}));
} catch (error) {
body.replaceChildren(alert('danger', error.status === 404

24
frontend/js/modal.js Normal file
View 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() };
}

View 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
View 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}`));
}

View file

@ -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 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="users.html">Пользователи</a></li>
</ul>
</aside>

36
frontend/users.html Normal file
View 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>