bunnymommycooks.com Review
bunnymommycooks.com Review
| Titletag | Descriptiontag | language |
|---|
| StudioGym Coach IA | |
French |
| Ip adress | 104.21.19.244 | Nameserver | tia.ns.cloudflare.com jack.ns.cloudflare.com |
Status code | 200 |
# As a condition of accessing this website, you agree to abide by the following
# content signals:
# (a) If a Content-Signal = yes, you may collect content for the corresponding
# use.
# (b) If a Content-Signal = no, you may not collect content for the
# corresponding use.
# (c) If the website operator does not include a Content-Signal for a
# corresponding use, the website operator neither grants nor restricts
# permission via Content-Signal with respect to the corresponding use.
# The content signals and their meanings are:
# search: building a search index and providing search results (e.g., returning
# hyperlinks and short excerpts from your website's contents). Search does not
# include providing AI-generated search summaries.
# ai-input: inputting content into one or more AI models (e.g., retrieval
# augmented generation, grounding, or other real-time taking of content for
# generative AI search answers).
# ai-train: training or fine-tuning AI models.
# use: how AI systems may consume the content (immediate, reference, or full).
# ANY RESTRICTIONS EXPRESSED VIA CONTENT SIGNALS ARE EXPRESS RESERVATIONS OF
# RIGHTS UNDER ARTICLE 4 OF THE EUROPEAN UNION DIRECTIVE 2019/790 ON COPYRIGHT
# AND RELATED RIGHTS IN THE DIGITAL SINGLE MARKET.
# BEGIN Cloudflare Managed content
User-agent: *
Content-Signal: search=yes,ai-train=no,use=reference
Allow: /
User-agent: Amazonbot
Disallow: /
User-agent: Applebot-Extended
Disallow: /
User-agent: Bytespider
Disallow: /
User-agent: CCBot
Disallow: /
User-agent: ClaudeBot
Disallow: /
User-agent: CloudflareBrowserRenderingCrawler
Disallow: /
User-agent: Google-Extended
Disallow: /
User-agent: GPTBot
Disallow: /
User-agent: meta-externalagent
Disallow: /
# END Cloudflare Managed Content
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>StudioGym Coach IA</title>
<style>
/* ── StudioGym design system ── */
@font-face {
font-family: 'goboldregular';
src: local('Impact'), local('Arial Narrow');
font-weight: normal;
font-style: normal;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
/* Couleurs StudioGym */
--bg: #1d1d1b; /* sg-black-background */
--surface: #111110; /* plus sombre que le bg */
--surface2: #272724; /* cards et inputs */
--surface3: #323230; /* hover, sélection */
--border: #3a3a37;
--accent: #a4c408; /* sg-color-primary / sg-color-green */
--accent-dim: #80af60; /* sg-color-lightgreen */
--text: #efecec; /* sg-color-offwhite */
--text-muted: #8a8884; /* dérivé sg-color-darkgrey */
--danger: #cc5920; /* sg-color-error */
--warning: #f9ac23; /* sg-color-warning */
--golden: #d5af60; /* sg-color-golden */
--user-bubble: #1a2410; /* vert très sombre pour bulles utilisateur */
--ai-bubble: #111110;
--radius: 4px; /* StudioGym est plutôt carré */
--font-display: 'goboldregular', Impact, 'Arial Narrow', sans-serif;
--font-body: 'Segoe UI', system-ui, sans-serif;
}
body {
font-family: var(--font-body);
background: var(--bg);
color: var(--text);
height: 100dvh;
display: flex;
flex-direction: column;
}
/* ── Header ── */
header {
background: var(--surface);
border-bottom: 3px solid var(--accent);
padding: 0 20px;
height: 56px;
display: flex;
align-items: center;
gap: 14px;
flex-shrink: 0;
}
.logo {
height: 34px;
width: auto;
flex-shrink: 0;
display: flex;
align-items: center;
}
.logo svg {
height: 34px;
width: auto;
}
.header-title {
display: flex;
flex-direction: column;
}
header h1 {
font-family: var(--font-display);
font-size: 15px;
font-weight: 900;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text);
line-height: 1.1;
}
header p {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .04em;
margin-top: 2px;
}
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #555; margin-left: auto; flex-shrink: 0;
transition: background .3s;
}
.status-dot.ok { background: var(--accent); }
.status-dot.err { background: var(--danger); }
/* ── Settings bar ── */
.settings {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 8px 20px;
display: flex; gap: 20px; align-items: center; flex-wrap: wrap;
flex-shrink: 0;
}
.settings label {
font-size: 11px;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .05em;
display: flex; gap: 8px; align-items: center;
}
.settings input, .settings select {
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 5px 10px;
font-size: 12px;
font-family: var(--font-body);
}
.settings input:focus, .settings select:focus {
outline: none;
border-color: var(--accent);
}
.settings input { width: 200px; }
/* ── Messages ── */
#messages {
flex: 1; overflow-y: auto; padding: 20px;
display: flex; flex-direction: column; gap: 16px;
}
#messages::-webkit-scrollbar { width: 4px; }
#messages::-webkit-scrollbar-track { background: transparent; }
#messages::-webkit-scrollbar-thumb { background: var(--border); }
.msg { display: flex; gap: 10px; max-width: 100%; }
.msg.user { flex-direction: row-reverse; }
.avatar {
width: 34px; height: 34px;
display: flex; align-items: center; justify-content: center;
font-family: var(--font-display);
font-size: 12px; font-weight: 900;
letter-spacing: .04em;
flex-shrink: 0;
align-self: flex-end;
}
.msg.user .avatar {
background: var(--user-bubble);
border: 1px solid var(--accent);
color: var(--accent);
}
.msg.ai .avatar {
background: var(--accent);
color: var(--bg);
}
.bubble {
max-width: min(700px, 84vw);
background: var(--ai-bubble);
border: 1px solid var(--border);
border-top: 2px solid var(--surface3);
padding: 14px 16px;
font-size: 14px; line-height: 1.65;
}
.msg.user .bubble {
background: var(--user-bubble);
border: 1px solid var(--accent);
border-top: 2px solid var(--accent);
}
/* Markdown-like formatting */
.bubble p { margin-bottom: 8px; }
.bubble p:last-child { margin-bottom: 0; }
.bubble strong { color: var(--accent); font-weight: 700; }
.bubble em { color: var(--accent-dim); font-style: normal; font-weight: 600; }
/* ── Cards ── */
.cards { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
.card {
background: var(--surface2);
border: 1px solid var(--border);
border-left: 3px solid var(--accent);
overflow: hidden;
}
.card:hover { border-left-color: var(--accent-dim); background: var(--surface3); }
.card-body { padding: 12px 14px; }
/* Programme card — image gauche, contenu droite */
.program-card {
display: flex;
align-items: flex-start;
}
.program-card .card-body {
flex: 1;
padding: 12px 14px 12px 16px;
}
.card-img {
width: 140px;
height: auto;
max-height: 140px;
object-fit: contain;
display: block;
flex-shrink: 0;
background: var(--surface);
}
.card-img-placeholder {
width: 140px;
height: 140px;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--surface);
font-family: var(--font-display);
font-size: 11px;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--text-muted);
text-align: center;
padding: 8px;
}
@media (max-width: 480px) {
.program-card {
flex-direction: column;
}
.program-card .card-img,
.program-card .card-img-placeholder {
width: 100%;
height: auto;
max-height: 180px;
}
.program-card .card-body {
padding: 12px 14px;
}
}
.card-header { display: flex; gap: 10px; align-items: flex-start; margin-bottom: 6px; }
.card-title {
font-family: var(--font-display);
font-size: 13px;
font-weight: 900;
letter-spacing: .05em;
text-transform: uppercase;
color: var(--text);
}
.card-sub { font-size: 12px; color: var(--text-muted); margin-top: 3px; }
.badge {
font-family: var(--font-display);
font-size: 10px;
letter-spacing: .06em;
text-transform: uppercase;
padding: 3px 9px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
white-space: nowrap; flex-shrink: 0;
}
.badge.beginner { border-color: var(--accent); color: var(--accent); }
.badge.intermediate { border-color: var(--golden); color: var(--golden); }
.badge.expert { border-color: var(--danger); color: var(--danger); }
.badge.no-equip { border-color: var(--text-muted); color: var(--text-muted); }
.tags { display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; }
.tag {
font-size: 11px; padding: 2px 8px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: .03em;
}
.tag.muscle { border-color: #3a5040; color: var(--accent-dim); }
.tag.bodypart { border-color: #4a4020; color: var(--golden); }
/* ── Program schedule table ── */
.week-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 10px; }
.week-table th {
background: var(--surface);
color: var(--text-muted);
padding: 7px 10px; text-align: left;
font-family: var(--font-display);
font-size: 11px; letter-spacing: .07em; text-transform: uppercase;
border-bottom: 2px solid var(--accent);
}
.week-table td {
padding: 7px 10px;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.week-table tr:last-child td { border-bottom: none; }
.week-table tr:hover td { background: var(--surface3); }
.rest-row td { color: var(--text-muted); font-style: italic; }
.week-label {
font-family: var(--font-display);
font-size: 12px; letter-spacing: .08em; text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
margin-top: 14px;
}
.week-label:first-child { margin-top: 0; }
.mode-label {
display: inline-block; font-size: 10px; padding: 1px 6px;
font-family: var(--font-display); letter-spacing: .05em; text-transform: uppercase;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
margin-bottom: 3px;
}
.mode-label.superset { border-color: var(--golden); color: var(--golden); }
.mode-label.stretching { border-color: var(--accent); color: var(--accent); }
/* ── Progress stats ── */
.progress-grid {
display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr));
gap: 8px; margin-top: 12px;
}
.stat-card {
background: var(--surface2);
border: 1px solid var(--border);
border-top: 2px solid var(--accent);
padding: 12px; text-align: center;
}
.stat-value {
font-family: var(--font-display);
font-size: 24px; font-weight: 900;
color: var(--accent);
letter-spacing: .02em;
}
.stat-label {
font-size: 10px; color: var(--text-muted);
text-transform: uppercase; letter-spacing: .07em;
margin-top: 4px;
}
/* ── Input area ── */
.input-area {
background: var(--surface);
border-top: 3px solid var(--accent);
padding: 14px 20px; flex-shrink: 0;
}
.input-row { display: flex; gap: 8px; }
#userInput {
flex: 1;
background: var(--surface2);
border: 1px solid var(--border);
color: var(--text);
padding: 10px 14px; font-size: 14px;
font-family: var(--font-body);
resize: none; min-height: 44px; max-height: 140px;
transition: border-color .2s;
}
#userInput::placeholder { color: var(--text-muted); }
#userInput:focus { outline: none; border-color: var(--accent); }
#sendBtn {
background: var(--accent);
color: var(--bg);
border: none;
padding: 0 20px;
font-size: 18px; cursor: pointer;
font-weight: 900;
transition: background .2s, opacity .2s;
flex-shrink: 0;
}
#sendBtn:hover { background: var(--accent-dim); }
#sendBtn:disabled { opacity: .35; cursor: default; }
.input-hint {
font-size: 11px; color: var(--text-muted);
text-transform: uppercase; letter-spacing: .04em;
margin-top: 7px;
}
/* ── Suggestions ── */
.suggestions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 14px; }
.suggestion {
font-size: 12px; padding: 6px 14px;
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
text-transform: uppercase;
letter-spacing: .04em;
font-family: var(--font-body);
transition: border-color .15s, color .15s;
}
.suggestion:hover { border-color: var(--accent); color: var(--accent); }
/* ── Typing indicator ── */
.typing { display: flex; gap: 5px; align-items: center; padding: 4px 0; }
.typing span {
width: 7px; height: 7px;
background: var(--accent);
animation: bounce .9s infinite;
}
.typing span:nth-child(2) { animation-delay: .15s; background: var(--accent-dim); }
.typing span:nth-child(3) { animation-delay: .30s; background: var(--accent); opacity: .6; }
@keyframes bounce {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-6px); }
}
/* ── Safety warning ── */
.safety-warning {
background: #1a0c06;
border: 1px solid var(--danger);
border-left: 3px solid var(--danger);
padding: 12px 14px; margin-top: 10px;
font-size: 12px; color: #e89070;
}
.safety-warning strong { color: var(--danger); }
@media (max-width: 480px) {
.settings { gap: 10px; }
.settings input { width: 120px; }
#messages { padding: 12px; }
.bubble { max-width: 92vw; }
}
</style>
</head>
<body>
<header>
<div class="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 442.04 188.64" fill="#efecec">
<g><rect width="442.04" height="19.64"/><rect y="169" width="410.87" height="19.64"/></g>
<g>
<path d="M0,63.83Q0,42.92,20.61,42.91T41.28,63.83V77.24H26.91V63.83q0-6.7-6.25-6.75h-.1q-6.19,0-6.19,6.75v9q0,6.64,13.45,16.3t13.46,23v12.59q0,21-20.62,21T0,124.76V111.35H14.37v13.41q0,6.75,6.29,6.75t6.25-6.75V113.38q0-7.2-13.46-16.85T0,73.17Z"/>
<path d="M88.91,44.49V58.65H75.46V144.1H61.09V58.65H47.63V44.49Z"/>
<path d="M137.05,124.76q0,20.91-20.66,21t-20.62-21V44.49h14.37v80.27q0,6.75,6.25,6.75t6.29-6.75V44.49h14.37Z"/>
<path d="M148,44.49h22.13q19.14,0,19.14,21v57.72q0,20.87-20.05,20.92H148Zm14.36,14.16v71.29h6.3q6.24,0,6.24-6.76V65.46q0-6.81-6.24-6.81Z"/>
<path d="M199.92,44.49h14.37V144.1H199.92Z"/>
<path d="M266.24,124.76q0,20.91-20.66,21t-20.62-21V63.83q0-20.91,20.62-20.92t20.66,20.92ZM251.87,63.83q0-6.7-6.24-6.75h-.1q-6.19,0-6.2,6.75v60.93q0,6.75,6.25,6.75t6.29-6.75Z"/>
<path d="M316.92,124.76q0,20.91-20.66,21t-20.62-21V63.83q0-20.91,20.62-20.92t20.66,20.92V77.24H302.55V63.83q0-6.7-6.24-6.75h-.1q-6.19,0-6.2,6.75v60.93q0,6.75,6.25,6.75t6.29-6.75V101.4h-7.26V87.19h21.63Z"/>
<path d="M338,44.49l9.8,38.84,9.8-38.84h15.23L355.41,101l-.4,1.21V144.1H340.64V102.21l-.46-1.21L322.77,44.49Z"/>
<path d="M411,112.72l16.7-68.23H442V144.1H428.28V88.2L416,144.1h-9.74L393.6,88.2v55.9H379.69V44.49h14.37Z"/>
</g>
</svg>
</div>
<div class="header-title">
<h1>StudioGym Coach IA</h1>
<p id="statusText">Connexion en cours...</p>
</div>
<div class="status-dot" id="statusDot"></div>
</header>
<div class="settings">
<label>
URL API
<input id="apiUrl" type="text" value="http://159.203.36.21/api" />
</label>
<label>
ID utilisateur (optionnel)
<input id="userId" type="text" placeholder="MongoDB ObjectId" style="width:180px" />
</label>
<label>
Langue
<select id="locale">
<option value="fr">Français</option>
<option value="en">English</option>
</select>
</label>
</div>
<div id="messages">
<div class="suggestions" id="suggestions">
<button class="suggestion" onclick="suggest(this)">Programmes débutant sans équipement</button>
<button class="suggestion" onclick="suggest(this)">Exercices pour les biceps et la poitrine</button>
<button class="suggestion" onclick="suggest(this)">J'ai mal au genou, qu'est-ce que je dois éviter ?</button>
<button class="suggestion" onclick="suggest(this)">Montre mes progrès ce mois-ci</button>
<button class="suggestion" onclick="suggest(this)">Comment faire un squat correctement ?</button>
</div>
<div class="msg ai">
<div class="avatar">SG</div>
<div class="bubble">
Bonjour ! Je suis votre <strong>Coach IA StudioGym</strong>.<br><br>
Comment puis-je vous aider aujourd'hui ?
</div>
</div>
</div>
<div class="input-area">
<div class="input-row">
<textarea id="userInput" placeholder="Posez votre question sur votre entraînement..." rows="1"></textarea>
<button id="sendBtn" onclick="sendMessage()" title="Envoyer">➤</button>
</div>
<div class="input-hint">Entrée pour envoyer · Maj+Entrée pour un saut de ligne</div>
</div>
<script>
let conversationHistory = [];
// ── Health check on load ──
async function checkHealth() {
const base = document.getElementById('apiUrl').value.trim();
try {
const r = await fetch(base + '/health');
const data = await r.json();
const ok = data.mongodb === 'connected';
document.getElementById('statusDot').className = 'status-dot ' + (ok ? 'ok' : 'err');
document.getElementById('statusText').textContent = ok
? `Connecté · ${data.tools?.length ?? 0} outils`
: 'MongoDB déconnecté';
} catch {
document.getElementById('statusDot').className = 'status-dot err';
document.getElementById('statusText').textContent = 'Serveur inaccessible';
}
}
checkHealth();
setInterval(checkHealth, 30000);
// ── Auto-resize textarea ──
const input = document.getElementById('userInput');
input.addEventListener('input', () => {
input.style.height = 'auto';
input.style.height = Math.min(input.scrollHeight, 140) + 'px';
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
});
function suggest(btn) {
input.value = btn.textContent;
document.getElementById('suggestions').style.display = 'none';
sendMessage();
}
// ── Main send ──
async function sendMessage() {
const message = input.value.trim();
if (!message) return;
const base = document.getElementById('apiUrl').value.trim();
const locale = document.getElementById('locale').value;
const userId = document.getElementById('userId').value.trim() || undefined;
appendMessage('user', message);
input.value = '';
input.style.height = 'auto';
document.getElementById('sendBtn').disabled = true;
document.getElementById('suggestions').style.display = 'none';
const typingId = appendTyping();
let rateLimited = false;
try {
const res = await fetch(base + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, conversationHistory, locale, userId }),
});
removeTyping(typingId);
if (res.status === 429) {
const err = await res.json().catch(() => ({}));
rateLimited = true;
appendMessage('ai', err.response || "Notre coach IA reçoit beaucoup de demandes en ce moment ! Veuillez patienter quelques secondes et réessayer votre question. 💪");
startCooldown(Number(err.retryAfter) > 0 ? Number(err.retryAfter) : 10);
return;
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
appendMessage('ai', `⚠️ Erreur : ${err.error ?? res.statusText}`);
return;
}
const data = await res.json();
conversationHistory = data.conversationHistory ?? conversationHistory;
renderAIResponse(data);
} catch (err) {
removeTyping(typingId);
appendMessage('ai', `⚠️ Impossible de contacter le serveur à **${base}**. Le serveur REST est-il démarré ?`);
} finally {
if (!rateLimited) {
document.getElementById('sendBtn').disabled = false;
input.focus();
}
}
}
// ── Rate-limit cooldown — keeps the send button disabled and shows a
// countdown until the server-recommended retry-after delay elapses ──
function startCooldown(seconds) {
const sendBtn = document.getElementById('sendBtn');
const hint = document.querySelector('.input-hint');
const originalHint = hint.textContent;
sendBtn.disabled = true;
let remaining = seconds;
hint.textContent = `Veuillez patienter... (${remaining}s)`;
const interval = setInterval(() => {
remaining -= 1;
if (remaining <= 0) {
clearInterval(interval);
hint.textContent = originalHint;
sendBtn.disabled = false;
input.focus();
} else {
hint.textContent = `Veuillez patienter... (${remaining}s)`;
}
}, 1000);
}
// ── Render helpers ──
function appendMessage(role, text) {
const msgs = document.getElementById('messages');
const div = document.createElement('div');
div.className = 'msg ' + role;
div.innerHTML = `
<div class="avatar">${role === 'user' ? '👤' : 'SG'}</div>
<div class="bubble">${formatText(text)}</div>`;
msgs.appendChild(div);
scrollBottom();
return div;
}
function appendTyping() {
const msgs = document.getElementById('messages');
const id = 'typing-' + Date.now();
const div = document.createElement('div');
div.className = 'msg ai'; div.id = id;
div.innerHTML = `
<div class="avatar">SG</div>
<div class="bubble">
<div class="typing"><span></span><span></span><span></span></div>
</div>`;
msgs.appendChild(div);
scrollBottom();
return id;
}
function removeTyping(id) {
document.getElementById(id)?.remove();
}
function scrollBottom() {
const msgs = document.getElementById('messages');
msgs.scrollTop = msgs.scrollHeight;
}
// ── Smart renderer — detects structured data and renders cards ──
function renderAIResponse(apiResponse) {
const msgs = document.getElementById('messages');
const wrap = document.createElement('div');
wrap.className = 'msg ai';
wrap.innerHTML = `<div class="avatar">SG</div><div class="bubble" id="ai-bubble-last"></div>`;
msgs.appendChild(wrap);
scrollBottom();
const bubble = wrap.querySelector('#ai-bubble-last');
// Text response
if (apiResponse.response) {
bubble.innerHTML = formatText(apiResponse.response);
}
// Structured data rendering
const data = apiResponse.data;
if (!data) return;
// Exercises list
if (data.exercises?.length) {
const cards = renderExerciseCards(data.exercises);
bubble.appendChild(cards);
}
// Programs list
if (data.programs?.length) {
const cards = renderProgramCards(data.programs);
bubble.appendChild(cards);
}
// Program schedule
if (data.weeks?.length) {
const table = renderProgramSchedule(data);
bubble.appendChild(table);
}
// User progress
if (data.summary?.totalSessions !== undefined) {
const stats = renderProgressStats(data.summary);
bubble.appendChild(stats);
}
// Contraindicated exercises
if (data.contraindicatedExercises?.length) {
const warn = renderContraindicated(data);
bubble.appendChild(warn);
}
scrollBottom();
}
function renderExerciseCards(exercises) {
const wrap = document.createElement('div');
wrap.className = 'cards';
for (const ex of exercises.slice(0, 8)) {
const card = document.createElement('div');
card.className = 'card';
card.innerHTML = `
<div class="card-body">
<div class="card-header">
<div>
<div class="card-title">${esc(ex.name)}</div>
<div class="card-sub">${esc(ex.shortText ?? '')}</div>
</div>
${ex.needWeights ? '<span class="badge">🏋️ Équipement</span>' : '<span class="badge no-equip">🏠 Poids du corps</span>'}
</div>
<div class="tags">
${(ex.muscles ?? []).map(m => `<span class="tag muscle">💪 ${esc(m)}</span>`).join('')}
${(ex.bodyParts ?? []).map(b => `<span class="tag bodypart">🎯 ${esc(b)}</span>`).join('')}
${ex.category ? `<span class="tag">${esc(ex.category)}</span>` : ''}
</div>
</div>`;
wrap.appendChild(card);
}
return wrap;
}
function renderProgramCards(programs) {
const wrap = document.createElement('div');
wrap.className = 'cards';
for (const p of programs) {
const levelClass = (p.level ?? '').toLowerCase();
const card = document.createElement('div');
card.className = 'card program-card';
// Image or placeholder
let imageHtml;
if (p.imageUrl) {
imageHtml = `<img
class="card-img"
src="${esc(p.imageUrl)}"
alt="${esc(p.description)}"
loading="lazy"
onerror="this.style.display='none';this.nextElementSibling.style.display='flex'"
/><div class="card-img-placeholder" style="display:none">${esc(p.description)}</div>`;
} else {
imageHtml = `<div class="card-img-placeholder">${esc(p.description)}</div>`;
}
card.innerHTML = `
${imageHtml}
<div class="card-body">
<div class="card-header">
<div>
<div class="card-title">${esc(p.description)}</div>
<div class="card-sub">${p.weekCount ?? '?'} semaines · ${p.duration ?? '?'} min · ${p.calories ? p.calories + ' kcal' : ''}</div>
</div>
<span class="badge ${levelClass}">${p.level ?? ''}</span>
</div>
${p.objective ? `<div class="card-sub" style="margin-top:4px">🎯 ${esc(p.objective)}</div>` : ''}
<div class="tags">
${p.noEquipmentNeeded ? '<span class="badge no-equip">🏠 Sans équipement</span>' : ''}
${p.gender === 'F' ? '<span class="tag">👩 Femme</span>' : p.gender === 'M' ? '<span class="tag">👨 Homme</span>' : ''}
${(p.tags ?? []).map(t => `<span class="tag">${esc(t)}</span>`).join('')}
</div>
</div>`;
wrap.appendChild(card);
}
return wrap;
}
function renderProgramSchedule(data) {
const wrap = document.createElement('div');
wrap.style.marginTop = '12px';
for (const week of data.weeks ?? []) {
const title = document.createElement('p');
title.className = 'week-label';
title.textContent = `Semaine ${week.weekNumber}`;
wrap.appendChild(title);
const table = document.createElement('table');
table.className = 'week-table';
table.innerHTML = '<thead><tr><th>Jour</th><th>Exercices</th></tr></thead>';
const tbody = document.createElement('tbody');
for (const day of week.days ?? []) {
const tr = document.createElement('tr');
if (day.isRestDay) tr.className = 'rest-row';
const exercises = (day.blocks ?? []).flatMap(b =>
(b.exercises ?? []).map(ex => {
const modeClass = b.mode?.includes('SUPERSET') ? 'superset' : b.mode?.includes('STRETCHING') ? 'stretching' : '';
const modeLabel = b.modeLabel !== 'Exercise' ? `<span class="mode-label ${modeClass}">${esc(b.modeLabel)}</span><br>` : '';
const reps = ex.reps ? ` · ${ex.reps} reps` : '';
const sets = ex.sets ? `${ex.sets} sets` : '';
const hold = ex.hold ? ` · ${ex.hold}s hold` : '';
return `${modeLabel}${esc(ex.name)} — ${sets}${reps}${hold}`;
})
);
tr.innerHTML = `
<td style="white-space:nowrap;color:var(--text-muted)">${esc(day.dayName)}</td>
<td>${day.isRestDay ? '<em>Jour de repos</em>' : exercises.join('<br>') || '<em>Repos</em>'}</td>`;
tbody.appendChild(tr);
}
table.appendChild(tbody);
wrap.appendChild(table);
}
return wrap;
}
function renderProgressStats(summary) {
const grid = document.createElement('div');
grid.className = 'progress-grid';
const stats = [
{ value: summary.totalSessions, label: 'Séances' },
{ value: summary.sessionsPerWeek, label: 'Par semaine' },
{ value: summary.totalCaloriesBurned ? summary.totalCaloriesBurned + ' kcal' : '—', label: 'Calories brûlées' },
{ value: summary.totalTimeMin ? summary.totalTimeMin + ' min' : '—', label: 'Temps d\'entraînement' },
{ value: summary.avgExertion ?? '—', label: 'Effort moyen' },
{ value: summary.uniqueExercises, label: 'Exercices réalisés' },
];
for (const s of stats) {
const card = document.createElement('div');
card.className = 'stat-card';
card.innerHTML = `<div class="stat-value">${s.value ?? '—'}</div><div class="stat-label">${s.label}</div>`;
grid.appendChild(card);
}
return grid;
}
function renderContraindicated(data) {
const div = document.createElement('div');
div.className = 'safety-warning';
div.innerHTML = `<strong>⚠️ Alerte sécurité :</strong> ${esc(data.warning ?? '')}<br><br>` +
(data.contraindicatedExercises ?? []).slice(0, 6).map(ex =>
`• <strong>${esc(ex.name)}</strong> — ${esc(ex.careNote ?? '').slice(0, 120)}…`
).join('<br>');
return div;
}
// ── Text formatter — converts basic markdown to HTML ──
function formatText(text) {
return text
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.+?)\*/g, '<em>$1</em>')
.replace(/`(.+?)`/g, '<code style="background:var(--surface2);padding:1px 4px;font-family:monospace">$1</code>')
.replace(/\n\n/g, '</p><p>')
.replace(/\n/g, '<br>')
.replace(/^/, '<p>').replace(/$/, '</p>');
}
function esc(str) {
return String(str ?? '')
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
</script>
</body>
</html>
HTTP/1.1 200 OK
Date: Sat, 04 Jul 2026 00:43:14 GMT
Content-Type: text/html
Transfer-Encoding: chunked
Connection: keep-alive
Server: cloudflare
Last-Modified: Wed, 01 Jul 2026 05:10:58 GMT
Nel: {"report_to":"cf-nel","success_fraction":0.0,"max_age":604800}
Vary: Accept-Encoding
Server-Timing: cfCacheStatus;desc="DYNAMIC"
Server-Timing: cfEdge;dur=164,cfOrigin;dur=7
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
X-Xss-Protection: 1; mode=block
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()
Report-To: {"group":"cf-nel","max_age":604800,"endpoints":[{"url":"https://a.nel.cloudflare.com/report/v4?s=afIWds56Y1CCdcIA18J%2Bh8F%2BHDfiVuqm7Zui8psdUahLOUHdJaXqlg47cRhlLwt8vbImYzjmHgWt%2FneZlkU05EpH8HUDUI7Pvjv2zA9nK4q1ckRyevddFAgrUmBomwSlyohgk6%2FQ"}]}
Cf-Cache-Status: DYNAMIC
Content-Encoding: br
CF-RAY: a15a18372bc7e391-NRT
alt-svc: h3=":443"; ma=86400