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 + + + + + + +
+ + + +
+

Пользователи

+
Загрузка…
+
+
+ + + + + + +