diff --git a/CLAUDE.md b/CLAUDE.md
index 0fc1a0d..aa58219 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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)
diff --git a/compose.yaml b/compose.yaml
index f51b544..99df67a 100644
--- a/compose.yaml
+++ b/compose.yaml
@@ -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
diff --git a/frontend/Dockerfile b/frontend/Dockerfile
index 4abba62..6ce85d7 100644
--- a/frontend/Dockerfile
+++ b/frontend/Dockerfile
@@ -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
diff --git a/frontend/exercises.html b/frontend/exercises.html
index 8b394c0..8d570e4 100644
--- a/frontend/exercises.html
+++ b/frontend/exercises.html
@@ -18,6 +18,7 @@
Тренировки
Разобрать заметку
Упражнения
+ Пользователи
diff --git a/frontend/index.html b/frontend/index.html
index 18b11e8..177d114 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -18,6 +18,7 @@
Тренировки
Разобрать заметку
Упражнения
+ Пользователи
diff --git a/frontend/js/common.js b/frontend/js/common.js
index 276653d..0679fe8 100644
--- a/frontend/js/common.js
+++ b/frontend/js/common.js
@@ -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);
diff --git a/frontend/js/edit-dialog.js b/frontend/js/edit-dialog.js
index 7fd86e5..f1e56fd 100644
--- a/frontend/js/edit-dialog.js
+++ b/frontend/js/edit-dialog.js
@@ -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
diff --git a/frontend/js/modal.js b/frontend/js/modal.js
new file mode 100644
index 0000000..221827c
--- /dev/null
+++ b/frontend/js/modal.js
@@ -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() };
+}
diff --git a/frontend/js/user-dialog.js b/frontend/js/user-dialog.js
new file mode 100644
index 0000000..f5fe1b4
--- /dev/null
+++ b/frontend/js/user-dialog.js
@@ -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 }));
+}
diff --git a/frontend/js/users.js b/frontend/js/users.js
new file mode 100644
index 0000000..e1d2b94
--- /dev/null
+++ b/frontend/js/users.js
@@ -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}`));
+}
diff --git a/frontend/parse.html b/frontend/parse.html
index 44e78d4..0a58ab7 100644
--- a/frontend/parse.html
+++ b/frontend/parse.html
@@ -18,6 +18,7 @@
Тренировки
Разобрать заметку
Упражнения
+ Пользователи
diff --git a/frontend/users.html b/frontend/users.html
new file mode 100644
index 0000000..1588605
--- /dev/null
+++ b/frontend/users.html
@@ -0,0 +1,36 @@
+
+
+
+
+
+ Пользователи - Fitness Parser
+
+
+
+
+
+
+
+
+
+
+
+ Пользователи
+
+
+
+
+
+
+
+
+
+