Files
gym-bro/modal.js
T

603 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const Modal = {
show(html) {
const container = document.getElementById('modal-container');
container.innerHTML = `<div class="modal-overlay" onclick="Modal.closeOnBg(event)">${html}</div>`;
},
close() {
document.getElementById('modal-container').innerHTML = '';
},
closeOnBg(e) {
if (e.target.classList.contains('modal-overlay')) this.close();
},
// ============================================================
// Универсальный степпер
// ============================================================
stepperHtml(id, value, step, placeholder, inputmode = 'decimal') {
const displayValue = value !== '' && value !== null && value !== undefined ? value : '';
return `
<div class="stepper-field">
<button type="button" class="stepper-btn" onclick="Modal.stepValue('${id}', -${step})">−</button>
<input type="number" id="${id}" step="${step}" value="${displayValue}"
placeholder="${placeholder}" inputmode="${inputmode}"
onchange="Modal._onStepperChange('${id}')">
<button type="button" class="stepper-btn" onclick="Modal.stepValue('${id}', ${step})">+</button>
</div>
`;
},
stepValue(id, delta) {
const input = document.getElementById(id);
if (!input) return;
const current = parseFloat(input.value) || 0;
const newVal = Math.max(0, current + delta);
// Округляем до шага, чтобы избежать 0.30000000004
const step = parseFloat(input.step) || 1;
const rounded = Math.round(newVal / step) * step;
input.value = parseFloat(rounded.toFixed(4));
this._onStepperChange(id);
},
_onStepperChange(id) {
// Хук для подклассов модалок — переопределяется при необходимости
if (this._stepperChangeHook) {
this._stepperChangeHook(id);
}
},
// ============================================================
// Dialog: Упражнение
// ============================================================
showExerciseDialog(workoutType, callback) {
const nameCount = {};
App.state.exerciseRef.forEach(ex => {
const isMatch = workoutType && ex.workoutType === workoutType;
const bonus = isMatch ? 100 : 10;
nameCount[ex.name] = (nameCount[ex.name] || 0) + bonus;
});
App.state.workouts.forEach(w => {
w.exercises.forEach(ex => {
nameCount[ex.name] = (nameCount[ex.name] || 0) + 1;
});
});
const sortedNames = Object.keys(nameCount).sort((a, b) => {
const diff = nameCount[b] - nameCount[a];
if (diff !== 0) return diff;
return a.localeCompare(b);
});
this.show(`
<div class="modal-content">
<h3 class="modal-title">Добавить упражнение</h3>
<div class="field-group">
<label>Название</label>
<input type="text" id="m-ex-name" placeholder="Начните вводить..."
oninput="Modal._filterExercises()" autocomplete="off">
<div id="m-ex-autocomplete" class="autocomplete-list"></div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn" onclick="Modal._submitExercise()">Добавить</button>
</div>
</div>
`);
this._exerciseCb = callback;
this._exerciseNames = sortedNames;
setTimeout(() => {
document.getElementById('m-ex-name').focus();
this._filterExercises();
}, 50);
},
_filterExercises() {
const input = document.getElementById('m-ex-name');
const list = document.getElementById('m-ex-autocomplete');
const query = input.value.trim().toLowerCase();
let filtered = this._exerciseNames;
if (query) {
filtered = filtered.filter(n => n.toLowerCase().includes(query));
}
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item text-muted">Нет совпадений — можно ввести новое</div>';
return;
}
list.innerHTML = filtered.slice(0, 15).map(name => {
const count = App.state.workouts.reduce((sum, w) =>
sum + w.exercises.filter(e => e.name === name).length, 0
);
return `
<div class="autocomplete-item" onclick="Modal._selectExercise('${name.replace(/'/g, "\\'")}')">
${name}
${count > 0 ? `<span class="freq">${count}×</span>` : ''}
</div>
`;
}).join('');
},
_selectExercise(name) {
document.getElementById('m-ex-name').value = name;
document.getElementById('m-ex-autocomplete').innerHTML = '';
},
_submitExercise() {
const name = document.getElementById('m-ex-name').value.trim();
this.close();
this._exerciseCb(name);
},
// ============================================================
// Dialog: Подход
// ============================================================
showSetDialog(existingSet, history, callback) {
const isEdit = !!existingSet;
const isDropset = existingSet?.isDropset || false;
let historyHtml = '';
if (history && history.length > 0) {
const rows = history.map(h => {
const date = new Date(h.date).toLocaleDateString('ru-RU', {day:'2-digit', month:'2-digit'});
const setsStr = h.sets.map(s => {
if (s.isDropset && s.subSets) {
return s.subSets.map(ss => `${ss.weight}×${ss.reps}`).join('-');
}
return `${s.weight}×${s.reps}`;
}).join(', ');
return `<div class="history-hint-row">
<span class="history-hint-date">${date}</span>
<span class="history-hint-sets">${setsStr}</span>
</div>`;
}).join('');
historyHtml = `
<div class="history-hint">
<div class="history-hint-title">📊 Прошлые тренировки</div>
${rows}
</div>
`;
}
this.show(`
<div class="modal-content">
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новый'} подход</h3>
${historyHtml}
<div class="field-group">
<label style="display:flex; align-items:center; gap:8px;">
<input type="checkbox" id="m-set-dropset" ${isDropset ? 'checked' : ''}
onchange="Modal._toggleDropsetMode()" style="width:auto;">
Это дроп-сет (несколько сбрасываний веса)
</label>
</div>
<div id="m-set-normal" class="${isDropset ? 'hidden' : ''}">
<div class="field-row">
<div class="field-group">
<label>Вес (кг)</label>
${this.stepperHtml('m-set-weight', existingSet?.weight ?? '', 0.5, 'Вес', 'decimal')}
</div>
<div class="field-group">
<label>Повторения</label>
${this.stepperHtml('m-set-reps', existingSet?.reps ?? '', 1, 'Повт.', 'numeric')}
</div>
</div>
</div>
<div id="m-set-dropset-block" class="${isDropset ? '' : 'hidden'}">
<div id="m-subsets-list"></div>
<button class="btn btn-ghost mt-10" style="width:100%;"
onclick="Modal._addSubset()">+ Добавить сбрасывание</button>
</div>
<div class="field-group">
<label>Оценка усилия (1 — легко, 5 — отказ)</label>
<div class="rating-grid" id="m-rating-grid">
${[1,2,3,4,5].map(r => `
<button class="rating-btn ${existingSet?.rating === r ? 'active' : ''}"
data-rating="${r}" onclick="Modal._selectRating(${r})">${r}</button>
`).join('')}
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn btn-success" onclick="Modal._submitSet()">Сохранить</button>
</div>
</div>
`);
this._setData = {
isDropset: isDropset,
weight: existingSet?.weight || '',
reps: existingSet?.reps || '',
rating: existingSet?.rating || 3,
subSets: existingSet?.subSets ? JSON.parse(JSON.stringify(existingSet.subSets)) : [],
};
this._setCb = callback;
this._stepperChangeHook = null;
if (isDropset) {
if (this._setData.subSets.length === 0) {
this._addSubset();
} else {
this._renderSubsets();
}
}
this._selectRating(this._setData.rating);
},
_toggleDropsetMode() {
const isDropset = document.getElementById('m-set-dropset').checked;
this._setData.isDropset = isDropset;
document.getElementById('m-set-normal').classList.toggle('hidden', isDropset);
document.getElementById('m-set-dropset-block').classList.toggle('hidden', !isDropset);
if (isDropset && this._setData.subSets.length === 0) {
this._addSubset();
} else if (isDropset) {
this._renderSubsets();
}
},
_addSubset() {
const last = this._setData.subSets[this._setData.subSets.length - 1];
this._setData.subSets.push({
weight: last ? last.weight : '',
reps: last ? last.reps : '',
});
this._renderSubsets();
},
_renderSubsets() {
const list = document.getElementById('m-subsets-list');
list.innerHTML = this._setData.subSets.map((s, i) => `
<div class="stepper-row">
${this.stepperHtml(`m-sub-w-${i}`, s.weight, 0.5, 'Вес', 'decimal').replace(/id="m-sub-w-[^"]+"/, `id="m-sub-w-${i}"`)}
${this.stepperHtml(`m-sub-r-${i}`, s.reps, 1, 'Повт.', 'numeric').replace(/id="m-sub-r-[^"]+"/, `id="m-sub-r-${i}"`)}
<button class="icon-btn danger" onclick="Modal._removeSubset(${i})">✕</button>
</div>
`).join('');
// Включаем хук для синхронизации с _setData
this._stepperChangeHook = (id) => {
const wMatch = id.match(/^m-sub-w-(\d+)$/);
const rMatch = id.match(/^m-sub-r-(\d+)$/);
if (wMatch) {
const i = parseInt(wMatch[1]);
this._setData.subSets[i].weight = document.getElementById(id).value;
} else if (rMatch) {
const i = parseInt(rMatch[1]);
this._setData.subSets[i].reps = document.getElementById(id).value;
}
};
},
_removeSubset(i) {
this._setData.subSets.splice(i, 1);
this._renderSubsets();
},
_selectRating(r) {
this._setData.rating = r;
document.querySelectorAll('#m-rating-grid .rating-btn').forEach(btn => {
btn.classList.toggle('active', parseInt(btn.dataset.rating) === r);
});
},
_submitSet() {
if (this._setData.isDropset) {
// Синхронизируем значения из input в _setData перед сабмитом
this._setData.subSets.forEach((s, i) => {
const wInput = document.getElementById(`m-sub-w-${i}`);
const rInput = document.getElementById(`m-sub-r-${i}`);
if (wInput) s.weight = wInput.value;
if (rInput) s.reps = rInput.value;
});
if (this._setData.subSets.length === 0) {
alert('Добавьте хотя бы одно сбрасывание');
return;
}
const result = {
isDropset: true,
subSets: this._setData.subSets.map(s => ({
weight: parseFloat(s.weight) || 0,
reps: parseInt(s.reps) || 0,
})),
rating: this._setData.rating,
};
this.close();
this._setCb(result);
} else {
const weight = parseFloat(document.getElementById('m-set-weight').value);
const reps = parseInt(document.getElementById('m-set-reps').value);
if (!weight && !reps) {
alert('Укажите вес и повторения');
return;
}
const result = {
isDropset: false,
weight: weight || 0,
reps: reps || 0,
rating: this._setData.rating,
};
this.close();
this._setCb(result);
}
},
// ============================================================
// Dialog: Кардио (со степперами)
// ============================================================
showCardioDialog(existing, callback) {
const isEdit = !!existing;
this.show(`
<div class="modal-content">
<h3 class="modal-title">${isEdit ? 'Редактировать' : 'Новое'} кардио</h3>
<div class="field-row">
<div class="field-group">
<label>Тип</label>
<select id="m-cardio-type">
<option value="вело" ${existing?.type==='вело'?'selected':''}>🚴 Вело</option>
<option value="ходьба" ${existing?.type==='ходьба'?'selected':''}>🚶 Ходьба</option>
<option value="бег" ${existing?.type==='бег'?'selected':''}>🏃 Бег</option>
<option value="эллипс" ${existing?.type==='эллипс'?'selected':''}>⚡ Эллипс</option>
<option value="гребля" ${existing?.type==='гребля'?'selected':''}>🚣 Гребля</option>
</select>
</div>
<div class="field-group">
<label>Назначение</label>
<select id="m-cardio-purpose">
<option value="warmup" ${existing?.purpose==='warmup'?'selected':''}>🔥 Разминка</option>
<option value="cooldown" ${existing?.purpose==='cooldown'?'selected':''}>🧘 Заминка</option>
<option value="separate" ${existing?.purpose==='separate'?'selected':''}>➕ Отдельная</option>
</select>
</div>
</div>
<div class="field-row-3">
<div class="field-group">
<label>Минут</label>
${this.stepperHtml('m-cardio-duration', existing?.duration ?? '', 1, 'мин', 'numeric')}
</div>
<div class="field-group">
<label>Дистанция</label>
${this.stepperHtml('m-cardio-distance', existing?.distance ?? '', 0.1, 'км', 'decimal')}
</div>
<div class="field-group">
<label>Скорость</label>
${this.stepperHtml('m-cardio-speed', existing?.speed ?? '', 0.5, 'км/ч', 'decimal')}
</div>
</div>
<div class="field-row">
<div class="field-group">
<label>Наклон</label>
${this.stepperHtml('m-cardio-incline', existing?.incline ?? '', 0.5, '—', 'decimal')}
</div>
<div class="field-group">
<label>Сопротивление</label>
${this.stepperHtml('m-cardio-resistance', existing?.resistance ?? '', 1, '—', 'numeric')}
</div>
</div>
<div class="field-group">
<label>Заметка</label>
<input type="text" id="m-cardio-notes" value="${existing?.notes || ''}"
placeholder="Например: тяжёло после 20 минуты">
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn btn-success" onclick="Modal._submitCardio()">Сохранить</button>
</div>
</div>
`);
this._cardioCb = callback;
},
_submitCardio() {
const result = {
type: document.getElementById('m-cardio-type').value,
purpose: document.getElementById('m-cardio-purpose').value,
duration: parseInt(document.getElementById('m-cardio-duration').value) || 0,
distance: parseFloat(document.getElementById('m-cardio-distance').value) || 0,
speed: parseFloat(document.getElementById('m-cardio-speed').value) || 0,
incline: parseFloat(document.getElementById('m-cardio-incline').value) || 0,
resistance: parseInt(document.getElementById('m-cardio-resistance').value) || 0,
notes: document.getElementById('m-cardio-notes').value.trim(),
};
this.close();
this._cardioCb(result);
},
// ============================================================
// Dialog: Замеры тела (со степперами + дата)
// ============================================================
showBodyMetricsDialog(existingMetrics, callback, editingMetric = null) {
const source = editingMetric || existingMetrics[0];
const isEdit = !!editingMetric;
// Форматируем дату для datetime-local
let dateValue = '';
if (source?.date) {
const d = new Date(source.date);
// Формат YYYY-MM-DDTHH:MM
const pad = n => String(n).padStart(2, '0');
dateValue = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
} else if (!isEdit) {
// По умолчанию — сейчас
const d = new Date();
const pad = n => String(n).padStart(2, '0');
dateValue = `${d.getFullYear()}-${pad(d.getMonth()+1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
this.show(`
<div class="modal-content">
<h3 class="modal-title">📏 Замеры тела ${isEdit ? '(редактирование)' : ''}</h3>
<div class="field-group">
<label>📅 Дата замера</label>
<input type="datetime-local" id="m-metric-date" value="${dateValue}">
</div>
<div class="field-row">
<div class="field-group">
<label>⚖️ Вес (кг)</label>
${this.stepperHtml('m-metric-weight', source?.weight ?? '', 0.1, 'кг', 'decimal')}
</div>
<div class="field-group">
<label>📏 Грудь (см)</label>
${this.stepperHtml('m-metric-chest', source?.chest ?? '', 1, 'см', 'numeric')}
</div>
</div>
<div class="field-row">
<div class="field-group">
<label>📏 Талия (см)</label>
${this.stepperHtml('m-metric-waist', source?.waist ?? '', 1, 'см', 'numeric')}
</div>
<div class="field-group">
<label>📏 Бёдра (см)</label>
${this.stepperHtml('m-metric-hips', source?.hips ?? '', 1, 'см', 'numeric')}
</div>
</div>
<div class="field-row">
<div class="field-group">
<label>💪 Руки (см)</label>
${this.stepperHtml('m-metric-arms', source?.arms ?? '', 0.5, 'см', 'decimal')}
</div>
<div class="field-group">
<label>🦵 Ноги (см)</label>
${this.stepperHtml('m-metric-legs', source?.legs ?? '', 0.5, 'см', 'decimal')}
</div>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn btn-success" onclick="Modal._submitMetrics(${isEdit})">Сохранить</button>
</div>
</div>
`);
this._metricsCb = callback;
},
_submitMetrics(isEdit) {
const dateStr = document.getElementById('m-metric-date').value;
const result = {
date: dateStr ? new Date(dateStr).toISOString() : new Date().toISOString(),
weight: parseFloat(document.getElementById('m-metric-weight').value) || null,
chest: parseInt(document.getElementById('m-metric-chest').value) || null,
waist: parseInt(document.getElementById('m-metric-waist').value) || null,
hips: parseInt(document.getElementById('m-metric-hips').value) || null,
arms: parseFloat(document.getElementById('m-metric-arms').value) || null,
legs: parseFloat(document.getElementById('m-metric-legs').value) || null,
};
if (!Object.values(result).some((v, i) => i > 0 && v !== null)) {
alert('Заполните хотя бы одно поле');
return;
}
this.close();
this._metricsCb(result, isEdit);
},
// ============================================================
// Dialog: Редактирование упражнения из справочника
// ============================================================
showEditExerciseDialog(exercise, callback) {
this.show(`
<div class="modal-content">
<h3 class="modal-title">Редактировать упражнение</h3>
<div class="field-group">
<label>Название</label>
<input type="text" id="m-edit-ex-name" value="${exercise.name}">
</div>
<div class="field-group">
<label>Тип тренировки</label>
<select id="m-edit-ex-type">
<option value="">тип не указан</option>
<option value="грудь-руки" ${exercise.workoutType==='грудь-руки'?'selected':''}>грудь-руки</option>
<option value="ноги" ${exercise.workoutType==='ноги'?'selected':''}>ноги</option>
<option value="спина-плечи" ${exercise.workoutType==='спина-плечи'?'selected':''}>спина-плечи</option>
<option value="круговая" ${exercise.workoutType==='круговая'?'selected':''}>круговая</option>
<option value="силовая" ${exercise.workoutType==='силовая'?'selected':''}>силовая</option>
<option value="многоповторная" ${exercise.workoutType==='многоповторная'?'selected':''}>многоповторная</option>
</select>
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn btn-success" onclick="Modal._submitEditExercise()">Сохранить</button>
</div>
</div>
`);
this._editExerciseCb = callback;
},
_submitEditExercise() {
const name = document.getElementById('m-edit-ex-name').value.trim();
const type = document.getElementById('m-edit-ex-type').value;
if (!name) {
alert('Название не может быть пустым');
return;
}
this.close();
this._editExerciseCb({ name, workoutType: type });
},
// ============================================================
// Dialog: Редактирование даты тренировки
// ============================================================
showEditWorkoutDateDialog(workout, callback) {
const start = new Date(workout.startTime);
const pad = n => String(n).padStart(2, '0');
const startValue = `${start.getFullYear()}-${pad(start.getMonth()+1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`;
let endValue = '';
if (workout.endTime) {
const end = new Date(workout.endTime);
endValue = `${end.getFullYear()}-${pad(end.getMonth()+1)}-${pad(end.getDate())}T${pad(end.getHours())}:${pad(end.getMinutes())}`;
}
this.show(`
<div class="modal-content">
<h3 class="modal-title">📅 Дата тренировки</h3>
<div class="field-group">
<label>Начало</label>
<input type="datetime-local" id="m-w-start" value="${startValue}">
</div>
<div class="field-group">
<label>Окончание</label>
<input type="datetime-local" id="m-w-end" value="${endValue}">
</div>
<div class="btn-row">
<button class="btn btn-secondary" onclick="Modal.close()">Отмена</button>
<button class="btn btn-success" onclick="Modal._submitWorkoutDate()">Сохранить</button>
</div>
</div>
`);
this._workoutDateCb = callback;
},
_submitWorkoutDate() {
const startStr = document.getElementById('m-w-start').value;
const endStr = document.getElementById('m-w-end').value;
if (!startStr) {
alert('Укажите дату начала');
return;
}
this.close();
this._workoutDateCb({
startTime: new Date(startStr).toISOString(),
endTime: endStr ? new Date(endStr).toISOString() : null,
});
},
};