feat(tg-fitness-bot): add telegram fitness bot with web app
Telegram workout tracker bot with Mini App web UI, SQLite database, API server, and cloudflared tunnel support. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
7288d93741
commit
ae09ab2eec
14 changed files with 1892 additions and 0 deletions
361
telegram-fitness-bot/webapp/app.js
Normal file
361
telegram-fitness-bot/webapp/app.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// ── Telegram Web App init ───────────────────────────────────────
|
||||
const tg = window.Telegram.WebApp;
|
||||
tg.ready();
|
||||
tg.expand();
|
||||
|
||||
const API = window.location.origin + "/api";
|
||||
const userId = tg.initDataUnsafe?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
document.getElementById("app").innerHTML =
|
||||
'<div class="empty-state"><p>Please open this app from Telegram.</p></div>';
|
||||
}
|
||||
|
||||
// ── State ───────────────────────────────────────────────────────
|
||||
let activeWorkout = null;
|
||||
let exercises = [];
|
||||
let timerInterval = null;
|
||||
|
||||
// ── API helpers ─────────────────────────────────────────────────
|
||||
async function api(method, path, body = null) {
|
||||
const opts = {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Telegram-Init-Data": tg.initData,
|
||||
},
|
||||
};
|
||||
if (body) opts.body = JSON.stringify(body);
|
||||
const res = await fetch(API + path, opts);
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error(err.error || `API error ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Toast ───────────────────────────────────────────────────────
|
||||
function showToast(message) {
|
||||
let toast = document.querySelector(".toast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.className = "toast";
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = message;
|
||||
toast.classList.add("show");
|
||||
setTimeout(() => toast.classList.remove("show"), 2000);
|
||||
}
|
||||
|
||||
// ── Tab navigation ──────────────────────────────────────────────
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active"));
|
||||
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
|
||||
tab.classList.add("active");
|
||||
document.getElementById("view-" + tab.dataset.view).classList.add("active");
|
||||
tg.HapticFeedback.selectionChanged();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Timer ───────────────────────────────────────────────────────
|
||||
function startTimer(startedAt) {
|
||||
if (timerInterval) clearInterval(timerInterval);
|
||||
const start = new Date(startedAt + "Z").getTime();
|
||||
const el = document.getElementById("workout-timer");
|
||||
|
||||
function tick() {
|
||||
const diff = Math.floor((Date.now() - start) / 1000);
|
||||
const m = String(Math.floor(diff / 60)).padStart(2, "0");
|
||||
const s = String(diff % 60).padStart(2, "0");
|
||||
el.textContent = `${m}:${s}`;
|
||||
}
|
||||
tick();
|
||||
timerInterval = setInterval(tick, 1000);
|
||||
}
|
||||
|
||||
function stopTimer() {
|
||||
if (timerInterval) {
|
||||
clearInterval(timerInterval);
|
||||
timerInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render helpers ──────────────────────────────────────────────
|
||||
|
||||
function renderExerciseSelect() {
|
||||
const sel = document.getElementById("sel-exercise");
|
||||
sel.innerHTML = '<option value="">Select exercise…</option>';
|
||||
exercises.forEach((ex) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = ex.id;
|
||||
opt.textContent = ex.name;
|
||||
sel.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSets(sets) {
|
||||
const container = document.getElementById("sets-list");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!sets || sets.length === 0) return;
|
||||
|
||||
// Group by exercise
|
||||
const groups = {};
|
||||
sets.forEach((s) => {
|
||||
if (!groups[s.exercise_name]) groups[s.exercise_name] = [];
|
||||
groups[s.exercise_name].push(s);
|
||||
});
|
||||
|
||||
for (const [name, groupSets] of Object.entries(groups)) {
|
||||
const group = document.createElement("div");
|
||||
group.className = "exercise-group";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
heading.className = "exercise-group-name";
|
||||
heading.textContent = name;
|
||||
group.appendChild(heading);
|
||||
|
||||
groupSets.forEach((s, i) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "set-item";
|
||||
item.innerHTML = `
|
||||
<span class="set-number">Set ${i + 1}</span>
|
||||
<span class="set-detail">${s.reps} reps × ${s.weight} kg</span>
|
||||
<button class="set-delete" data-set-id="${s.id}">×</button>
|
||||
`;
|
||||
group.appendChild(item);
|
||||
});
|
||||
|
||||
container.appendChild(group);
|
||||
}
|
||||
|
||||
// Attach delete handlers
|
||||
container.querySelectorAll(".set-delete").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
tg.HapticFeedback.impactOccurred("light");
|
||||
await api("DELETE", `/sets/${btn.dataset.setId}`);
|
||||
await refreshWorkout();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderExercisesList() {
|
||||
const container = document.getElementById("exercises-list");
|
||||
container.innerHTML = "";
|
||||
|
||||
exercises.forEach((ex) => {
|
||||
const item = document.createElement("div");
|
||||
item.className = "exercise-item";
|
||||
item.innerHTML = `
|
||||
<span>${ex.name}</span>
|
||||
<button class="exercise-delete" data-exercise-id="${ex.id}">×</button>
|
||||
`;
|
||||
container.appendChild(item);
|
||||
});
|
||||
|
||||
container.querySelectorAll(".exercise-delete").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
tg.HapticFeedback.impactOccurred("light");
|
||||
try {
|
||||
await api("DELETE", `/exercises/${btn.dataset.exerciseId}`);
|
||||
await loadExercises();
|
||||
showToast("Exercise deleted");
|
||||
} catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function renderHistory() {
|
||||
try {
|
||||
const data = await api("GET", "/workouts");
|
||||
const container = document.getElementById("history-list");
|
||||
const noHistory = document.getElementById("no-history");
|
||||
container.innerHTML = "";
|
||||
|
||||
if (!data.workouts || data.workouts.length === 0) {
|
||||
noHistory.style.display = "block";
|
||||
return;
|
||||
}
|
||||
noHistory.style.display = "none";
|
||||
|
||||
data.workouts.forEach((w) => {
|
||||
const card = document.createElement("div");
|
||||
card.className = "history-card";
|
||||
|
||||
const date = new Date(w.started_at + "Z");
|
||||
const dateStr = date.toLocaleDateString(undefined, {
|
||||
weekday: "short",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
let exercisesHtml = "";
|
||||
if (w.summary && w.summary.exercises) {
|
||||
for (const [name, sets] of Object.entries(w.summary.exercises)) {
|
||||
const setsStr = sets
|
||||
.map((s) => `${s.reps}×${s.weight}kg`)
|
||||
.join(", ");
|
||||
exercisesHtml += `
|
||||
<div class="history-exercise">
|
||||
<div class="history-exercise-name">${name}</div>
|
||||
<div class="history-exercise-sets">${setsStr}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="history-card-header">
|
||||
<span class="history-date">${dateStr}</span>
|
||||
<span class="history-volume">${w.summary?.total_volume || 0} kg total</span>
|
||||
</div>
|
||||
${exercisesHtml}
|
||||
`;
|
||||
container.appendChild(card);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to load history", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workout flow ────────────────────────────────────────────────
|
||||
|
||||
function showWorkoutState(active) {
|
||||
document.getElementById("no-workout").style.display = active ? "none" : "block";
|
||||
document.getElementById("active-workout").style.display = active ? "block" : "none";
|
||||
}
|
||||
|
||||
async function refreshWorkout() {
|
||||
try {
|
||||
const data = await api("GET", "/workouts/active");
|
||||
activeWorkout = data.workout;
|
||||
if (activeWorkout) {
|
||||
showWorkoutState(true);
|
||||
startTimer(activeWorkout.started_at);
|
||||
const setsData = await api("GET", `/workouts/${activeWorkout.id}/sets`);
|
||||
renderSets(setsData.sets);
|
||||
} else {
|
||||
showWorkoutState(false);
|
||||
stopTimer();
|
||||
}
|
||||
} catch (e) {
|
||||
showWorkoutState(false);
|
||||
stopTimer();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event listeners ─────────────────────────────────────────────
|
||||
|
||||
// Start workout
|
||||
document.getElementById("btn-start-workout").addEventListener("click", async () => {
|
||||
tg.HapticFeedback.impactOccurred("medium");
|
||||
try {
|
||||
const data = await api("POST", "/workouts");
|
||||
activeWorkout = data.workout;
|
||||
showWorkoutState(true);
|
||||
startTimer(activeWorkout.started_at);
|
||||
showToast("Workout started!");
|
||||
} catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Finish workout
|
||||
document.getElementById("btn-finish-workout").addEventListener("click", async () => {
|
||||
tg.HapticFeedback.notificationOccurred("success");
|
||||
try {
|
||||
await api("POST", `/workouts/${activeWorkout.id}/finish`);
|
||||
activeWorkout = null;
|
||||
showWorkoutState(false);
|
||||
stopTimer();
|
||||
showToast("Workout finished!");
|
||||
renderHistory();
|
||||
} catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Add set
|
||||
document.getElementById("btn-add-set").addEventListener("click", async () => {
|
||||
const exerciseId = document.getElementById("sel-exercise").value;
|
||||
const reps = parseInt(document.getElementById("inp-reps").value);
|
||||
const weight = parseFloat(document.getElementById("inp-weight").value);
|
||||
|
||||
if (!exerciseId) {
|
||||
showToast("Pick an exercise first");
|
||||
tg.HapticFeedback.notificationOccurred("error");
|
||||
return;
|
||||
}
|
||||
if (!reps || reps < 1) {
|
||||
showToast("Enter valid reps");
|
||||
tg.HapticFeedback.notificationOccurred("error");
|
||||
return;
|
||||
}
|
||||
|
||||
tg.HapticFeedback.impactOccurred("light");
|
||||
try {
|
||||
await api("POST", `/workouts/${activeWorkout.id}/sets`, {
|
||||
exercise_id: parseInt(exerciseId),
|
||||
reps,
|
||||
weight: weight || 0,
|
||||
});
|
||||
await refreshWorkout();
|
||||
showToast(`${reps} × ${weight} kg logged`);
|
||||
} catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Add exercise
|
||||
document.getElementById("btn-add-exercise").addEventListener("click", async () => {
|
||||
const input = document.getElementById("inp-exercise-name");
|
||||
const name = input.value.trim();
|
||||
if (!name) {
|
||||
showToast("Enter exercise name");
|
||||
tg.HapticFeedback.notificationOccurred("error");
|
||||
return;
|
||||
}
|
||||
|
||||
tg.HapticFeedback.impactOccurred("light");
|
||||
try {
|
||||
await api("POST", "/exercises", { name });
|
||||
input.value = "";
|
||||
await loadExercises();
|
||||
showToast(`${name} added`);
|
||||
} catch (e) {
|
||||
showToast(e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Enter key to add exercise
|
||||
document.getElementById("inp-exercise-name").addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") document.getElementById("btn-add-exercise").click();
|
||||
});
|
||||
|
||||
// ── Data loading ────────────────────────────────────────────────
|
||||
|
||||
async function loadExercises() {
|
||||
try {
|
||||
const data = await api("GET", "/exercises");
|
||||
exercises = data.exercises || [];
|
||||
renderExerciseSelect();
|
||||
renderExercisesList();
|
||||
} catch (e) {
|
||||
console.error("Failed to load exercises", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Init ────────────────────────────────────────────────────────
|
||||
async function init() {
|
||||
if (!userId) return;
|
||||
await loadExercises();
|
||||
await refreshWorkout();
|
||||
await renderHistory();
|
||||
}
|
||||
|
||||
init();
|
||||
83
telegram-fitness-bot/webapp/index.html
Normal file
83
telegram-fitness-bot/webapp/index.html
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no" />
|
||||
<title>Workout Tracker</title>
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
<!-- Navigation tabs -->
|
||||
<nav id="tabs">
|
||||
<button class="tab active" data-view="workout">Workout</button>
|
||||
<button class="tab" data-view="exercises">Exercises</button>
|
||||
<button class="tab" data-view="history">History</button>
|
||||
</nav>
|
||||
|
||||
<!-- ═══ WORKOUT VIEW ═══ -->
|
||||
<div id="view-workout" class="view active">
|
||||
<!-- No active workout state -->
|
||||
<div id="no-workout">
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">🏋️</div>
|
||||
<p>No active workout</p>
|
||||
<button id="btn-start-workout" class="btn btn-primary">Start Workout</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active workout state -->
|
||||
<div id="active-workout" style="display:none">
|
||||
<div class="workout-header">
|
||||
<span id="workout-timer">00:00</span>
|
||||
<button id="btn-finish-workout" class="btn btn-danger btn-sm">Finish</button>
|
||||
</div>
|
||||
|
||||
<!-- Add set form -->
|
||||
<div class="card" id="add-set-card">
|
||||
<select id="sel-exercise" class="input">
|
||||
<option value="">Select exercise…</option>
|
||||
</select>
|
||||
<div class="set-inputs">
|
||||
<div class="input-group">
|
||||
<label>Reps</label>
|
||||
<input type="number" id="inp-reps" class="input" min="1" value="10" inputmode="numeric" />
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Weight (kg)</label>
|
||||
<input type="number" id="inp-weight" class="input" min="0" step="0.5" value="20" inputmode="decimal" />
|
||||
</div>
|
||||
</div>
|
||||
<button id="btn-add-set" class="btn btn-primary">Log Set</button>
|
||||
</div>
|
||||
|
||||
<!-- Logged sets list -->
|
||||
<div id="sets-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ EXERCISES VIEW ═══ -->
|
||||
<div id="view-exercises" class="view">
|
||||
<div class="card">
|
||||
<div class="add-exercise-row">
|
||||
<input type="text" id="inp-exercise-name" class="input" placeholder="New exercise name…" />
|
||||
<button id="btn-add-exercise" class="btn btn-primary btn-sm">Add</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="exercises-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ HISTORY VIEW ═══ -->
|
||||
<div id="view-history" class="view">
|
||||
<div id="history-list"></div>
|
||||
<div id="no-history" class="empty-state">
|
||||
<div class="empty-icon">📋</div>
|
||||
<p>No workouts yet</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
333
telegram-fitness-bot/webapp/style.css
Normal file
333
telegram-fitness-bot/webapp/style.css
Normal file
|
|
@ -0,0 +1,333 @@
|
|||
/* ── Reset & Telegram-native theming ─────────────────────────── */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background-color: var(--tg-theme-bg-color, #ffffff);
|
||||
color: var(--tg-theme-text-color, #000000);
|
||||
min-height: 100vh;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ── Tab navigation ──────────────────────────────────────────── */
|
||||
|
||||
#tabs {
|
||||
display: flex;
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
border-bottom: 1px solid var(--tg-theme-hint-color, #999999)33;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 12px 0;
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
color: var(--tg-theme-button-color, #3390ec);
|
||||
border-bottom-color: var(--tg-theme-button-color, #3390ec);
|
||||
}
|
||||
|
||||
/* ── Views ───────────────────────────────────────────────────── */
|
||||
|
||||
.view {
|
||||
display: none;
|
||||
padding: 16px;
|
||||
padding-bottom: 32px;
|
||||
}
|
||||
|
||||
.view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Cards ───────────────────────────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────────────────────────── */
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 12px 20px;
|
||||
width: 100%;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.btn:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--tg-theme-button-color, #3390ec);
|
||||
color: var(--tg-theme-button-text-color, #ffffff);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e53935;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 8px 16px;
|
||||
font-size: 13px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* ── Inputs ──────────────────────────────────────────────────── */
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 1.5px solid var(--tg-theme-hint-color, #999999)44;
|
||||
background: var(--tg-theme-bg-color, #ffffff);
|
||||
color: var(--tg-theme-text-color, #000000);
|
||||
font-size: 15px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--tg-theme-button-color, #3390ec);
|
||||
}
|
||||
|
||||
select.input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Workout view specifics ──────────────────────────────────── */
|
||||
|
||||
.workout-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
#workout-timer {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.set-inputs {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
margin-bottom: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ── Set list items ──────────────────────────────────────────── */
|
||||
|
||||
.exercise-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.exercise-group-name {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.set-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.set-item .set-number {
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
font-weight: 600;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.set-item .set-detail {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.set-item .set-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #e53935;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* ── Exercise list ───────────────────────────────────────────── */
|
||||
|
||||
.add-exercise-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.add-exercise-row .input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.exercise-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 14px 16px;
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
border-radius: 10px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 15px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.exercise-item .exercise-delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
/* ── History ─────────────────────────────────────────────────── */
|
||||
|
||||
.history-card {
|
||||
background: var(--tg-theme-secondary-bg-color, #f0f0f0);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.history-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 13px;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
}
|
||||
|
||||
.history-volume {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--tg-theme-button-color, #3390ec);
|
||||
}
|
||||
|
||||
.history-exercise {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.history-exercise-name {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.history-exercise-sets {
|
||||
font-size: 13px;
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
}
|
||||
|
||||
/* ── Empty state ─────────────────────────────────────────────── */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--tg-theme-hint-color, #999999);
|
||||
font-size: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ── Haptic-like tap feedback ────────────────────────────────── */
|
||||
|
||||
.btn:active,
|
||||
.tab:active,
|
||||
.set-delete:active,
|
||||
.exercise-delete:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
/* ── Toast notification ──────────────────────────────────────── */
|
||||
|
||||
.toast {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) translateY(80px);
|
||||
background: var(--tg-theme-text-color, #000000);
|
||||
color: var(--tg-theme-bg-color, #ffffff);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
opacity: 0;
|
||||
transition: transform 0.3s, opacity 0.3s;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast.show {
|
||||
transform: translateX(-50%) translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue